init: iloom WMS monorepo with K8s deployment manifests

Go workspace (go.work) with 5 microservices + shared library:
- gateway (8080), auth-service (8081), purchaser-service (8082)
- textile-service (8083), washing-service (8084)
- shared: proto definitions, common packages

Infrastructure: docker-compose for local dev, K8s manifests for
K3s cluster deployment (mysql/redis/etcd + traefik ingress).

Frontend (iloom-flatten) added as git submodule.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chever John 2026-06-14 11:33:31 +08:00
commit 9c39c8cbd7
No known key found for this signature in database
GPG Key ID: ADC4815BFE960182
169 changed files with 23204 additions and 0 deletions

15
.dockerignore Normal file
View File

@ -0,0 +1,15 @@
.git
.github
.claude
*.md
*.swp
*~
.env
.env.*
!.env.example
iloom-flatten/node_modules
iloom-flatten/dist
iloom-flatten/.cache
**/*_test.go
**/*.test.ts
**/*.test.tsx

28
.env.example Normal file
View File

@ -0,0 +1,28 @@
# iloom 微服务环境变量配置
# MySQL (共享 muyu_wms 数据库)
DB_DSN=root:muyu2026@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
# muyu gRPC 地址
MUYU_SYSTEM_RPC_ADDR=system-rpc:9001
MUYU_INVENTORY_RPC_ADDR=inventory-rpc:9002
# 服务间调用密钥
INTERNAL_SERVICE_KEY=internal-service-shared-secret
# 服务端口
GATEWAY_PORT=8080
AUTH_SERVICE_PORT=8081
PURCHASER_SERVICE_PORT=8082
TEXTILE_SERVICE_PORT=8083
WASHING_SERVICE_PORT=8084
# 前端
FRONTEND_URL=http://localhost:3015
# CORS
ALLOW_ORIGINS=http://localhost:3015,http://localhost:3000

79
.gitignore vendored Normal file
View File

@ -0,0 +1,79 @@
# ========================
# Go
# ========================
*.exe
*.exe~
*.dll
*.so
*.dylib
*.test
*.out
*.prof
coverage.out
coverage.html
bin/
vendor/
# ========================
# Node / Frontend
# ========================
node_modules/
dist/
build/
.cache/
coverage/
*.tsbuildinfo
.eslintcache
# ========================
# Frontend build artifacts
# ========================
.microcompact/
.meoo/
outputs/
test-results/
playwright-report/
# ========================
# Environment & Secrets
# ========================
.env
.env.local
.env.*.local
!.env.example
!.env.production
# K8s secrets (real values)
k8s/01-secrets.yaml
# ========================
# IDE & Editor
# ========================
.vscode/
.idea/
*.swp
*.swo
*~
.project
.classpath
# ========================
# OS
# ========================
.DS_Store
Thumbs.db
*.log
*.tmp
*.bak
# ========================
# Docker
# ========================
docker-compose.override.yml
# ========================
# Planning / task tracking
# ========================
.todo/
.plan/
.claude/

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "iloom-flatten"]
path = iloom-flatten
url = https://github.com/kihaneseifu/iloom-flatten.git

74
Makefile Normal file
View File

@ -0,0 +1,74 @@
.PHONY: all build run stop test lint migrate-up clean help
SERVICES = gateway auth-service purchaser-service textile-service washing-service
help: ## 显示帮助信息
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
all: build ## 构建所有服务
build: ## 构建所有服务二进制
@for svc in $(SERVICES); do \
echo "==> Building $$svc..."; \
cd $$svc && go build -o bin/$$svc ./cmd/ && cd ..; \
done
@echo "==> All services built."
run: ## 启动所有服务 (Docker Compose)
docker-compose up --build -d
run-dev: ## 启动开发环境 (前台,含日志)
docker-compose up --build
stop: ## 停止所有服务
docker-compose down
restart: stop run ## 重启所有服务
logs: ## 查看所有服务日志
docker-compose logs -f
logs-%: ## 查看指定服务日志 (如: make logs-gateway)
docker-compose logs -f $*
test: ## 运行所有服务的测试
@for svc in $(SERVICES); do \
echo "==> Testing $$svc..."; \
cd $$svc && go test ./... -v && cd ..; \
done
lint: ## 代码检查
@for svc in $(SERVICES); do \
echo "==> Linting $$svc..."; \
cd $$svc && go vet ./... && cd ..; \
done
tidy: ## 整理所有模块依赖
@for svc in shared $(SERVICES); do \
echo "==> Tidying $$svc..."; \
cd $$svc && go mod tidy && cd ..; \
done
migrate-up: ## 执行数据库迁移
@echo "==> Running database migrations..."
@PGPASSWORD=$${DB_PASSWORD:-iloom_secret_2024} psql -h localhost -U $${POSTGRES_USER:-iloom} -d $${POSTGRES_DB:-iloom} -f scripts/init-schemas.sql
@echo "==> Migrations complete."
clean: ## 清理构建产物
@for svc in $(SERVICES); do \
rm -rf $$svc/bin; \
done
@echo "==> Cleaned."
dev-%: ## 单独运行指定服务 (如: make dev-gateway)
cd $* && go run ./cmd/
docker-build-%: ## 单独构建指定服务镜像 (如: make docker-build-gateway)
docker-compose build $*
health: ## 检查所有服务健康状态
@echo "Gateway: $$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/health)"
@echo "Auth: $$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8081/health)"
@echo "Purchaser: $$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8082/health)"
@echo "Textile: $$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8083/health)"
@echo "Washing: $$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8084/health)"

12
auth-service/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM golang:1.24-alpine AS builder
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
RUN apk add --no-cache wget
COPY --from=builder /auth-service /auth-service
EXPOSE 8081
CMD ["/auth-service"]

93
auth-service/cmd/main.go Normal file
View File

@ -0,0 +1,93 @@
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/auth-service/internal/config"
"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/shared/pkg/auth"
"github.com/muyuqingfeng/iloom/shared/pkg/database"
sgrpc "github.com/muyuqingfeng/iloom/shared/pkg/grpc"
sharedMW "github.com/muyuqingfeng/iloom/shared/pkg/middleware"
)
func main() {
cfg := config.Load()
db, err := database.NewDBFromDSN(cfg.DBDSN)
if err != nil {
log.Fatalf("connect database: %v", err)
}
defer db.Close()
sysClient, err := sgrpc.NewSystemClient(cfg.SystemRPCAddr)
if err != nil {
log.Fatalf("connect system-rpc: %v", err)
}
defer sysClient.Close()
jwtMgr := auth.NewJWTManager(cfg.JWTSecret, cfg.JWTRefreshSecret)
profileRepo := repository.NewProfileRepo(db)
companyRepo := repository.NewCompanyRepo(db)
memberRepo := repository.NewMemberRepo(db)
notifRepo := repository.NewNotificationRepo(db)
shareLinkRepo := repository.NewShareLinkRepo(db)
auditLogRepo := repository.NewAuditLogRepo(db)
authSvc := service.NewAuthService(profileRepo, companyRepo, memberRepo, sysClient, jwtMgr)
companySvc := service.NewCompanyService(companyRepo)
memberSvc := service.NewMemberService(memberRepo, sysClient)
notifSvc := service.NewNotificationService(notifRepo)
authH := handler.NewAuthHandler(authSvc)
companyH := handler.NewCompanyHandler(companySvc)
memberH := handler.NewMemberHandler(memberSvc)
notifH := handler.NewNotificationHandler(notifSvc)
commonH := handler.NewCommonHandler(shareLinkRepo, auditLogRepo)
r := gin.New()
r.Use(
sharedMW.RequestID(),
sharedMW.Logger(),
sharedMW.Recovery(),
)
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
authGroup := r.Group("/api/v1/auth")
{
authGroup.POST("/register", authH.Register)
authGroup.POST("/login", authH.Login)
authGroup.POST("/logout", authH.Logout)
authGroup.POST("/refresh", authH.Refresh)
authGroup.GET("/me", authH.Me)
}
commonGroup := r.Group("/api/v1/common")
{
commonGroup.GET("/companies", companyH.ListRelationships)
commonGroup.GET("/companies/:id", companyH.GetCompany)
commonGroup.GET("/members", memberH.List)
commonGroup.POST("/members", memberH.Create)
commonGroup.DELETE("/members/:id", memberH.Delete)
commonGroup.GET("/notifications", notifH.List)
commonGroup.PATCH("/notifications/:id", notifH.MarkRead)
commonGroup.POST("/share-links", commonH.CreateShareLink)
commonGroup.GET("/share-links/:code", commonH.GetShareLink)
commonGroup.POST("/feedback", commonH.Feedback)
commonGroup.POST("/crash-reports", commonH.CrashReport)
}
addr := ":" + cfg.Port
log.Printf("auth-service listening on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("server error: %v", err)
}
}

48
auth-service/go.mod Normal file
View File

@ -0,0 +1,48 @@
module github.com/muyuqingfeng/iloom/auth-service
go 1.23
toolchain go1.24.4
require github.com/muyuqingfeng/iloom/shared v0.0.0
replace github.com/muyuqingfeng/iloom/shared => ../shared
require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
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/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.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/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/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/grpc v1.72.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

117
auth-service/go.sum Normal file
View File

@ -0,0 +1,117 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
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/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-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=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
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/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=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
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/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/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/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=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
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/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=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -0,0 +1,28 @@
package config
import "os"
type Config struct {
Port string
DBDSN string
JWTSecret string
JWTRefreshSecret string
SystemRPCAddr string
}
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"),
SystemRPCAddr: getEnv("MUYU_SYSTEM_RPC_ADDR", "127.0.0.1:9001"),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@ -0,0 +1,116 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/auth-service/internal/model"
"github.com/muyuqingfeng/iloom/auth-service/internal/service"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
)
type AuthHandler struct {
svc *service.AuthService
}
func NewAuthHandler(svc *service.AuthService) *AuthHandler {
return &AuthHandler{svc: svc}
}
type RegisterRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
CompanyName string `json:"company_name" binding:"required"`
Role string `json:"role" binding:"required,oneof=purchaser textile washing"`
}
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
User *model.Profile `json:"user"`
Company *model.Company `json:"company"`
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
func (h *AuthHandler) Register(c *gin.Context) {
var req RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
svcReq := service.RegisterRequest{
Username: req.Username,
Password: req.Password,
CompanyName: req.CompanyName,
Role: req.Role,
}
result, err := h.svc.Register(c.Request.Context(), svcReq)
if err != nil {
response.Error(c, http.StatusConflict, response.ErrCodeBadRequest, err.Error())
return
}
response.OK(c, result)
}
func (h *AuthHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
result, err := h.svc.Login(c.Request.Context(), req.Username, req.Password)
if err != nil {
response.Unauthorized(c, err.Error())
return
}
response.OK(c, result)
}
func (h *AuthHandler) Logout(c *gin.Context) {
response.OK(c, gin.H{"message": "logged out"})
}
func (h *AuthHandler) Refresh(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
accessToken, err := h.svc.RefreshToken(c.Request.Context(), req.RefreshToken)
if err != nil {
response.Unauthorized(c, err.Error())
return
}
response.OK(c, gin.H{"access_token": accessToken})
}
func (h *AuthHandler) Me(c *gin.Context) {
userID := c.GetHeader("X-User-ID")
if userID == "" {
response.Unauthorized(c, "user id not found")
return
}
result, err := h.svc.GetMe(c.Request.Context(), userID)
if err != nil {
response.NotFound(c, err.Error())
return
}
response.OK(c, result)
}

View File

@ -0,0 +1,134 @@
package handler
import (
"time"
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/auth-service/internal/repository"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
)
type CommonHandler struct {
shareLinkRepo *repository.ShareLinkRepo
auditLogRepo *repository.AuditLogRepo
}
func NewCommonHandler(slRepo *repository.ShareLinkRepo, alRepo *repository.AuditLogRepo) *CommonHandler {
return &CommonHandler{shareLinkRepo: slRepo, auditLogRepo: alRepo}
}
type createShareLinkReq struct {
ResourceType string `json:"resource_type" binding:"required"`
ResourceID string `json:"resource_id" binding:"required"`
ExpiresIn int `json:"expires_in"` // seconds, 0 = no expiry
}
func (h *CommonHandler) CreateShareLink(c *gin.Context) {
var req createShareLinkReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "invalid request: "+err.Error())
return
}
userID := c.GetHeader("X-User-ID")
companyID := c.GetHeader("X-Company-ID")
var expiresAt *time.Time
if req.ExpiresIn > 0 {
t := time.Now().Add(time.Duration(req.ExpiresIn) * time.Second)
expiresAt = &t
}
link, err := h.shareLinkRepo.Create(c.Request.Context(), companyID, userID, req.ResourceType, req.ResourceID, expiresAt)
if err != nil {
response.InternalError(c, err.Error())
return
}
response.OK(c, link)
}
func (h *CommonHandler) GetShareLink(c *gin.Context) {
code := c.Param("code")
link, err := h.shareLinkRepo.GetByCode(c.Request.Context(), code)
if err != nil {
response.InternalError(c, err.Error())
return
}
if link == nil {
response.NotFound(c, "share link not found")
return
}
// check expiry
if link.ExpiresAt != nil && link.ExpiresAt.Before(time.Now()) {
response.BadRequest(c, "share link expired")
return
}
response.OK(c, link)
}
type feedbackReq struct {
Content string `json:"content" binding:"required"`
Type string `json:"type"`
}
func (h *CommonHandler) Feedback(c *gin.Context) {
var req feedbackReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "invalid request: "+err.Error())
return
}
userID := c.GetHeader("X-User-ID")
companyID := c.GetHeader("X-Company-ID")
feedbackType := req.Type
if feedbackType == "" {
feedbackType = "general"
}
err := h.auditLogRepo.Create(
c.Request.Context(), userID, companyID,
"feedback", feedbackType, "",
c.ClientIP(), map[string]string{"content": req.Content},
)
if err != nil {
response.InternalError(c, err.Error())
return
}
response.OK(c, gin.H{"message": "feedback received"})
}
type crashReportReq struct {
Error string `json:"error" binding:"required"`
StackTrace string `json:"stack_trace"`
UserAgent string `json:"user_agent"`
URL string `json:"url"`
}
func (h *CommonHandler) CrashReport(c *gin.Context) {
var req crashReportReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "invalid request: "+err.Error())
return
}
userID := c.GetHeader("X-User-ID")
companyID := c.GetHeader("X-Company-ID")
err := h.auditLogRepo.Create(
c.Request.Context(), userID, companyID,
"crash_report", "frontend", "",
c.ClientIP(), req,
)
if err != nil {
response.InternalError(c, err.Error())
return
}
response.OK(c, gin.H{"message": "crash report received"})
}

View File

@ -0,0 +1,41 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/auth-service/internal/service"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
)
type CompanyHandler struct {
svc *service.CompanyService
}
func NewCompanyHandler(svc *service.CompanyService) *CompanyHandler {
return &CompanyHandler{svc: svc}
}
func (h *CompanyHandler) GetCompany(c *gin.Context) {
id := c.Param("id")
company, err := h.svc.GetByID(c.Request.Context(), id)
if err != nil {
response.NotFound(c, err.Error())
return
}
response.OK(c, company)
}
func (h *CompanyHandler) ListRelationships(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "company id not found")
return
}
rels, err := h.svc.ListRelationships(c.Request.Context(), companyID)
if err != nil {
response.InternalError(c, err.Error())
return
}
response.OK(c, rels)
}

View File

@ -0,0 +1,84 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/auth-service/internal/service"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
)
type MemberHandler struct {
svc *service.MemberService
}
func NewMemberHandler(svc *service.MemberService) *MemberHandler {
return &MemberHandler{svc: svc}
}
type CreateMemberRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required,min=6"`
RealName string `json:"real_name"`
Phone string `json:"phone"`
}
func (h *MemberHandler) List(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "company id not found")
return
}
members, err := h.svc.List(c.Request.Context(), companyID)
if err != nil {
response.InternalError(c, err.Error())
return
}
response.OK(c, members)
}
func (h *MemberHandler) Create(c *gin.Context) {
var req CreateMemberRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
companyID := c.GetHeader("X-Company-ID")
roleKey := c.GetHeader("X-User-Role")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
svcReq := service.CreateMemberRequest{
Username: req.Username,
Password: req.Password,
RealName: req.RealName,
Phone: req.Phone,
}
member, err := h.svc.Create(c.Request.Context(), companyID, roleKey, svcReq)
if err != nil {
response.InternalError(c, err.Error())
return
}
response.OK(c, member)
}
func (h *MemberHandler) Delete(c *gin.Context) {
memberID := c.Param("id")
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "company id not found")
return
}
if err := h.svc.Delete(c.Request.Context(), memberID, companyID); err != nil {
response.InternalError(c, err.Error())
return
}
response.OK(c, gin.H{"message": "member deleted"})
}

View File

@ -0,0 +1,47 @@
package handler
import (
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/auth-service/internal/service"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
)
type NotificationHandler struct {
svc *service.NotificationService
}
func NewNotificationHandler(svc *service.NotificationService) *NotificationHandler {
return &NotificationHandler{svc: svc}
}
func (h *NotificationHandler) List(c *gin.Context) {
userID := c.GetHeader("X-User-ID")
if userID == "" {
response.BadRequest(c, "user id not found")
return
}
notifications, err := h.svc.List(c.Request.Context(), userID)
if err != nil {
response.InternalError(c, err.Error())
return
}
response.OK(c, notifications)
}
func (h *NotificationHandler) MarkRead(c *gin.Context) {
id := c.Param("id")
userID := c.GetHeader("X-User-ID")
if userID == "" {
response.BadRequest(c, "user id not found")
return
}
if err := h.svc.MarkRead(c.Request.Context(), id, userID); err != nil {
response.InternalError(c, err.Error())
return
}
response.OK(c, gin.H{"message": "marked as read"})
}

View File

@ -0,0 +1,32 @@
package model
import "time"
type Company struct {
ID string `json:"id"`
Name string `json:"name"`
Role string `json:"role"`
Address string `json:"address,omitempty"`
Phone string `json:"phone,omitempty"`
Status int `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
type CompanyMember struct {
ID string `json:"id"`
CompanyID string `json:"company_id"`
UserID string `json:"user_id"`
Role string `json:"role"`
IsMaster bool `json:"is_master"`
CreatedAt time.Time `json:"created_at"`
}
type CompanyRelationship struct {
ID string `json:"id"`
PurchaserCompanyID string `json:"purchaser_company_id"`
FactoryCompanyID string `json:"factory_company_id"`
FactoryType string `json:"factory_type"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

View File

@ -0,0 +1,35 @@
package model
import "time"
type Notification struct {
ID string `json:"id" db:"id"`
RecipientID string `json:"recipient_id" db:"recipient_id"`
SenderID *string `json:"sender_id,omitempty" db:"sender_id"`
SenderCompanyID *string `json:"sender_company_id,omitempty" db:"sender_company_id"`
Title string `json:"title" db:"title"`
Content string `json:"content" db:"content"`
Type string `json:"type" db:"type"`
PlanID *string `json:"plan_id,omitempty" db:"plan_id"`
IsRead bool `json:"is_read" db:"is_read"`
ReadAt *time.Time `json:"read_at,omitempty" db:"read_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type ShareLink struct {
ID string `json:"id" db:"id"`
PlanID string `json:"plan_id" db:"plan_id"`
CreatedBy string `json:"created_by" db:"created_by"`
ShortCode string `json:"short_code" db:"short_code"`
FactoryType string `json:"factory_type" db:"factory_type"`
HideSensitive bool `json:"hide_sensitive" db:"hide_sensitive"`
Status string `json:"status" db:"status"`
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"`
ClickedAt *time.Time `json:"clicked_at,omitempty" db:"clicked_at"`
ClickCount int `json:"click_count" db:"click_count"`
UsedAt *time.Time `json:"used_at,omitempty" db:"used_at"`
ResponseResult *string `json:"response_result,omitempty" db:"response_result"`
RejectReason *string `json:"reject_reason,omitempty" db:"reject_reason"`
RespondedAt *time.Time `json:"responded_at,omitempty" db:"responded_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}

View File

@ -0,0 +1,18 @@
package model
import "time"
type Profile struct {
ID string `json:"id"`
Username string `json:"username"`
RealName string `json:"real_name,omitempty"`
Phone string `json:"phone,omitempty"`
Email string `json:"email,omitempty"`
Avatar string `json:"avatar,omitempty"`
Status int `json:"status"`
CompanyID string `json:"company_id,omitempty"`
CompanyName string `json:"company_name,omitempty"`
Role string `json:"role,omitempty"`
IsMaster bool `json:"is_master"`
CreatedAt time.Time `json:"created_at"`
}

View File

@ -0,0 +1,41 @@
package repository
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"github.com/google/uuid"
)
type AuditLogRepo struct {
db *sql.DB
}
func NewAuditLogRepo(db *sql.DB) *AuditLogRepo {
return &AuditLogRepo{db: db}
}
func (r *AuditLogRepo) Create(ctx context.Context, userID, companyID, action, resourceType, resourceID, ip string, newValue any) error {
id := uuid.New().String()
var valueJSON []byte
if newValue != nil {
var err error
valueJSON, err = json.Marshal(newValue)
if err != nil {
return fmt.Errorf("marshal audit value: %w", err)
}
}
_, err := r.db.ExecContext(ctx,
`INSERT INTO ilm_audit_log (id, user_id, company_id, action, resource_type, resource_id, new_value, ip)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
id, userID, companyID, action, resourceType, resourceID, valueJSON, ip,
)
if err != nil {
return fmt.Errorf("create audit log: %w", err)
}
return nil
}

View File

@ -0,0 +1,69 @@
package repository
import (
"context"
"database/sql"
"fmt"
"github.com/muyuqingfeng/iloom/auth-service/internal/model"
)
type CompanyRepo struct {
db *sql.DB
}
func NewCompanyRepo(db *sql.DB) *CompanyRepo {
return &CompanyRepo{db: db}
}
func (r *CompanyRepo) Create(ctx context.Context, c *model.Company) error {
query := `INSERT INTO ilm_company (id, name, type, address, phone, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`
_, err := r.db.ExecContext(ctx, query,
c.ID, c.Name, c.Role, c.Address, c.Phone, c.Status, c.CreatedAt,
)
if err != nil {
return fmt.Errorf("insert company: %w", err)
}
return nil
}
func (r *CompanyRepo) GetByID(ctx context.Context, id string) (*model.Company, error) {
query := `SELECT id, name, type, address, phone, status, created_at FROM ilm_company WHERE id = ?`
c := &model.Company{}
err := r.db.QueryRowContext(ctx, query, id).Scan(
&c.ID, &c.Name, &c.Role, &c.Address, &c.Phone, &c.Status, &c.CreatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("get company by id: %w", err)
}
return c, nil
}
func (r *CompanyRepo) ListRelationships(ctx context.Context, companyID string) ([]model.CompanyRelationship, error) {
query := `SELECT id, purchaser_company_id, factory_company_id, factory_type, status, created_at, updated_at
FROM ilm_company_relationship
WHERE purchaser_company_id = ? OR factory_company_id = ?
ORDER BY created_at DESC`
rows, err := r.db.QueryContext(ctx, query, companyID, companyID)
if err != nil {
return nil, fmt.Errorf("list relationships: %w", err)
}
defer rows.Close()
var rels []model.CompanyRelationship
for rows.Next() {
var rel model.CompanyRelationship
if err := rows.Scan(
&rel.ID, &rel.PurchaserCompanyID, &rel.FactoryCompanyID,
&rel.FactoryType, &rel.Status, &rel.CreatedAt, &rel.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan relationship: %w", err)
}
rels = append(rels, rel)
}
return rels, nil
}

View File

@ -0,0 +1,85 @@
package repository
import (
"context"
"database/sql"
"fmt"
"github.com/muyuqingfeng/iloom/auth-service/internal/model"
)
type MemberRepo struct {
db *sql.DB
}
func NewMemberRepo(db *sql.DB) *MemberRepo {
return &MemberRepo{db: db}
}
func (r *MemberRepo) Create(ctx context.Context, m *model.CompanyMember) error {
query := `INSERT INTO ilm_company_member (id, company_id, user_id, role, is_master, created_at)
VALUES (?, ?, ?, ?, ?, ?)`
_, err := r.db.ExecContext(ctx, query,
m.ID, m.CompanyID, m.UserID, m.Role, m.IsMaster, m.CreatedAt,
)
if err != nil {
return fmt.Errorf("insert member: %w", err)
}
return nil
}
func (r *MemberRepo) Delete(ctx context.Context, id, companyID string) error {
_, err := r.db.ExecContext(ctx,
`DELETE FROM ilm_company_member WHERE id = ? AND company_id = ?`,
id, companyID,
)
if err != nil {
return fmt.Errorf("delete member: %w", err)
}
return nil
}
func (r *MemberRepo) GetByUserAndCompany(ctx context.Context, userID, companyID string) (*model.CompanyMember, error) {
query := `SELECT id, company_id, user_id, role, is_master, created_at
FROM ilm_company_member WHERE user_id = ? AND company_id = ?`
m := &model.CompanyMember{}
err := r.db.QueryRowContext(ctx, query, userID, companyID).Scan(
&m.ID, &m.CompanyID, &m.UserID, &m.Role, &m.IsMaster, &m.CreatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("get member: %w", err)
}
return m, nil
}
func (r *MemberRepo) ListByCompany(ctx context.Context, companyID string) ([]model.Profile, error) {
query := `SELECT su.user_id, su.username, su.real_name, su.phone, su.email, su.avatar, su.status, su.created_at,
cm.company_id, COALESCE(c.name, ''), COALESCE(c.type, ''), cm.is_master
FROM ilm_company_member cm
JOIN sys_user su ON su.user_id = cm.user_id AND su.deleted_at IS NULL
LEFT JOIN ilm_company c ON c.id = cm.company_id
WHERE cm.company_id = ?
ORDER BY cm.created_at`
rows, err := r.db.QueryContext(ctx, query, companyID)
if err != nil {
return nil, fmt.Errorf("list members by company: %w", err)
}
defer rows.Close()
var profiles []model.Profile
for rows.Next() {
var p model.Profile
if err := rows.Scan(
&p.ID, &p.Username, &p.RealName, &p.Phone, &p.Email,
&p.Avatar, &p.Status, &p.CreatedAt,
&p.CompanyID, &p.CompanyName, &p.Role, &p.IsMaster,
); err != nil {
return nil, fmt.Errorf("scan member profile: %w", err)
}
profiles = append(profiles, p)
}
return profiles, nil
}

View File

@ -0,0 +1,54 @@
package repository
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/muyuqingfeng/iloom/auth-service/internal/model"
)
type NotificationRepo struct {
db *sql.DB
}
func NewNotificationRepo(db *sql.DB) *NotificationRepo {
return &NotificationRepo{db: db}
}
func (r *NotificationRepo) ListByRecipient(ctx context.Context, userID string) ([]model.Notification, error) {
query := `SELECT id, recipient_id, sender_id, sender_company_id, title, content, type, plan_id, is_read, read_at, created_at
FROM ilm_notification WHERE recipient_id = ? ORDER BY created_at DESC`
rows, err := r.db.QueryContext(ctx, query, userID)
if err != nil {
return nil, fmt.Errorf("list notifications: %w", err)
}
defer rows.Close()
var notifications []model.Notification
for rows.Next() {
var n model.Notification
if err := rows.Scan(
&n.ID, &n.RecipientID, &n.SenderID, &n.SenderCompanyID,
&n.Title, &n.Content, &n.Type, &n.PlanID,
&n.IsRead, &n.ReadAt, &n.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan notification: %w", err)
}
notifications = append(notifications, n)
}
return notifications, nil
}
func (r *NotificationRepo) MarkRead(ctx context.Context, id, userID string) error {
now := time.Now()
_, err := r.db.ExecContext(ctx,
`UPDATE ilm_notification SET is_read = true, read_at = ? WHERE id = ? AND recipient_id = ?`,
now, id, userID,
)
if err != nil {
return fmt.Errorf("mark notification read: %w", err)
}
return nil
}

View File

@ -0,0 +1,74 @@
package repository
import (
"context"
"database/sql"
"fmt"
"github.com/muyuqingfeng/iloom/auth-service/internal/model"
)
type ProfileRepo struct {
db *sql.DB
}
func NewProfileRepo(db *sql.DB) *ProfileRepo {
return &ProfileRepo{db: db}
}
func (r *ProfileRepo) GetByID(ctx context.Context, userID string) (*model.Profile, error) {
query := `SELECT user_id, username, real_name, phone, email, avatar, status, created_at
FROM sys_user WHERE user_id = ? AND deleted_at IS NULL`
p := &model.Profile{}
err := r.db.QueryRowContext(ctx, query, userID).Scan(
&p.ID, &p.Username, &p.RealName, &p.Phone, &p.Email,
&p.Avatar, &p.Status, &p.CreatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("get profile by id: %w", err)
}
return p, nil
}
func (r *ProfileRepo) GetByUsername(ctx context.Context, username string) (*model.Profile, error) {
query := `SELECT user_id, username, real_name, phone, email, avatar, status, created_at
FROM sys_user WHERE username = ? AND deleted_at IS NULL`
p := &model.Profile{}
err := r.db.QueryRowContext(ctx, query, username).Scan(
&p.ID, &p.Username, &p.RealName, &p.Phone, &p.Email,
&p.Avatar, &p.Status, &p.CreatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("get profile by username: %w", err)
}
return p, nil
}
func (r *ProfileRepo) GetWithCompany(ctx context.Context, userID string) (*model.Profile, error) {
query := `SELECT su.user_id, su.username, su.real_name, su.phone, su.email, su.avatar, su.status, su.created_at,
COALESCE(cm.company_id, ''), COALESCE(c.name, ''), COALESCE(c.type, ''), COALESCE(cm.is_master, 0)
FROM sys_user su
LEFT JOIN ilm_company_member cm ON cm.user_id = su.user_id
LEFT JOIN ilm_company c ON c.id = cm.company_id
WHERE su.user_id = ? AND su.deleted_at IS NULL
LIMIT 1`
p := &model.Profile{}
err := r.db.QueryRowContext(ctx, query, userID).Scan(
&p.ID, &p.Username, &p.RealName, &p.Phone, &p.Email,
&p.Avatar, &p.Status, &p.CreatedAt,
&p.CompanyID, &p.CompanyName, &p.Role, &p.IsMaster,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("get profile with company: %w", err)
}
return p, nil
}

View File

@ -0,0 +1,77 @@
package repository
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"time"
"github.com/google/uuid"
)
type ShareLinkRepo struct {
db *sql.DB
}
func NewShareLinkRepo(db *sql.DB) *ShareLinkRepo {
return &ShareLinkRepo{db: db}
}
type ShareLink struct {
ID string `json:"id"`
Code string `json:"code"`
CompanyID string `json:"company_id"`
CreatedBy string `json:"created_by"`
ResourceType string `json:"resource_type"`
ResourceID string `json:"resource_id"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func (r *ShareLinkRepo) Create(ctx context.Context, companyID, createdBy, resourceType, resourceID string, expiresAt *time.Time) (*ShareLink, error) {
id := uuid.New().String()
code := generateShortCode()
_, err := r.db.ExecContext(ctx,
`INSERT INTO ilm_share_link (id, code, company_id, created_by, resource_type, resource_id, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, code, companyID, createdBy, resourceType, resourceID, expiresAt,
)
if err != nil {
return nil, fmt.Errorf("create share link: %w", err)
}
return &ShareLink{
ID: id,
Code: code,
CompanyID: companyID,
CreatedBy: createdBy,
ResourceType: resourceType,
ResourceID: resourceID,
ExpiresAt: expiresAt,
CreatedAt: time.Now(),
}, nil
}
func (r *ShareLinkRepo) GetByCode(ctx context.Context, code string) (*ShareLink, error) {
var sl ShareLink
err := r.db.QueryRowContext(ctx,
`SELECT id, code, company_id, created_by, resource_type, resource_id, expires_at, created_at
FROM ilm_share_link WHERE code = ?`, code,
).Scan(&sl.ID, &sl.Code, &sl.CompanyID, &sl.CreatedBy, &sl.ResourceType, &sl.ResourceID, &sl.ExpiresAt, &sl.CreatedAt)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("get share link: %w", err)
}
return &sl, nil
}
func generateShortCode() string {
b := make([]byte, 6)
rand.Read(b)
return hex.EncodeToString(b)
}

View File

@ -0,0 +1,220 @@
package service
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/muyuqingfeng/iloom/auth-service/internal/model"
"github.com/muyuqingfeng/iloom/auth-service/internal/repository"
"github.com/muyuqingfeng/iloom/shared/pkg/auth"
sgrpc "github.com/muyuqingfeng/iloom/shared/pkg/grpc"
)
var roleIDMap = map[string]string{
"purchaser": "r_010",
"textile": "r_011",
"washing": "r_012",
}
type AuthService struct {
profileRepo *repository.ProfileRepo
compRepo *repository.CompanyRepo
memberRepo *repository.MemberRepo
sysClient *sgrpc.SystemClient
jwt *auth.JWTManager
}
func NewAuthService(
profileRepo *repository.ProfileRepo,
compRepo *repository.CompanyRepo,
memberRepo *repository.MemberRepo,
sysClient *sgrpc.SystemClient,
jwt *auth.JWTManager,
) *AuthService {
return &AuthService{
profileRepo: profileRepo,
compRepo: compRepo,
memberRepo: memberRepo,
sysClient: sysClient,
jwt: jwt,
}
}
type RegisterRequest struct {
Username string `json:"username"`
Password string `json:"password"`
CompanyName string `json:"company_name"`
Role string `json:"role"`
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
User *model.Profile `json:"user"`
Company *model.Company `json:"company"`
}
type ProfileWithCompany struct {
User *model.Profile `json:"user"`
Company *model.Company `json:"company,omitempty"`
}
func (s *AuthService) Register(ctx context.Context, req RegisterRequest) (*LoginResponse, error) {
roleID, ok := roleIDMap[req.Role]
if !ok {
return nil, errors.New("invalid role")
}
userID, err := s.sysClient.CreateUser(ctx, req.Username, req.Password, "", "", roleID)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
now := time.Now()
companyID := uuid.New().String()
company := &model.Company{
ID: companyID,
Name: req.CompanyName,
Role: req.Role,
Status: 1,
CreatedAt: now,
}
if err := s.compRepo.Create(ctx, company); err != nil {
return nil, fmt.Errorf("create company: %w", err)
}
member := &model.CompanyMember{
ID: uuid.New().String(),
CompanyID: companyID,
UserID: userID,
Role: "owner",
IsMaster: true,
CreatedAt: now,
}
if err := s.memberRepo.Create(ctx, member); err != nil {
return nil, fmt.Errorf("create member: %w", err)
}
profile := &model.Profile{
ID: userID,
Username: req.Username,
CompanyID: companyID,
CompanyName: req.CompanyName,
Role: req.Role,
IsMaster: true,
CreatedAt: now,
}
return s.generateTokens(profile, company)
}
func (s *AuthService) Login(ctx context.Context, username, password string) (*LoginResponse, error) {
result, err := s.sysClient.Login(ctx, username, password, "")
if err != nil {
return nil, errors.New("invalid credentials")
}
profile, err := s.profileRepo.GetWithCompany(ctx, result.UserID)
if err != nil {
return nil, fmt.Errorf("get profile: %w", err)
}
if profile == nil {
profile = &model.Profile{
ID: result.UserID,
Username: result.Username,
RealName: result.RealName,
Avatar: result.Avatar,
}
}
var company *model.Company
if profile.CompanyID != "" {
company, err = s.compRepo.GetByID(ctx, profile.CompanyID)
if err != nil {
return nil, fmt.Errorf("get company: %w", err)
}
}
return s.generateTokens(profile, company)
}
func (s *AuthService) RefreshToken(ctx context.Context, refreshToken string) (string, error) {
userID, err := s.jwt.ParseRefreshToken(refreshToken)
if err != nil {
return "", fmt.Errorf("invalid refresh token: %w", err)
}
profile, err := s.profileRepo.GetWithCompany(ctx, userID)
if err != nil {
return "", fmt.Errorf("get user: %w", err)
}
if profile == nil {
return "", errors.New("user not found")
}
claims := auth.Claims{
UserID: profile.ID,
CompanyID: profile.CompanyID,
Role: profile.Role,
IsMaster: profile.IsMaster,
Username: profile.Username,
}
return s.jwt.GenerateAccessToken(claims)
}
func (s *AuthService) GetMe(ctx context.Context, userID string) (*ProfileWithCompany, error) {
profile, err := s.profileRepo.GetWithCompany(ctx, userID)
if err != nil {
return nil, fmt.Errorf("get profile: %w", err)
}
if profile == nil {
return nil, errors.New("user not found")
}
result := &ProfileWithCompany{User: profile}
if profile.CompanyID != "" {
company, err := s.compRepo.GetByID(ctx, profile.CompanyID)
if err != nil {
return nil, fmt.Errorf("get company: %w", err)
}
result.Company = company
}
return result, nil
}
func (s *AuthService) generateTokens(profile *model.Profile, company *model.Company) (*LoginResponse, error) {
role := profile.Role
if role == "" && company != nil {
role = company.Role
}
claims := auth.Claims{
UserID: profile.ID,
CompanyID: profile.CompanyID,
Role: role,
IsMaster: profile.IsMaster,
Username: profile.Username,
}
accessToken, err := s.jwt.GenerateAccessToken(claims)
if err != nil {
return nil, fmt.Errorf("generate access token: %w", err)
}
refreshToken, err := s.jwt.GenerateRefreshToken(profile.ID)
if err != nil {
return nil, fmt.Errorf("generate refresh token: %w", err)
}
return &LoginResponse{
AccessToken: accessToken,
RefreshToken: refreshToken,
User: profile,
Company: company,
}, nil
}

View File

@ -0,0 +1,33 @@
package service
import (
"context"
"errors"
"fmt"
"github.com/muyuqingfeng/iloom/auth-service/internal/model"
"github.com/muyuqingfeng/iloom/auth-service/internal/repository"
)
type CompanyService struct {
repo *repository.CompanyRepo
}
func NewCompanyService(repo *repository.CompanyRepo) *CompanyService {
return &CompanyService{repo: repo}
}
func (s *CompanyService) GetByID(ctx context.Context, id string) (*model.Company, error) {
company, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, fmt.Errorf("get company: %w", err)
}
if company == nil {
return nil, errors.New("company not found")
}
return company, nil
}
func (s *CompanyService) ListRelationships(ctx context.Context, companyID string) ([]model.CompanyRelationship, error) {
return s.repo.ListRelationships(ctx, companyID)
}

View File

@ -0,0 +1,78 @@
package service
import (
"context"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/muyuqingfeng/iloom/auth-service/internal/model"
"github.com/muyuqingfeng/iloom/auth-service/internal/repository"
sgrpc "github.com/muyuqingfeng/iloom/shared/pkg/grpc"
)
type MemberService struct {
memberRepo *repository.MemberRepo
sysClient *sgrpc.SystemClient
}
func NewMemberService(memberRepo *repository.MemberRepo, sysClient *sgrpc.SystemClient) *MemberService {
return &MemberService{
memberRepo: memberRepo,
sysClient: sysClient,
}
}
type CreateMemberRequest struct {
Username string `json:"username"`
Password string `json:"password"`
RealName string `json:"real_name"`
Phone string `json:"phone"`
}
func (s *MemberService) List(ctx context.Context, companyID string) ([]model.Profile, error) {
return s.memberRepo.ListByCompany(ctx, companyID)
}
func (s *MemberService) Create(ctx context.Context, companyID, roleKey string, req CreateMemberRequest) (*model.Profile, error) {
roleID := roleIDMap[roleKey]
userID, err := s.sysClient.CreateUser(ctx, req.Username, req.Password, req.RealName, req.Phone, roleID)
if err != nil {
return nil, fmt.Errorf("create user: %w", err)
}
now := time.Now()
member := &model.CompanyMember{
ID: uuid.New().String(),
CompanyID: companyID,
UserID: userID,
Role: "member",
CreatedAt: now,
}
if err := s.memberRepo.Create(ctx, member); err != nil {
return nil, fmt.Errorf("create member: %w", err)
}
return &model.Profile{
ID: userID,
Username: req.Username,
RealName: req.RealName,
Phone: req.Phone,
CompanyID: companyID,
CreatedAt: now,
}, nil
}
func (s *MemberService) Delete(ctx context.Context, memberID, companyID string) error {
member, err := s.memberRepo.GetByUserAndCompany(ctx, memberID, companyID)
if err != nil {
return fmt.Errorf("get member: %w", err)
}
if member == nil {
return errors.New("member not found")
}
return s.memberRepo.Delete(ctx, member.ID, companyID)
}

View File

@ -0,0 +1,24 @@
package service
import (
"context"
"github.com/muyuqingfeng/iloom/auth-service/internal/model"
"github.com/muyuqingfeng/iloom/auth-service/internal/repository"
)
type NotificationService struct {
repo *repository.NotificationRepo
}
func NewNotificationService(repo *repository.NotificationRepo) *NotificationService {
return &NotificationService{repo: repo}
}
func (s *NotificationService) List(ctx context.Context, userID string) ([]model.Notification, error) {
return s.repo.ListByRecipient(ctx, userID)
}
func (s *NotificationService) MarkRead(ctx context.Context, id, userID string) error {
return s.repo.MarkRead(ctx, id, userID)
}

View File

@ -0,0 +1,128 @@
# iloom 集成模式 — 连接 muyu-apiserver 的 MySQL 和 gRPC
# 用法docker compose -f docker-compose.integrated.yml up -d
services:
gateway:
build:
context: .
dockerfile: gateway/Dockerfile
ports:
- "${GATEWAY_PORT:-8080}:8080"
environment:
- PORT=8080
- JWT_SECRET=${JWT_SECRET:-muyu-wms-jwt-secret-key-2026}
- AUTH_SERVICE_URL=http://auth-service:8081
- PURCHASER_SERVICE_URL=http://purchaser-service:8082
- TEXTILE_SERVICE_URL=http://textile-service:8083
- WASHING_SERVICE_URL=http://washing-service:8084
- ALLOW_ORIGINS=${ALLOW_ORIGINS:-http://localhost:3015}
depends_on:
auth-service:
condition: service_healthy
purchaser-service:
condition: service_healthy
textile-service:
condition: service_healthy
washing-service:
condition: service_healthy
networks:
- muyu-net
- default
auth-service:
build:
context: .
dockerfile: auth-service/Dockerfile
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}
- MUYU_SYSTEM_RPC_ADDR=muyu-system-rpc:9001
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8081/health"]
interval: 10s
timeout: 3s
retries: 5
networks:
- muyu-net
- default
purchaser-service:
build:
context: .
dockerfile: purchaser-service/Dockerfile
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}
- MUYU_INVENTORY_RPC_ADDR=muyu-inventory-rpc:9002
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8082/health"]
interval: 10s
timeout: 3s
retries: 5
networks:
- muyu-net
- default
textile-service:
build:
context: .
dockerfile: textile-service/Dockerfile
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}
- PURCHASER_SERVICE_URL=http://purchaser-service:8082
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8083/health"]
interval: 10s
timeout: 3s
retries: 5
networks:
- muyu-net
- default
washing-service:
build:
context: .
dockerfile: washing-service/Dockerfile
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}
- PURCHASER_SERVICE_URL=http://purchaser-service:8082
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8084/health"]
interval: 10s
timeout: 3s
retries: 5
networks:
- muyu-net
- default
frontend:
build:
context: .
dockerfile: iloom-flatten/Dockerfile
ports:
- "${FRONTEND_PORT:-3015}:3015"
depends_on:
gateway:
condition: service_started
networks:
- default
networks:
muyu-net:
external: true
name: deploy_default

150
docker-compose.yml Normal file
View File

@ -0,0 +1,150 @@
# 使用方式:
# 独立模式开发docker compose up
# - 启动本地 MySQL自动初始化 muyu + iloom 表
# - gRPC 服务需要另外启动 muyu-apiservercd ../muyu-apiserver/deploy && docker compose up
#
# 集成模式(与 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 MUYU_SYSTEM_RPC_ADDR=muyu-system-rpc:9001
# export MUYU_INVENTORY_RPC_ADDR=muyu-inventory-rpc:9002
# 3. docker compose --profile integrated up
services:
mysql:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-muyu2026}
MYSQL_DATABASE: ${MYSQL_DATABASE:-muyu_wms}
TZ: Asia/Shanghai
ports:
- "${MYSQL_PORT:-3306}:3306"
volumes:
- mysql_data:/var/lib/mysql
- ../muyu-apiserver/deploy/mysql/init.sql:/docker-entrypoint-initdb.d/01-muyu.sql
- ./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}"]
interval: 5s
timeout: 5s
retries: 10
gateway:
build:
context: .
dockerfile: gateway/Dockerfile
ports:
- "${GATEWAY_PORT:-8080}:8080"
environment:
- PORT=8080
- JWT_SECRET=${JWT_SECRET:-muyu-wms-jwt-secret-key-2026}
- AUTH_SERVICE_URL=http://auth-service:8081
- PURCHASER_SERVICE_URL=http://purchaser-service:8082
- TEXTILE_SERVICE_URL=http://textile-service:8083
- WASHING_SERVICE_URL=http://washing-service:8084
- ALLOW_ORIGINS=${ALLOW_ORIGINS:-http://localhost:3015}
depends_on:
auth-service:
condition: service_healthy
purchaser-service:
condition: service_healthy
textile-service:
condition: service_healthy
washing-service:
condition: service_healthy
auth-service:
build:
context: .
dockerfile: auth-service/Dockerfile
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}
- MUYU_SYSTEM_RPC_ADDR=${MUYU_SYSTEM_RPC_ADDR:-system-rpc:9001}
depends_on:
mysql:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8081/health"]
interval: 10s
timeout: 3s
retries: 3
purchaser-service:
build:
context: .
dockerfile: purchaser-service/Dockerfile
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}
- MUYU_INVENTORY_RPC_ADDR=${MUYU_INVENTORY_RPC_ADDR:-inventory-rpc:9002}
depends_on:
mysql:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8082/health"]
interval: 10s
timeout: 3s
retries: 3
textile-service:
build:
context: .
dockerfile: textile-service/Dockerfile
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}
- PURCHASER_SERVICE_URL=http://purchaser-service:8082
depends_on:
mysql:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8083/health"]
interval: 10s
timeout: 3s
retries: 3
washing-service:
build:
context: .
dockerfile: washing-service/Dockerfile
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}
- PURCHASER_SERVICE_URL=http://purchaser-service:8082
depends_on:
mysql:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8084/health"]
interval: 10s
timeout: 3s
retries: 3
frontend:
build:
context: .
dockerfile: iloom-flatten/Dockerfile
ports:
- "${FRONTEND_PORT:-3015}:3015"
depends_on:
gateway:
condition: service_started
volumes:
mysql_data:

1563
docs/architecture.html Normal file

File diff suppressed because it is too large Load Diff

12
gateway/Dockerfile Normal file
View File

@ -0,0 +1,12 @@
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY shared/ ./shared/
COPY gateway/ ./gateway/
WORKDIR /app/gateway
RUN go build -o /gateway ./cmd/
FROM alpine:3.20
RUN apk add --no-cache wget
COPY --from=builder /gateway /gateway
EXPOSE 8080
CMD ["/gateway"]

71
gateway/cmd/main.go Normal file
View File

@ -0,0 +1,71 @@
package main
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/gateway/internal/config"
"github.com/muyuqingfeng/iloom/gateway/internal/middleware"
"github.com/muyuqingfeng/iloom/gateway/internal/proxy"
sharedMW "github.com/muyuqingfeng/iloom/shared/pkg/middleware"
)
func main() {
cfg := config.Load()
r := gin.New()
r.Use(
sharedMW.RequestID(),
sharedMW.Logger(),
sharedMW.Recovery(),
sharedMW.CORS(cfg.AllowOrigins),
middleware.RateLimit(100),
)
r.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
})
v1 := r.Group("/api/v1")
{
// /api/v1/auth/* -> AUTH_SERVICE_URL (no auth required)
v1.Any("/auth/*path", proxy.NewReverseProxy(cfg.AuthServiceURL))
// /api/v1/common/* -> AUTH_SERVICE_URL (JWT required)
common := v1.Group("/common", middleware.AuthRequired(cfg.JWTSecret))
common.Any("/*path", proxy.NewReverseProxy(cfg.AuthServiceURL))
// /api/v1/purchaser/* -> PURCHASER_SERVICE_URL (JWT + role=purchaser)
purchaser := v1.Group("/purchaser",
middleware.AuthRequired(cfg.JWTSecret),
middleware.RoleRequired("purchaser"),
)
purchaser.Any("/*path", proxy.NewReverseProxy(cfg.PurchaserServiceURL))
// /api/v1/textile/* -> TEXTILE_SERVICE_URL (JWT + role=textile)
textile := v1.Group("/textile",
middleware.AuthRequired(cfg.JWTSecret),
middleware.RoleRequired("textile"),
)
textile.Any("/*path", proxy.NewReverseProxy(cfg.TextileServiceURL))
// /api/v1/washing/* -> WASHING_SERVICE_URL (JWT + role=washing)
washing := v1.Group("/washing",
middleware.AuthRequired(cfg.JWTSecret),
middleware.RoleRequired("washing"),
)
washing.Any("/*path", proxy.NewReverseProxy(cfg.WashingServiceURL))
}
// 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))
addr := ":" + cfg.Port
log.Printf("gateway listening on %s", addr)
if err := r.Run(addr); err != nil {
log.Fatalf("server error: %v", err)
}
}

47
gateway/go.mod Normal file
View File

@ -0,0 +1,47 @@
module github.com/muyuqingfeng/iloom/gateway
go 1.23
toolchain go1.24.4
require github.com/muyuqingfeng/iloom/shared v0.0.0
replace github.com/muyuqingfeng/iloom/shared => ../shared
require (
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/gorilla/websocket v1.5.3
)
require (
github.com/bytedance/sonic v1.11.6 // indirect
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/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.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/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/kr/pretty v0.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

109
gateway/go.sum Normal file
View File

@ -0,0 +1,109 @@
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
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/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/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/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/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
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/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=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
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/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
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=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -0,0 +1,35 @@
package config
import (
"os"
"strings"
)
type Config struct {
Port string
JWTSecret string
AuthServiceURL string
PurchaserServiceURL string
TextileServiceURL string
WashingServiceURL string
AllowOrigins []string
}
func Load() *Config {
return &Config{
Port: getEnv("PORT", "8080"),
JWTSecret: getEnv("JWT_SECRET", "muyu-wms-jwt-secret-key-2026"),
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"),
AllowOrigins: strings.Split(getEnv("ALLOW_ORIGINS", "*"), ","),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@ -0,0 +1,65 @@
package middleware
import (
"fmt"
"strings"
"github.com/gin-gonic/gin"
"github.com/golang-jwt/jwt/v5"
"github.com/muyuqingfeng/iloom/shared/pkg/auth"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
)
func AuthRequired(jwtSecret string) gin.HandlerFunc {
secret := []byte(jwtSecret)
return func(c *gin.Context) {
header := c.GetHeader("Authorization")
if header == "" {
response.Unauthorized(c, "missing authorization header")
c.Abort()
return
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "bearer") {
response.Unauthorized(c, "invalid authorization format")
c.Abort()
return
}
tokenStr := parts[1]
token, err := jwt.ParseWithClaims(tokenStr, &auth.Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return secret, nil
})
if err != nil {
response.Error(c, 401, response.ErrCodeTokenExpired, "token expired or invalid")
c.Abort()
return
}
claims, ok := token.Claims.(*auth.Claims)
if !ok || !token.Valid {
response.Error(c, 401, response.ErrCodeTokenInvalid, "invalid token")
c.Abort()
return
}
c.Set("user_id", claims.UserID)
c.Set("company_id", claims.CompanyID)
c.Set("role", claims.Role)
c.Set("is_master", claims.IsMaster)
c.Set("username", claims.Username)
c.Request.Header.Set("X-User-ID", claims.UserID)
c.Request.Header.Set("X-Company-ID", claims.CompanyID)
c.Request.Header.Set("X-User-Role", claims.Role)
c.Request.Header.Set("X-Is-Master", fmt.Sprintf("%t", claims.IsMaster))
c.Request.Header.Set("X-Username", claims.Username)
c.Next()
}
}

View File

@ -0,0 +1,54 @@
package middleware
import (
"net/http"
"sync"
"time"
"github.com/gin-gonic/gin"
)
type bucket struct {
tokens float64
lastCheck time.Time
mu sync.Mutex
}
var buckets sync.Map
func RateLimit(rps int) gin.HandlerFunc {
rate := float64(rps)
return func(c *gin.Context) {
ip := c.ClientIP()
val, _ := buckets.LoadOrStore(ip, &bucket{
tokens: rate,
lastCheck: time.Now(),
})
b := val.(*bucket)
b.mu.Lock()
now := time.Now()
elapsed := now.Sub(b.lastCheck).Seconds()
b.lastCheck = now
b.tokens += elapsed * rate
if b.tokens > rate {
b.tokens = rate
}
if b.tokens < 1 {
b.mu.Unlock()
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"code": 42900,
"message": "rate limit exceeded",
})
return
}
b.tokens--
b.mu.Unlock()
c.Next()
}
}

View File

@ -0,0 +1,31 @@
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
)
func RoleRequired(roles ...string) gin.HandlerFunc {
allowed := make(map[string]bool, len(roles))
for _, r := range roles {
allowed[r] = true
}
return func(c *gin.Context) {
role, exists := c.Get("role")
if !exists {
response.Forbidden(c, "role not found in context")
c.Abort()
return
}
roleStr, ok := role.(string)
if !ok || !allowed[roleStr] {
response.Error(c, 403, response.ErrCodeRoleMismatch, "insufficient role permission")
c.Abort()
return
}
c.Next()
}
}

View File

@ -0,0 +1,34 @@
package proxy
import (
"log"
"net/http"
"net/http/httputil"
"net/url"
"github.com/gin-gonic/gin"
)
func NewReverseProxy(target string) gin.HandlerFunc {
targetURL, err := url.Parse(target)
if err != nil {
log.Fatalf("invalid proxy target URL: %s, err: %v", target, err)
}
proxy := &httputil.ReverseProxy{
Director: func(req *http.Request) {
req.URL.Scheme = targetURL.Scheme
req.URL.Host = targetURL.Host
req.Host = targetURL.Host
},
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
log.Printf("[proxy] error: %v", err)
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write([]byte(`{"code":50200,"message":"service unavailable"}`))
},
}
return func(c *gin.Context) {
proxy.ServeHTTP(c.Writer, c.Request)
}
}

View File

@ -0,0 +1,94 @@
package proxy
import (
"io"
"log"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
func NewWSProxy(targetURL string) gin.HandlerFunc {
return func(c *gin.Context) {
clientConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("[ws_proxy] upgrade error: %v", err)
return
}
defer clientConn.Close()
backendURL, err := buildWSURL(targetURL, c.Request)
if err != nil {
log.Printf("[ws_proxy] url parse error: %v", err)
return
}
header := http.Header{}
for _, key := range []string{"X-User-ID", "X-Company-ID", "X-User-Role"} {
if v := c.Request.Header.Get(key); v != "" {
header.Set(key, v)
}
}
backendConn, _, err := websocket.DefaultDialer.Dial(backendURL, header)
if err != nil {
log.Printf("[ws_proxy] dial backend error: %v", err)
return
}
defer backendConn.Close()
errCh := make(chan error, 2)
go pumpMessages(clientConn, backendConn, errCh)
go pumpMessages(backendConn, clientConn, errCh)
<-errCh
}
}
func pumpMessages(src, dst *websocket.Conn, errCh chan<- error) {
for {
msgType, reader, err := src.NextReader()
if err != nil {
errCh <- err
return
}
writer, err := dst.NextWriter(msgType)
if err != nil {
errCh <- err
return
}
if _, err := io.Copy(writer, reader); err != nil {
errCh <- err
return
}
if err := writer.Close(); err != nil {
errCh <- err
return
}
}
}
func buildWSURL(target string, req *http.Request) (string, error) {
u, err := url.Parse(target)
if err != nil {
return "", err
}
scheme := "ws"
if strings.HasPrefix(u.Scheme, "https") {
scheme = "wss"
}
u.Scheme = scheme
u.Path = req.URL.Path
u.RawQuery = req.URL.RawQuery
return u.String(), nil
}

12
go.work Normal file
View File

@ -0,0 +1,12 @@
go 1.23
toolchain go1.24.4
use (
./auth-service
./gateway
./purchaser-service
./shared
./textile-service
./washing-service
)

26
go.work.sum Normal file
View File

@ -0,0 +1,26 @@
cel.dev/expr v0.20.0/go.mod h1:MrpN08Q+lEBs+bGYdLxxHkZoUSsCp0nSKTs0nTymJgw=
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.26.0/go.mod h1:2bIszWvQRlJVmJLiuLhukLImRjKPcYdzzsx6darK02A=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cncf/xds/go v0.0.0-20250121191232-2f005788dc42/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8=
github.com/envoyproxy/go-control-plane v0.13.4/go.mod h1:kDfuBlDVsSj2MjrLEtRWtHlsWIFcGyB2RMO44Dc5GZA=
github.com/envoyproxy/go-control-plane/envoy v1.32.4/go.mod h1:Gzjc5k8JcJswLjAx1Zm+wSYE20UrLtt7JZMWiWQXQEw=
github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4=
github.com/envoyproxy/protoc-gen-validate v1.2.1/go.mod h1:d/C80l/jxXLdfEIhX1W2TmLfsJ31lvEjwamM4DxlWXU=
github.com/go-jose/go-jose/v4 v4.0.4/go.mod h1:NKb5HO1EZccyMpiZNbdUw/14tiXNyUJh188dfnMCAfc=
github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/spiffe/go-spiffe/v2 v2.5.0/go.mod h1:P+NxobPc6wXhVtINNtFjNWGBTreew1GBUCwT2wPmb7g=
github.com/zeebo/errs v1.4.0/go.mod h1:sgbWHsvVuTPHcqJJGQ1WhI5KbWlHYz+2+2C/LSEtCw4=
go.opentelemetry.io/contrib/detectors/gcp v1.34.0/go.mod h1:cV4BMFcscUR/ckqLkbfQmF0PRsq8w/lMGzdbCSveBHo=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=

1
iloom-flatten Submodule

@ -0,0 +1 @@
Subproject commit 3093ae18c25f6d654e1c0c3a6cd546c04a5438d1

4
k8s/00-namespace.yaml Normal file
View File

@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: iloom

View File

@ -0,0 +1,44 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: iloom-frontend-nginx
namespace: iloom
data:
default.conf: |
server {
listen 3015;
server_name _;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 256;
location /api/ {
proxy_pass http://iloom-gateway:8080;
proxy_set_header Host $host;
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;
}
location /ws/ {
proxy_pass http://iloom-gateway:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400;
}
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}

View File

@ -0,0 +1,83 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: muyu-system-config
namespace: iloom
data:
system.yaml: |
Name: system.rpc
ListenOn: 0.0.0.0:9001
Etcd:
Hosts:
- etcd:2379
Key: system.rpc
DataSource: root:muyu2026@tcp(mysql:3306)/muyu_wms?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
Cache:
- Host: redis:6379
Log:
ServiceName: system-rpc
Mode: console
Level: info
---
apiVersion: v1
kind: ConfigMap
metadata:
name: muyu-inventory-config
namespace: iloom
data:
inventory.yaml: |
Name: inventory.rpc
ListenOn: 0.0.0.0:9002
Etcd:
Hosts:
- etcd:2379
Key: inventory.rpc
DataSource: root:muyu2026@tcp(mysql:3306)/muyu_wms?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
Cache:
- Host: redis:6379
Log:
ServiceName: inventory-rpc
Mode: console
Level: info
---
apiVersion: v1
kind: ConfigMap
metadata:
name: muyu-gateway-config
namespace: iloom
data:
gateway.yaml: |
Name: gateway-api
Host: 0.0.0.0
Port: 8888
Auth:
AccessSecret: muyu-wms-jwt-secret-key-2026
AccessExpire: 7200
SystemRpc:
Etcd:
Hosts:
- etcd:2379
Key: system.rpc
NonBlock: true
InventoryRpc:
Etcd:
Hosts:
- etcd:2379
Key: inventory.rpc
NonBlock: true
Log:
ServiceName: gateway-api
Mode: console
Level: info

89
k8s/10-infra/etcd.yaml Normal file
View File

@ -0,0 +1,89 @@
apiVersion: v1
kind: Service
metadata:
name: etcd
namespace: iloom
spec:
clusterIP: None
ports:
- name: client
port: 2379
targetPort: 2379
- name: peer
port: 2380
targetPort: 2380
selector:
app: etcd
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: etcd
namespace: iloom
spec:
serviceName: etcd
replicas: 1
selector:
matchLabels:
app: etcd
template:
metadata:
labels:
app: etcd
spec:
imagePullSecrets:
- name: harbor-pull-secret
containers:
- name: etcd
image: harbor-in-k3s.cheverjohn.me/iloom/etcd:v3.5.17
ports:
- containerPort: 2379
name: client
- containerPort: 2380
name: peer
env:
- name: ETCD_NAME
value: "etcd-0"
- name: ETCD_DATA_DIR
value: "/etcd-data"
- name: ETCD_LISTEN_CLIENT_URLS
value: "http://0.0.0.0:2379"
- name: ETCD_ADVERTISE_CLIENT_URLS
value: "http://etcd:2379"
- name: ETCD_LISTEN_PEER_URLS
value: "http://0.0.0.0:2380"
- name: ETCD_INITIAL_ADVERTISE_PEER_URLS
value: "http://etcd-0.etcd:2380"
- name: ETCD_INITIAL_CLUSTER
value: "etcd-0=http://etcd-0.etcd:2380"
- name: ETCD_INITIAL_CLUSTER_STATE
value: "new"
volumeMounts:
- name: etcd-data
mountPath: /etcd-data
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
readinessProbe:
exec:
command: ["etcdctl", "endpoint", "health", "--endpoints=http://localhost:2379"]
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
exec:
command: ["etcdctl", "endpoint", "health", "--endpoints=http://localhost:2379"]
initialDelaySeconds: 30
periodSeconds: 30
volumeClaimTemplates:
- metadata:
name: etcd-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: longhorn
resources:
requests:
storage: 1Gi

87
k8s/10-infra/mysql.yaml Normal file
View File

@ -0,0 +1,87 @@
apiVersion: v1
kind: Service
metadata:
name: mysql
namespace: iloom
spec:
clusterIP: None
ports:
- port: 3306
targetPort: 3306
selector:
app: mysql
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: mysql
namespace: iloom
spec:
serviceName: mysql
replicas: 1
selector:
matchLabels:
app: mysql
template:
metadata:
labels:
app: mysql
spec:
imagePullSecrets:
- name: harbor-pull-secret
containers:
- name: mysql
image: harbor-in-k3s.cheverjohn.me/iloom/mysql:8.0
ports:
- containerPort: 3306
env:
- name: MYSQL_ROOT_PASSWORD
valueFrom:
secretKeyRef:
name: db-secret
key: MYSQL_ROOT_PASSWORD
- name: MYSQL_DATABASE
valueFrom:
secretKeyRef:
name: db-secret
key: MYSQL_DATABASE
- name: TZ
value: Asia/Shanghai
args:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumeMounts:
- name: mysql-data
mountPath: /var/lib/mysql
- name: init-scripts
mountPath: /docker-entrypoint-initdb.d
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
cpu: "1"
memory: 1Gi
readinessProbe:
exec:
command: ["mysqladmin", "ping", "-h", "localhost"]
initialDelaySeconds: 30
periodSeconds: 10
livenessProbe:
exec:
command: ["mysqladmin", "ping", "-h", "localhost"]
initialDelaySeconds: 60
periodSeconds: 30
volumes:
- name: init-scripts
configMap:
name: mysql-init-scripts
volumeClaimTemplates:
- metadata:
name: mysql-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: longhorn
resources:
requests:
storage: 5Gi

66
k8s/10-infra/redis.yaml Normal file
View File

@ -0,0 +1,66 @@
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: iloom
spec:
clusterIP: None
ports:
- port: 6379
targetPort: 6379
selector:
app: redis
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: redis
namespace: iloom
spec:
serviceName: redis
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
imagePullSecrets:
- name: harbor-pull-secret
containers:
- name: redis
image: harbor-in-k3s.cheverjohn.me/iloom/redis:7-alpine
ports:
- containerPort: 6379
command: ["redis-server", "--appendonly", "yes"]
volumeMounts:
- name: redis-data
mountPath: /data
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 15
periodSeconds: 30
volumeClaimTemplates:
- metadata:
name: redis-data
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: longhorn
resources:
requests:
storage: 2Gi

66
k8s/20-muyu/gateway.yaml Normal file
View File

@ -0,0 +1,66 @@
apiVersion: v1
kind: Service
metadata:
name: muyu-gateway
namespace: iloom
spec:
type: ClusterIP
ports:
- port: 8888
targetPort: 8888
selector:
app: muyu-gateway
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: muyu-gateway
namespace: iloom
spec:
replicas: 1
selector:
matchLabels:
app: muyu-gateway
template:
metadata:
labels:
app: muyu-gateway
spec:
imagePullSecrets:
- name: harbor-pull-secret
initContainers:
- name: wait-etcd
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z etcd 2379; do sleep 2; done']
containers:
- name: gateway
image: harbor-in-k3s.cheverjohn.me/iloom/muyu-gateway:v1
ports:
- containerPort: 8888
env:
- name: TZ
value: Asia/Shanghai
volumeMounts:
- name: config
mountPath: /app/etc
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
readinessProbe:
tcpSocket:
port: 8888
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 8888
initialDelaySeconds: 20
periodSeconds: 30
volumes:
- name: config
configMap:
name: muyu-gateway-config

View File

@ -0,0 +1,72 @@
apiVersion: v1
kind: Service
metadata:
name: muyu-inventory-rpc
namespace: iloom
spec:
type: ClusterIP
ports:
- port: 9002
targetPort: 9002
selector:
app: muyu-inventory-rpc
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: muyu-inventory-rpc
namespace: iloom
spec:
replicas: 1
selector:
matchLabels:
app: muyu-inventory-rpc
template:
metadata:
labels:
app: muyu-inventory-rpc
spec:
imagePullSecrets:
- name: harbor-pull-secret
initContainers:
- name: wait-mysql
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z mysql 3306; do sleep 2; done']
- name: wait-etcd
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z etcd 2379; do sleep 2; done']
- name: wait-redis
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z redis 6379; do sleep 2; done']
containers:
- name: inventory-rpc
image: harbor-in-k3s.cheverjohn.me/iloom/muyu-inventory-rpc:v1
ports:
- containerPort: 9002
env:
- name: TZ
value: Asia/Shanghai
volumeMounts:
- name: config
mountPath: /app/etc
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
readinessProbe:
tcpSocket:
port: 9002
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 9002
initialDelaySeconds: 20
periodSeconds: 30
volumes:
- name: config
configMap:
name: muyu-inventory-config

View File

@ -0,0 +1,72 @@
apiVersion: v1
kind: Service
metadata:
name: muyu-system-rpc
namespace: iloom
spec:
type: ClusterIP
ports:
- port: 9001
targetPort: 9001
selector:
app: muyu-system-rpc
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: muyu-system-rpc
namespace: iloom
spec:
replicas: 1
selector:
matchLabels:
app: muyu-system-rpc
template:
metadata:
labels:
app: muyu-system-rpc
spec:
imagePullSecrets:
- name: harbor-pull-secret
initContainers:
- name: wait-mysql
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z mysql 3306; do sleep 2; done']
- name: wait-etcd
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z etcd 2379; do sleep 2; done']
- name: wait-redis
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z redis 6379; do sleep 2; done']
containers:
- name: system-rpc
image: harbor-in-k3s.cheverjohn.me/iloom/muyu-system-rpc:v1
ports:
- containerPort: 9001
env:
- name: TZ
value: Asia/Shanghai
volumeMounts:
- name: config
mountPath: /app/etc
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
readinessProbe:
tcpSocket:
port: 9001
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
tcpSocket:
port: 9001
initialDelaySeconds: 20
periodSeconds: 30
volumes:
- name: config
configMap:
name: muyu-system-config

View File

@ -0,0 +1,81 @@
apiVersion: v1
kind: Service
metadata:
name: iloom-auth-service
namespace: iloom
spec:
type: ClusterIP
ports:
- port: 8081
targetPort: 8081
selector:
app: iloom-auth-service
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: iloom-auth-service
namespace: iloom
spec:
replicas: 1
selector:
matchLabels:
app: iloom-auth-service
template:
metadata:
labels:
app: iloom-auth-service
spec:
imagePullSecrets:
- name: harbor-pull-secret
initContainers:
- name: wait-mysql
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z mysql 3306; do sleep 2; done']
- name: wait-muyu-system
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z muyu-system-rpc 9001; do sleep 2; done']
containers:
- name: auth-service
image: harbor-in-k3s.cheverjohn.me/iloom/iloom-auth-service:v1
ports:
- containerPort: 8081
env:
- name: PORT
value: "8081"
- name: DB_DSN
valueFrom:
secretKeyRef:
name: db-secret
key: DB_DSN
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: app-secret
key: JWT_SECRET
- name: JWT_REFRESH_SECRET
valueFrom:
secretKeyRef:
name: app-secret
key: JWT_REFRESH_SECRET
- name: MUYU_SYSTEM_RPC_ADDR
value: "muyu-system-rpc:9001"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
readinessProbe:
httpGet:
path: /health
port: 8081
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8081
initialDelaySeconds: 20
periodSeconds: 30

View File

@ -0,0 +1,61 @@
apiVersion: v1
kind: Service
metadata:
name: iloom-frontend
namespace: iloom
spec:
type: ClusterIP
ports:
- port: 3015
targetPort: 3015
selector:
app: iloom-frontend
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: iloom-frontend
namespace: iloom
spec:
replicas: 1
selector:
matchLabels:
app: iloom-frontend
template:
metadata:
labels:
app: iloom-frontend
spec:
imagePullSecrets:
- name: harbor-pull-secret
containers:
- name: frontend
image: harbor-in-k3s.cheverjohn.me/iloom/iloom-frontend:v1
ports:
- containerPort: 3015
volumeMounts:
- name: nginx-config
mountPath: /etc/nginx/conf.d
resources:
requests:
cpu: 25m
memory: 32Mi
limits:
cpu: 100m
memory: 64Mi
readinessProbe:
httpGet:
path: /
port: 3015
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 3015
initialDelaySeconds: 10
periodSeconds: 30
volumes:
- name: nginx-config
configMap:
name: iloom-frontend-nginx

85
k8s/30-iloom/gateway.yaml Normal file
View File

@ -0,0 +1,85 @@
apiVersion: v1
kind: Service
metadata:
name: iloom-gateway
namespace: iloom
spec:
type: ClusterIP
ports:
- port: 8080
targetPort: 8080
selector:
app: iloom-gateway
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: iloom-gateway
namespace: iloom
spec:
replicas: 1
selector:
matchLabels:
app: iloom-gateway
template:
metadata:
labels:
app: iloom-gateway
spec:
imagePullSecrets:
- name: harbor-pull-secret
initContainers:
- name: wait-auth
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z iloom-auth-service 8081; do sleep 2; done']
- name: wait-purchaser
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z iloom-purchaser-service 8082; do sleep 2; done']
- name: wait-textile
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z iloom-textile-service 8083; do sleep 2; done']
- name: wait-washing
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z iloom-washing-service 8084; do sleep 2; done']
containers:
- name: gateway
image: harbor-in-k3s.cheverjohn.me/iloom/iloom-gateway:v1
ports:
- containerPort: 8080
env:
- name: PORT
value: "8080"
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: app-secret
key: JWT_SECRET
- name: AUTH_SERVICE_URL
value: "http://iloom-auth-service:8081"
- name: PURCHASER_SERVICE_URL
value: "http://iloom-purchaser-service:8082"
- name: TEXTILE_SERVICE_URL
value: "http://iloom-textile-service:8083"
- name: WASHING_SERVICE_URL
value: "http://iloom-washing-service:8084"
- name: ALLOW_ORIGINS
value: "https://iloom.cheverjohn.me"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 20
periodSeconds: 30

View File

@ -0,0 +1,76 @@
apiVersion: v1
kind: Service
metadata:
name: iloom-purchaser-service
namespace: iloom
spec:
type: ClusterIP
ports:
- port: 8082
targetPort: 8082
selector:
app: iloom-purchaser-service
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: iloom-purchaser-service
namespace: iloom
spec:
replicas: 1
selector:
matchLabels:
app: iloom-purchaser-service
template:
metadata:
labels:
app: iloom-purchaser-service
spec:
imagePullSecrets:
- name: harbor-pull-secret
initContainers:
- name: wait-mysql
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z mysql 3306; do sleep 2; done']
- name: wait-muyu-inventory
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z muyu-inventory-rpc 9002; do sleep 2; done']
containers:
- name: purchaser-service
image: harbor-in-k3s.cheverjohn.me/iloom/iloom-purchaser-service:v1
ports:
- containerPort: 8082
env:
- name: PORT
value: "8082"
- name: DB_DSN
valueFrom:
secretKeyRef:
name: db-secret
key: DB_DSN
- name: INTERNAL_SERVICE_KEY
valueFrom:
secretKeyRef:
name: app-secret
key: INTERNAL_SERVICE_KEY
- name: MUYU_INVENTORY_RPC_ADDR
value: "muyu-inventory-rpc:9002"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
readinessProbe:
httpGet:
path: /health
port: 8082
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8082
initialDelaySeconds: 20
periodSeconds: 30

View File

@ -0,0 +1,73 @@
apiVersion: v1
kind: Service
metadata:
name: iloom-textile-service
namespace: iloom
spec:
type: ClusterIP
ports:
- port: 8083
targetPort: 8083
selector:
app: iloom-textile-service
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: iloom-textile-service
namespace: iloom
spec:
replicas: 1
selector:
matchLabels:
app: iloom-textile-service
template:
metadata:
labels:
app: iloom-textile-service
spec:
imagePullSecrets:
- name: harbor-pull-secret
initContainers:
- name: wait-mysql
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z mysql 3306; do sleep 2; done']
containers:
- name: textile-service
image: harbor-in-k3s.cheverjohn.me/iloom/iloom-textile-service:v1
ports:
- containerPort: 8083
env:
- name: PORT
value: "8083"
- name: DB_DSN
valueFrom:
secretKeyRef:
name: db-secret
key: DB_DSN
- name: INTERNAL_SERVICE_KEY
valueFrom:
secretKeyRef:
name: app-secret
key: INTERNAL_SERVICE_KEY
- name: PURCHASER_SERVICE_URL
value: "http://iloom-purchaser-service:8082"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
readinessProbe:
httpGet:
path: /health
port: 8083
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8083
initialDelaySeconds: 20
periodSeconds: 30

View File

@ -0,0 +1,73 @@
apiVersion: v1
kind: Service
metadata:
name: iloom-washing-service
namespace: iloom
spec:
type: ClusterIP
ports:
- port: 8084
targetPort: 8084
selector:
app: iloom-washing-service
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: iloom-washing-service
namespace: iloom
spec:
replicas: 1
selector:
matchLabels:
app: iloom-washing-service
template:
metadata:
labels:
app: iloom-washing-service
spec:
imagePullSecrets:
- name: harbor-pull-secret
initContainers:
- name: wait-mysql
image: harbor-in-k3s.cheverjohn.me/iloom/busybox:1.36
command: ['sh', '-c', 'until nc -z mysql 3306; do sleep 2; done']
containers:
- name: washing-service
image: harbor-in-k3s.cheverjohn.me/iloom/iloom-washing-service:v1
ports:
- containerPort: 8084
env:
- name: PORT
value: "8084"
- name: DB_DSN
valueFrom:
secretKeyRef:
name: db-secret
key: DB_DSN
- name: INTERNAL_SERVICE_KEY
valueFrom:
secretKeyRef:
name: app-secret
key: INTERNAL_SERVICE_KEY
- name: PURCHASER_SERVICE_URL
value: "http://iloom-purchaser-service:8082"
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 250m
memory: 128Mi
readinessProbe:
httpGet:
path: /health
port: 8084
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 8084
initialDelaySeconds: 20
periodSeconds: 30

33
k8s/40-ingress.yaml Normal file
View File

@ -0,0 +1,33 @@
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: iloom-frontend
namespace: iloom
spec:
entryPoints:
- websecure
routes:
- match: Host(`iloom.cheverjohn.me`)
kind: Rule
services:
- name: iloom-frontend
port: 3015
tls:
certResolver: letsencrypt
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: muyu-gateway
namespace: iloom
spec:
entryPoints:
- websecure
routes:
- match: Host(`muyu-api.cheverjohn.me`)
kind: Rule
services:
- name: muyu-gateway
port: 8888
tls:
certResolver: letsencrypt

View File

@ -0,0 +1,12 @@
FROM golang:1.24-alpine AS builder
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
RUN apk add --no-cache wget
COPY --from=builder /purchaser-service /purchaser-service
EXPOSE 8082
CMD ["/purchaser-service"]

View File

@ -0,0 +1,102 @@
package main
import (
"log"
"os"
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/config"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/handler"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/repository"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/service"
"github.com/muyuqingfeng/iloom/shared/pkg/database"
"github.com/muyuqingfeng/iloom/shared/pkg/middleware"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
"github.com/muyuqingfeng/iloom/shared/pkg/websocket"
)
func main() {
cfg := config.Load()
db, err := database.NewDBFromDSN(cfg.DBDSN)
if err != nil {
log.Fatalf("failed to connect database: %v", err)
}
defer db.Close()
wsHub := websocket.NewHub()
go wsHub.Run()
planRepo := repository.NewPlanRepo(db)
productRepo := repository.NewProductRepo(db)
apRepo := repository.NewAPRepo(db)
factoryRepo := repository.NewFactoryRepo(db)
washingPlanRepo := repository.NewWashingPlanRepo(db)
planSvc := service.NewPlanService(planRepo, db)
productSvc := service.NewProductService(productRepo, db)
apSvc := service.NewAPService(apRepo, db)
factorySvc := service.NewFactoryService(factoryRepo)
washingPlanSvc := service.NewWashingPlanService(washingPlanRepo)
dashboardSvc := service.NewDashboardService(planRepo, productRepo, apRepo)
planHandler := handler.NewPlanHandler(planSvc, wsHub)
productHandler := handler.NewProductHandler(productSvc, wsHub)
apHandler := handler.NewAPHandler(apSvc)
factoryHandler := handler.NewFactoryHandler(factorySvc)
washingPlanHandler := handler.NewWashingPlanHandler(washingPlanSvc)
dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
if os.Getenv("GIN_MODE") == "" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(middleware.Recovery(), middleware.Logger(), middleware.RequestID())
r.GET("/health", func(c *gin.Context) {
response.OK(c, gin.H{"status": "ok", "service": "purchaser-service"})
})
api := r.Group("/api/v1/purchaser")
{
api.GET("/dashboard/stats", dashboardHandler.Stats)
api.GET("/plans", planHandler.List)
api.POST("/plans", planHandler.Create)
api.GET("/plans/:id", planHandler.Get)
api.PUT("/plans/:id", planHandler.Update)
api.DELETE("/plans/:id", planHandler.Delete)
api.POST("/plans/:id/factories", planHandler.AssignFactory)
api.GET("/plans/:id/steps", planHandler.GetSteps)
api.GET("/plans/:id/inventory", planHandler.GetInventory)
api.GET("/products", productHandler.List)
api.POST("/products", productHandler.Create)
api.PUT("/products/:id", productHandler.Update)
api.DELETE("/products/:id", productHandler.Delete)
api.POST("/products/:id/inbound", productHandler.Inbound)
api.POST("/products/:id/outbound", productHandler.Outbound)
api.GET("/products/:id/records", productHandler.Records)
api.PUT("/products/:id/price", productHandler.UpdatePrice)
api.GET("/products/:id/price-history", productHandler.PriceHistory)
api.GET("/factories", factoryHandler.List)
api.GET("/accounts-payable", apHandler.List)
api.POST("/accounts-payable/:id/pay", apHandler.Pay)
api.POST("/washing-plans", washingPlanHandler.Create)
api.GET("/washing-plans", washingPlanHandler.List)
}
r.GET("/ws/v1/purchaser", handler.HandleWebSocket(wsHub))
port := cfg.Port
if port == "" {
port = "8082"
}
log.Printf("purchaser-service listening on :%s", port)
if err := r.Run(":" + port); err != nil {
log.Fatalf("failed to start server: %v", err)
}
}

45
purchaser-service/go.mod Normal file
View File

@ -0,0 +1,45 @@
module github.com/muyuqingfeng/iloom/purchaser-service
go 1.23
toolchain go1.24.4
require (
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/muyuqingfeng/iloom/shared v0.0.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
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/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.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/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace github.com/muyuqingfeng/iloom/shared => ../shared

97
purchaser-service/go.sum Normal file
View File

@ -0,0 +1,97 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
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/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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
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/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/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/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/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/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=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
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/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=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

View File

@ -0,0 +1,26 @@
package config
import "os"
type Config struct {
Port string
DBDSN string
InternalServiceKey string
InventoryRPCAddr string
}
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"),
InternalServiceKey: getEnv("INTERNAL_SERVICE_KEY", "dev-internal-key"),
InventoryRPCAddr: getEnv("MUYU_INVENTORY_RPC_ADDR", "127.0.0.1:9002"),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@ -0,0 +1,62 @@
package handler
import (
"log"
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/service"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
)
type APHandler struct {
svc *service.APService
}
func NewAPHandler(svc *service.APService) *APHandler {
return &APHandler{svc: svc}
}
type PayRequest struct {
Amount float64 `json:"amount" binding:"required,gt=0"`
}
func (h *APHandler) List(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
list, err := h.svc.List(c.Request.Context(), companyID)
if err != nil {
log.Printf("list accounts payable error: %v", err)
response.InternalError(c, "failed to list accounts payable")
return
}
response.OK(c, list)
}
func (h *APHandler) Pay(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
apID := c.Param("id")
var req PayRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.svc.Pay(c.Request.Context(), apID, companyID, req.Amount); err != nil {
log.Printf("pay error: %v", err)
response.InternalError(c, "failed to process payment")
return
}
response.OK(c, nil)
}

View File

@ -0,0 +1,34 @@
package handler
import (
"log"
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/service"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
)
type DashboardHandler struct {
svc *service.DashboardService
}
func NewDashboardHandler(svc *service.DashboardService) *DashboardHandler {
return &DashboardHandler{svc: svc}
}
func (h *DashboardHandler) Stats(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
stats, err := h.svc.Stats(c.Request.Context(), companyID)
if err != nil {
log.Printf("get dashboard stats error: %v", err)
response.InternalError(c, "failed to get stats")
return
}
response.OK(c, stats)
}

View File

@ -0,0 +1,34 @@
package handler
import (
"log"
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/service"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
)
type FactoryHandler struct {
svc *service.FactoryService
}
func NewFactoryHandler(svc *service.FactoryService) *FactoryHandler {
return &FactoryHandler{svc: svc}
}
func (h *FactoryHandler) List(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
factories, err := h.svc.List(c.Request.Context(), companyID)
if err != nil {
log.Printf("list factories error: %v", err)
response.InternalError(c, "failed to list factories")
return
}
response.OK(c, factories)
}

View File

@ -0,0 +1,274 @@
package handler
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/service"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
"github.com/muyuqingfeng/iloom/shared/pkg/types"
"github.com/muyuqingfeng/iloom/shared/pkg/websocket"
)
type PlanHandler struct {
svc *service.PlanService
wsHub *websocket.Hub
}
func NewPlanHandler(svc *service.PlanService, wsHub *websocket.Hub) *PlanHandler {
return &PlanHandler{svc: svc, wsHub: wsHub}
}
type CreatePlanReq struct {
ProductName string `json:"product_name" binding:"required"`
FabricCode string `json:"fabric_code" binding:"required"`
Color string `json:"color" binding:"required"`
ColorCode string `json:"color_code"`
TargetQuantity float64 `json:"target_quantity" binding:"required,gt=0"`
ProductionPrice *float64 `json:"production_price"`
YarnUsagePerMeter *float64 `json:"yarn_usage_per_meter"`
Remark string `json:"remark"`
YarnRatios []CreateYarnRatioReq `json:"yarn_ratios"`
}
type CreateYarnRatioReq struct {
YarnName string `json:"yarn_name" binding:"required"`
YarnType string `json:"yarn_type"`
Ratio float64 `json:"ratio" binding:"required"`
AmountPerMeter float64 `json:"amount_per_meter"`
TotalAmount float64 `json:"total_amount"`
}
type UpdatePlanReq struct {
ProductName string `json:"product_name"`
FabricCode string `json:"fabric_code"`
Color string `json:"color"`
ColorCode string `json:"color_code"`
TargetQuantity float64 `json:"target_quantity"`
ProductionPrice *float64 `json:"production_price"`
YarnUsagePerMeter *float64 `json:"yarn_usage_per_meter"`
Status string `json:"status"`
Remark string `json:"remark"`
}
type AssignFactoryReq struct {
FactoryID string `json:"factory_id" binding:"required"`
FactoryType string `json:"factory_type" binding:"required"`
}
func (h *PlanHandler) List(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
var page types.Pagination
if err := c.ShouldBindQuery(&page); err != nil {
response.BadRequest(c, "invalid pagination")
return
}
page.Normalize()
status := c.Query("status")
plans, total, err := h.svc.List(c.Request.Context(), companyID, page, status)
if err != nil {
log.Printf("list plans error: %v", err)
response.InternalError(c, "failed to list plans")
return
}
response.OKWithMeta(c, plans, &response.Meta{
Page: page.Page,
PageSize: page.PageSize,
Total: total,
HasMore: page.Page*page.PageSize < total,
})
}
func (h *PlanHandler) Create(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
userID := c.GetHeader("X-User-ID")
if companyID == "" || userID == "" {
response.BadRequest(c, "missing user or company id")
return
}
var req CreatePlanReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
svcReq := service.CreatePlanRequest{
ProductName: req.ProductName,
FabricCode: req.FabricCode,
Color: req.Color,
ColorCode: req.ColorCode,
TargetQuantity: req.TargetQuantity,
ProductionPrice: req.ProductionPrice,
YarnUsagePerMeter: req.YarnUsagePerMeter,
Remark: req.Remark,
}
for _, yr := range req.YarnRatios {
svcReq.YarnRatios = append(svcReq.YarnRatios, service.CreateYarnRatio{
YarnName: yr.YarnName,
YarnType: yr.YarnType,
Ratio: yr.Ratio,
AmountPerMeter: yr.AmountPerMeter,
TotalAmount: yr.TotalAmount,
})
}
plan, err := h.svc.Create(c.Request.Context(), companyID, userID, svcReq)
if err != nil {
log.Printf("create plan error: %v", err)
response.InternalError(c, "failed to create plan")
return
}
h.wsHub.BroadcastToCompany(companyID, &websocket.Event{
Table: "production_plans",
Action: "INSERT",
})
c.JSON(http.StatusCreated, response.Response{
Code: 0,
Message: "success",
Data: plan,
})
}
func (h *PlanHandler) Get(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
id := c.Param("id")
plan, err := h.svc.GetByID(c.Request.Context(), id, companyID)
if err != nil {
log.Printf("get plan error: %v", err)
response.NotFound(c, "plan not found")
return
}
response.OK(c, plan)
}
func (h *PlanHandler) Update(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
id := c.Param("id")
var req UpdatePlanReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
svcReq := service.UpdatePlanRequest{
ProductName: req.ProductName,
FabricCode: req.FabricCode,
Color: req.Color,
ColorCode: req.ColorCode,
TargetQuantity: req.TargetQuantity,
ProductionPrice: req.ProductionPrice,
YarnUsagePerMeter: req.YarnUsagePerMeter,
Status: req.Status,
Remark: req.Remark,
}
if err := h.svc.Update(c.Request.Context(), id, companyID, svcReq); err != nil {
log.Printf("update plan error: %v", err)
response.InternalError(c, "failed to update plan")
return
}
h.wsHub.BroadcastToCompany(companyID, &websocket.Event{
Table: "production_plans",
Action: "UPDATE",
})
response.OK(c, nil)
}
func (h *PlanHandler) Delete(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
id := c.Param("id")
if err := h.svc.Delete(c.Request.Context(), id, companyID); err != nil {
log.Printf("delete plan error: %v", err)
response.InternalError(c, "failed to delete plan")
return
}
h.wsHub.BroadcastToCompany(companyID, &websocket.Event{
Table: "production_plans",
Action: "DELETE",
})
response.OK(c, nil)
}
func (h *PlanHandler) AssignFactory(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
planID := c.Param("id")
var req AssignFactoryReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.svc.AssignFactory(c.Request.Context(), planID, req.FactoryID, req.FactoryType); err != nil {
log.Printf("assign factory error: %v", err)
response.InternalError(c, "failed to assign factory")
return
}
h.wsHub.BroadcastToCompany(companyID, &websocket.Event{
Table: "plan_factories",
Action: "INSERT",
})
response.OK(c, nil)
}
func (h *PlanHandler) GetSteps(c *gin.Context) {
planID := c.Param("id")
steps, err := h.svc.GetSteps(c.Request.Context(), planID)
if err != nil {
log.Printf("get steps error: %v", err)
response.InternalError(c, "failed to get steps")
return
}
response.OK(c, steps)
}
func (h *PlanHandler) GetInventory(c *gin.Context) {
planID := c.Param("id")
records, err := h.svc.GetInventory(c.Request.Context(), planID)
if err != nil {
log.Printf("get inventory error: %v", err)
response.InternalError(c, "failed to get inventory")
return
}
response.OK(c, records)
}

View File

@ -0,0 +1,319 @@
package handler
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/service"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
"github.com/muyuqingfeng/iloom/shared/pkg/websocket"
)
type ProductHandler struct {
svc *service.ProductService
wsHub *websocket.Hub
}
func NewProductHandler(svc *service.ProductService, wsHub *websocket.Hub) *ProductHandler {
return &ProductHandler{svc: svc, wsHub: wsHub}
}
type CreateProductReq struct {
ProductName string `json:"product_name" binding:"required"`
FabricCode string `json:"fabric_code" binding:"required"`
Color string `json:"color" binding:"required"`
ColorCode string `json:"color_code"`
Weight float64 `json:"weight"`
YarnUsagePerMeter float64 `json:"yarn_usage_per_meter"`
ProductionPrice *float64 `json:"production_price"`
YarnTypes []string `json:"yarn_types"`
YarnRatios []float64 `json:"yarn_ratios"`
}
type UpdateProductReq struct {
ProductName string `json:"product_name"`
FabricCode string `json:"fabric_code"`
Color string `json:"color"`
ColorCode string `json:"color_code"`
Weight float64 `json:"weight"`
YarnUsagePerMeter float64 `json:"yarn_usage_per_meter"`
ProductionPrice *float64 `json:"production_price"`
YarnTypes []string `json:"yarn_types"`
YarnRatios []float64 `json:"yarn_ratios"`
}
type InboundReq struct {
BatchNo string `json:"batch_no" binding:"required"`
Rolls int `json:"rolls" binding:"required,gt=0"`
Meters float64 `json:"meters" binding:"required,gt=0"`
Notes string `json:"notes"`
WarehouseID string `json:"warehouse_id"`
}
type OutboundReq struct {
BatchNo string `json:"batch_no"`
Rolls int `json:"rolls" binding:"required,gt=0"`
Meters float64 `json:"meters" binding:"required,gt=0"`
OutboundType string `json:"outbound_type"`
Notes string `json:"notes"`
RecipientCompanyID string `json:"recipient_company_id"`
RecipientCompanyName string `json:"recipient_company_name"`
SourceBatchID string `json:"source_batch_id"`
WashingPlanID string `json:"washing_plan_id"`
}
type UpdatePriceReq struct {
NewPrice float64 `json:"new_price" binding:"required,gt=0"`
Reason string `json:"reason"`
}
func (h *ProductHandler) List(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
products, err := h.svc.List(c.Request.Context(), companyID)
if err != nil {
log.Printf("list products error: %v", err)
response.InternalError(c, "failed to list products")
return
}
response.OK(c, products)
}
func (h *ProductHandler) Create(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
var req CreateProductReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
svcReq := service.CreateProductRequest{
ProductName: req.ProductName,
FabricCode: req.FabricCode,
Color: req.Color,
ColorCode: req.ColorCode,
Weight: req.Weight,
YarnUsagePerMeter: req.YarnUsagePerMeter,
ProductionPrice: req.ProductionPrice,
YarnTypes: req.YarnTypes,
YarnRatios: req.YarnRatios,
}
product, err := h.svc.Create(c.Request.Context(), companyID, svcReq)
if err != nil {
log.Printf("create product error: %v", err)
response.InternalError(c, "failed to create product")
return
}
h.wsHub.BroadcastToCompany(companyID, &websocket.Event{
Table: "products",
Action: "INSERT",
})
c.JSON(http.StatusCreated, response.Response{
Code: 0,
Message: "success",
Data: product,
})
}
func (h *ProductHandler) Update(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
id := c.Param("id")
var req UpdateProductReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
svcReq := service.UpdateProductRequest{
ProductName: req.ProductName,
FabricCode: req.FabricCode,
Color: req.Color,
ColorCode: req.ColorCode,
Weight: req.Weight,
YarnUsagePerMeter: req.YarnUsagePerMeter,
ProductionPrice: req.ProductionPrice,
YarnTypes: req.YarnTypes,
YarnRatios: req.YarnRatios,
}
if err := h.svc.Update(c.Request.Context(), id, companyID, svcReq); err != nil {
log.Printf("update product error: %v", err)
response.InternalError(c, "failed to update product")
return
}
h.wsHub.BroadcastToCompany(companyID, &websocket.Event{
Table: "products",
Action: "UPDATE",
})
response.OK(c, nil)
}
func (h *ProductHandler) Delete(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
id := c.Param("id")
if err := h.svc.Delete(c.Request.Context(), id, companyID); err != nil {
log.Printf("delete product error: %v", err)
response.InternalError(c, "failed to delete product")
return
}
h.wsHub.BroadcastToCompany(companyID, &websocket.Event{
Table: "products",
Action: "DELETE",
})
response.OK(c, nil)
}
func (h *ProductHandler) Inbound(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
userID := c.GetHeader("X-User-ID")
if companyID == "" || userID == "" {
response.BadRequest(c, "missing user or company id")
return
}
productID := c.Param("id")
var req InboundReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
svcReq := service.InboundRequest{
BatchNo: req.BatchNo,
Rolls: req.Rolls,
Meters: req.Meters,
Notes: req.Notes,
WarehouseID: req.WarehouseID,
}
if err := h.svc.Inbound(c.Request.Context(), productID, companyID, userID, svcReq); err != nil {
log.Printf("inbound error: %v", err)
response.InternalError(c, "failed to inbound")
return
}
h.wsHub.BroadcastToCompany(companyID, &websocket.Event{
Table: "product_inventory_records",
Action: "INSERT",
})
response.OK(c, nil)
}
func (h *ProductHandler) Outbound(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
userID := c.GetHeader("X-User-ID")
if companyID == "" || userID == "" {
response.BadRequest(c, "missing user or company id")
return
}
productID := c.Param("id")
var req OutboundReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
svcReq := service.OutboundRequest{
BatchNo: req.BatchNo,
Rolls: req.Rolls,
Meters: req.Meters,
OutboundType: req.OutboundType,
Notes: req.Notes,
RecipientCompanyID: req.RecipientCompanyID,
RecipientCompanyName: req.RecipientCompanyName,
SourceBatchID: req.SourceBatchID,
WashingPlanID: req.WashingPlanID,
}
if err := h.svc.Outbound(c.Request.Context(), productID, companyID, userID, svcReq); err != nil {
log.Printf("outbound error: %v", err)
response.InternalError(c, "failed to outbound")
return
}
h.wsHub.BroadcastToCompany(companyID, &websocket.Event{
Table: "product_outbound_records",
Action: "INSERT",
})
response.OK(c, nil)
}
func (h *ProductHandler) Records(c *gin.Context) {
productID := c.Param("id")
records, err := h.svc.Records(c.Request.Context(), productID)
if err != nil {
log.Printf("get records error: %v", err)
response.InternalError(c, "failed to get records")
return
}
response.OK(c, records)
}
func (h *ProductHandler) UpdatePrice(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
userID := c.GetHeader("X-User-ID")
if companyID == "" || userID == "" {
response.BadRequest(c, "missing user or company id")
return
}
productID := c.Param("id")
var req UpdatePriceReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
if err := h.svc.UpdatePrice(c.Request.Context(), productID, companyID, userID, req.NewPrice, req.Reason); err != nil {
log.Printf("update price error: %v", err)
response.InternalError(c, "failed to update price")
return
}
response.OK(c, nil)
}
func (h *ProductHandler) PriceHistory(c *gin.Context) {
productID := c.Param("id")
history, err := h.svc.PriceHistory(c.Request.Context(), productID)
if err != nil {
log.Printf("get price history error: %v", err)
response.InternalError(c, "failed to get price history")
return
}
response.OK(c, history)
}

View File

@ -0,0 +1,81 @@
package handler
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/service"
"github.com/muyuqingfeng/iloom/shared/pkg/response"
)
type WashingPlanHandler struct {
svc *service.WashingPlanService
}
func NewWashingPlanHandler(svc *service.WashingPlanService) *WashingPlanHandler {
return &WashingPlanHandler{svc: svc}
}
type CreateWashingPlanReq struct {
ProductID string `json:"product_id" binding:"required"`
WashingFactoryID string `json:"washing_factory_id"`
PlannedMeters float64 `json:"planned_meters" binding:"required,gt=0"`
WashingPrice float64 `json:"washing_price" binding:"required,gt=0"`
EstimatedShrinkageRate float64 `json:"estimated_shrinkage_rate"`
Notes string `json:"notes"`
}
func (h *WashingPlanHandler) Create(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
userID := c.GetHeader("X-User-ID")
if companyID == "" || userID == "" {
response.BadRequest(c, "missing user or company id")
return
}
var req CreateWashingPlanReq
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err.Error())
return
}
svcReq := service.CreateWashingPlanRequest{
ProductID: req.ProductID,
WashingFactoryID: req.WashingFactoryID,
PlannedMeters: req.PlannedMeters,
WashingPrice: req.WashingPrice,
EstimatedShrinkageRate: req.EstimatedShrinkageRate,
Notes: req.Notes,
}
wp, err := h.svc.Create(c.Request.Context(), companyID, userID, svcReq)
if err != nil {
log.Printf("create washing plan error: %v", err)
response.InternalError(c, "failed to create washing plan")
return
}
c.JSON(http.StatusCreated, response.Response{
Code: 0,
Message: "success",
Data: wp,
})
}
func (h *WashingPlanHandler) List(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
response.BadRequest(c, "missing company id")
return
}
plans, err := h.svc.List(c.Request.Context(), companyID)
if err != nil {
log.Printf("list washing plans error: %v", err)
response.InternalError(c, "failed to list washing plans")
return
}
response.OK(c, plans)
}

View File

@ -0,0 +1,48 @@
package handler
import (
"log"
"net/http"
"github.com/gin-gonic/gin"
gorillaWS "github.com/gorilla/websocket"
"github.com/muyuqingfeng/iloom/shared/pkg/websocket"
)
var upgrader = gorillaWS.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
func HandleWebSocket(hub *websocket.Hub) gin.HandlerFunc {
return func(c *gin.Context) {
companyID := c.GetHeader("X-Company-ID")
if companyID == "" {
companyID = c.Query("company_id")
}
userID := c.GetHeader("X-User-ID")
if userID == "" {
userID = c.Query("user_id")
}
if companyID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing company_id"})
return
}
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
log.Printf("websocket upgrade error: %v", err)
return
}
client := websocket.NewClient(hub, conn, companyID, userID)
client.Register()
go client.WritePump()
go client.ReadPump()
}
}

View File

@ -0,0 +1,46 @@
package model
import "time"
type AccountsPayable struct {
ID string `json:"id" db:"id"`
PlanID string `json:"plan_id" db:"plan_id"`
PurchaserID string `json:"purchaser_id" db:"purchaser_id"`
TextileFactoryID string `json:"textile_factory_id" db:"textile_factory_id"`
PricePerMeter float64 `json:"price_per_meter" db:"price_per_meter"`
TotalQuantity float64 `json:"total_quantity" db:"total_quantity"`
TotalAmount float64 `json:"total_amount" db:"total_amount"`
PaidQuantity float64 `json:"paid_quantity" db:"paid_quantity"`
PaidAmount float64 `json:"paid_amount" db:"paid_amount"`
UnpaidQuantity float64 `json:"unpaid_quantity" db:"unpaid_quantity"`
UnpaidAmount float64 `json:"unpaid_amount" db:"unpaid_amount"`
Status string `json:"status" db:"status"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// Aggregated
PlanCode string `json:"plan_code,omitempty"`
FactoryName string `json:"factory_name,omitempty"`
}
type WashingPlan struct {
ID string `json:"id" db:"id"`
PlanCode string `json:"plan_code" db:"plan_code"`
CompanyID string `json:"company_id" db:"company_id"`
ProductID string `json:"product_id" db:"product_id"`
WashingFactoryID *string `json:"washing_factory_id,omitempty" db:"washing_factory_id"`
PlannedMeters float64 `json:"planned_meters" db:"planned_meters"`
WashingPrice float64 `json:"washing_price" db:"washing_price"`
EstimatedShrinkageRate float64 `json:"estimated_shrinkage_rate" db:"estimated_shrinkage_rate"`
EstimatedWashedMeters *float64 `json:"estimated_washed_meters,omitempty" db:"estimated_washed_meters"`
EstimatedTotalCost *float64 `json:"estimated_total_cost,omitempty" db:"estimated_total_cost"`
ActualShrinkageRate *float64 `json:"actual_shrinkage_rate,omitempty" db:"actual_shrinkage_rate"`
ActualWashedMeters *float64 `json:"actual_washed_meters,omitempty" db:"actual_washed_meters"`
ActualTotalCost *float64 `json:"actual_total_cost,omitempty" db:"actual_total_cost"`
WashingDate *time.Time `json:"washing_date,omitempty" db:"washing_date"`
Status string `json:"status" db:"status"`
Notes *string `json:"notes,omitempty" db:"notes"`
CreatedBy *string `json:"created_by,omitempty" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}

View File

@ -0,0 +1,16 @@
package model
import "time"
type CompanyRelationship struct {
ID string `json:"id" db:"id"`
PurchaserCompanyID string `json:"purchaser_company_id" db:"purchaser_company_id"`
FactoryCompanyID string `json:"factory_company_id" db:"factory_company_id"`
FactoryType string `json:"factory_type" db:"factory_type"`
Status string `json:"status" db:"status"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// Aggregated
FactoryName string `json:"factory_name,omitempty"`
}

View File

@ -0,0 +1,84 @@
package model
import "time"
type ProductionPlan struct {
ID string `json:"id" db:"id"`
PlanCode string `json:"plan_code" db:"plan_code"`
PurchaserID string `json:"purchaser_id" db:"purchaser_id"`
ProductName string `json:"product_name" db:"product_name"`
FabricCode string `json:"fabric_code" db:"fabric_code"`
Color string `json:"color" db:"color"`
ColorCode string `json:"color_code" db:"color_code"`
TargetQuantity float64 `json:"target_quantity" db:"target_quantity"`
CompletedQuantity float64 `json:"completed_quantity" db:"completed_quantity"`
ProductionPrice *float64 `json:"production_price,omitempty" db:"production_price"`
YarnUsagePerMeter *float64 `json:"yarn_usage_per_meter,omitempty" db:"yarn_usage_per_meter"`
Status string `json:"status" db:"status"`
Remark *string `json:"remark,omitempty" db:"remark"`
StartTime *time.Time `json:"start_time,omitempty" db:"start_time"`
CreatedBy *string `json:"created_by,omitempty" db:"created_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
// Aggregated fields
YarnRatios []YarnRatio `json:"yarn_ratios,omitempty"`
ProcessSteps []ProcessStep `json:"process_steps,omitempty"`
Factories []PlanFactory `json:"factories,omitempty"`
InventoryRecords []InventoryRecord `json:"inventory_records,omitempty"`
}
type YarnRatio struct {
ID string `json:"id" db:"id"`
PlanID string `json:"plan_id" db:"plan_id"`
YarnName string `json:"yarn_name" db:"yarn_name"`
YarnType *string `json:"yarn_type,omitempty" db:"yarn_type"`
Ratio float64 `json:"ratio" db:"ratio"`
AmountPerMeter float64 `json:"amount_per_meter" db:"amount_per_meter"`
TotalAmount float64 `json:"total_amount" db:"total_amount"`
CompanyID *string `json:"company_id,omitempty" db:"company_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type PlanFactory struct {
ID string `json:"id" db:"id"`
PlanID string `json:"plan_id" db:"plan_id"`
FactoryID string `json:"factory_id" db:"factory_id"`
FactoryType string `json:"factory_type" db:"factory_type"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
// Aggregated
FactoryName string `json:"factory_name,omitempty"`
}
type ProcessStep struct {
ID string `json:"id" db:"id"`
PlanID string `json:"plan_id" db:"plan_id"`
StepType string `json:"step_type" db:"step_type"`
Status string `json:"status" db:"status"`
Notes *string `json:"notes,omitempty" db:"notes"`
OperatorID *string `json:"operator_id,omitempty" db:"operator_id"`
CompanyID *string `json:"company_id,omitempty" db:"company_id"`
Timestamp *time.Time `json:"timestamp,omitempty" db:"timestamp"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
// Aggregated
OperatorName string `json:"operator_name,omitempty"`
}
type InventoryRecord struct {
ID string `json:"id" db:"id"`
PlanID string `json:"plan_id" db:"plan_id"`
WarehouseID string `json:"warehouse_id" db:"warehouse_id"`
Quantity float64 `json:"quantity" db:"quantity"`
Rolls *int `json:"rolls,omitempty" db:"rolls"`
PricePerMeter *float64 `json:"price_per_meter,omitempty" db:"price_per_meter"`
PriceNote *string `json:"price_note,omitempty" db:"price_note"`
WarehouseLocation *string `json:"warehouse_location,omitempty" db:"warehouse_location"`
OperatorID *string `json:"operator_id,omitempty" db:"operator_id"`
CompanyID *string `json:"company_id,omitempty" db:"company_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
// Aggregated
OperatorName string `json:"operator_name,omitempty"`
}

View File

@ -0,0 +1,66 @@
package model
import "time"
type Product struct {
ID string `json:"id" db:"id"`
CompanyID string `json:"company_id" db:"company_id"`
ProductName string `json:"product_name" db:"product_name"`
FabricCode string `json:"fabric_code" db:"fabric_code"`
Color string `json:"color" db:"color"`
ColorCode string `json:"color_code" db:"color_code"`
Weight float64 `json:"weight" db:"weight"`
WarpWeight *float64 `json:"warp_weight,omitempty" db:"warp_weight"`
WeftWeight *float64 `json:"weft_weight,omitempty" db:"weft_weight"`
TotalWeight *float64 `json:"total_weight,omitempty" db:"total_weight"`
YarnUsagePerMeter float64 `json:"yarn_usage_per_meter" db:"yarn_usage_per_meter"`
ProductionPrice *float64 `json:"production_price,omitempty" db:"production_price"`
TotalStock float64 `json:"total_stock" db:"total_stock"`
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"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type ProductInventoryRecord struct {
ID string `json:"id" db:"id"`
ProductID string `json:"product_id" db:"product_id"`
BatchNo string `json:"batch_no" db:"batch_no"`
Rolls int `json:"rolls" db:"rolls"`
Meters float64 `json:"meters" db:"meters"`
Notes *string `json:"notes,omitempty" db:"notes"`
OperatorID *string `json:"operator_id,omitempty" db:"operator_id"`
CompanyID *string `json:"company_id,omitempty" db:"company_id"`
WarehouseID *string `json:"warehouse_id,omitempty" db:"warehouse_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type ProductOutboundRecord struct {
ID string `json:"id" db:"id"`
ProductID string `json:"product_id" db:"product_id"`
BatchNo string `json:"batch_no" db:"batch_no"`
Rolls int `json:"rolls" db:"rolls"`
Meters float64 `json:"meters" db:"meters"`
OutboundType string `json:"outbound_type" db:"outbound_type"`
Notes *string `json:"notes,omitempty" db:"notes"`
OperatorID *string `json:"operator_id,omitempty" db:"operator_id"`
CompanyID *string `json:"company_id,omitempty" db:"company_id"`
RecipientCompanyID *string `json:"recipient_company_id,omitempty" db:"recipient_company_id"`
RecipientCompanyName *string `json:"recipient_company_name,omitempty" db:"recipient_company_name"`
SourceBatchID *string `json:"source_batch_id,omitempty" db:"source_batch_id"`
WashingPlanID *string `json:"washing_plan_id,omitempty" db:"washing_plan_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type ProductPriceHistory struct {
ID string `json:"id" db:"id"`
ProductID string `json:"product_id" db:"product_id"`
OldPrice *float64 `json:"old_price,omitempty" db:"old_price"`
NewPrice float64 `json:"new_price" db:"new_price"`
Reason *string `json:"reason,omitempty" db:"reason"`
ChangedBy *string `json:"changed_by,omitempty" db:"changed_by"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}

View File

@ -0,0 +1,86 @@
package repository
import (
"context"
"database/sql"
"fmt"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/model"
)
type APRepo struct {
db *sql.DB
}
func NewAPRepo(db *sql.DB) *APRepo {
return &APRepo{db: db}
}
func (r *APRepo) List(ctx context.Context, companyID string) ([]model.AccountsPayable, error) {
query := `SELECT ap.id, ap.plan_id, ap.purchaser_id, ap.textile_factory_id,
ap.price_per_meter, ap.total_quantity, ap.total_amount,
ap.paid_quantity, ap.paid_amount, ap.unpaid_quantity, ap.unpaid_amount,
ap.status, ap.created_at, ap.updated_at,
COALESCE(pp.plan_code, '') as plan_code,
COALESCE(c.name, '') as factory_name
FROM ilm_accounts_payable ap
LEFT JOIN ilm_production_plan pp ON pp.id = ap.plan_id
LEFT JOIN ilm_company c ON c.id = ap.textile_factory_id
WHERE ap.purchaser_id = ?
ORDER BY ap.created_at DESC`
rows, err := r.db.QueryContext(ctx, query, companyID)
if err != nil {
return nil, fmt.Errorf("list accounts payable: %w", err)
}
defer rows.Close()
var list []model.AccountsPayable
for rows.Next() {
var ap model.AccountsPayable
if err := rows.Scan(
&ap.ID, &ap.PlanID, &ap.PurchaserID, &ap.TextileFactoryID,
&ap.PricePerMeter, &ap.TotalQuantity, &ap.TotalAmount,
&ap.PaidQuantity, &ap.PaidAmount, &ap.UnpaidQuantity, &ap.UnpaidAmount,
&ap.Status, &ap.CreatedAt, &ap.UpdatedAt,
&ap.PlanCode, &ap.FactoryName,
); err != nil {
return nil, fmt.Errorf("scan accounts payable: %w", err)
}
list = append(list, ap)
}
return list, nil
}
func (r *APRepo) Pay(ctx context.Context, tx *sql.Tx, apID string, amount float64) error {
query := `UPDATE ilm_accounts_payable SET
paid_amount = paid_amount + ?,
unpaid_amount = unpaid_amount - ?,
status = CASE WHEN unpaid_amount - ? <= 0 THEN 'completed' ELSE status END,
updated_at = NOW()
WHERE id = ? AND unpaid_amount >= ?`
result, err := tx.ExecContext(ctx, query, amount, amount, amount, apID, amount)
if err != nil {
return fmt.Errorf("pay: %w", err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("check rows affected: %w", err)
}
if rowsAffected == 0 {
return fmt.Errorf("payment failed: insufficient unpaid amount or record not found")
}
return nil
}
func (r *APRepo) TotalUnpaid(ctx context.Context, companyID string) (float64, error) {
var total float64
err := r.db.QueryRowContext(ctx,
`SELECT COALESCE(SUM(unpaid_amount), 0) FROM ilm_accounts_payable WHERE purchaser_id = ?`,
companyID,
).Scan(&total)
if err != nil {
return 0, fmt.Errorf("total unpaid: %w", err)
}
return total, nil
}

View File

@ -0,0 +1,47 @@
package repository
import (
"context"
"database/sql"
"fmt"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/model"
)
type FactoryRepo struct {
db *sql.DB
}
func NewFactoryRepo(db *sql.DB) *FactoryRepo {
return &FactoryRepo{db: db}
}
func (r *FactoryRepo) ListRelationships(ctx context.Context, companyID string) ([]model.CompanyRelationship, error) {
query := `SELECT cr.id, cr.purchaser_company_id, cr.factory_company_id,
cr.factory_type, cr.status, cr.created_at, cr.updated_at,
COALESCE(c.name, '') as factory_name
FROM ilm_company_relationship cr
LEFT JOIN ilm_company c ON c.id = cr.factory_company_id
WHERE cr.purchaser_company_id = ? AND cr.status = 'active'
ORDER BY cr.created_at DESC`
rows, err := r.db.QueryContext(ctx, query, companyID)
if err != nil {
return nil, fmt.Errorf("list relationships: %w", err)
}
defer rows.Close()
var rels []model.CompanyRelationship
for rows.Next() {
var rel model.CompanyRelationship
if err := rows.Scan(
&rel.ID, &rel.PurchaserCompanyID, &rel.FactoryCompanyID,
&rel.FactoryType, &rel.Status, &rel.CreatedAt, &rel.UpdatedAt,
&rel.FactoryName,
); err != nil {
return nil, fmt.Errorf("scan relationship: %w", err)
}
rels = append(rels, rel)
}
return rels, nil
}

View File

@ -0,0 +1,324 @@
package repository
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/model"
"github.com/muyuqingfeng/iloom/shared/pkg/types"
)
type PlanRepo struct {
db *sql.DB
}
func NewPlanRepo(db *sql.DB) *PlanRepo {
return &PlanRepo{db: db}
}
func (r *PlanRepo) List(ctx context.Context, companyID string, p types.Pagination, status string) ([]model.ProductionPlan, int, error) {
countQuery := `SELECT COUNT(*) FROM ilm_production_plan WHERE purchaser_id = ?`
args := []interface{}{companyID}
if status != "" {
countQuery += " AND status = ?"
args = append(args, status)
}
var total int
if err := r.db.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count plans: %w", err)
}
query := `SELECT id, plan_code, purchaser_id, product_name, fabric_code, color, color_code,
target_quantity, completed_quantity, production_price, yarn_usage_per_meter,
status, remark, start_time, created_by, created_at, updated_at
FROM ilm_production_plan WHERE purchaser_id = ?`
queryArgs := []interface{}{companyID}
if status != "" {
query += " AND status = ?"
queryArgs = append(queryArgs, status)
}
query += " ORDER BY created_at DESC"
query += " LIMIT ? OFFSET ?"
queryArgs = append(queryArgs, p.PageSize, p.Offset())
rows, err := r.db.QueryContext(ctx, query, queryArgs...)
if err != nil {
return nil, 0, fmt.Errorf("list plans: %w", err)
}
defer rows.Close()
var plans []model.ProductionPlan
for rows.Next() {
var plan model.ProductionPlan
if err := rows.Scan(
&plan.ID, &plan.PlanCode, &plan.PurchaserID, &plan.ProductName,
&plan.FabricCode, &plan.Color, &plan.ColorCode,
&plan.TargetQuantity, &plan.CompletedQuantity,
&plan.ProductionPrice, &plan.YarnUsagePerMeter,
&plan.Status, &plan.Remark, &plan.StartTime,
&plan.CreatedBy, &plan.CreatedAt, &plan.UpdatedAt,
); err != nil {
return nil, 0, fmt.Errorf("scan plan: %w", err)
}
plans = append(plans, plan)
}
return plans, total, nil
}
func (r *PlanRepo) Create(ctx context.Context, tx *sql.Tx, plan *model.ProductionPlan) error {
query := `INSERT INTO ilm_production_plan
(id, plan_code, purchaser_id, product_name, fabric_code, color, color_code,
target_quantity, completed_quantity, production_price, yarn_usage_per_meter,
status, remark, start_time, created_by, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
_, err := tx.ExecContext(ctx, query,
plan.ID, plan.PlanCode, plan.PurchaserID, plan.ProductName,
plan.FabricCode, plan.Color, plan.ColorCode,
plan.TargetQuantity, plan.CompletedQuantity,
plan.ProductionPrice, plan.YarnUsagePerMeter,
plan.Status, plan.Remark, plan.StartTime,
plan.CreatedBy, plan.CreatedAt, plan.UpdatedAt,
)
if err != nil {
return fmt.Errorf("insert plan: %w", err)
}
return nil
}
func (r *PlanRepo) CreateYarnRatios(ctx context.Context, tx *sql.Tx, ratios []model.YarnRatio) error {
for _, ratio := range ratios {
query := `INSERT INTO ilm_yarn_ratio
(id, plan_id, yarn_name, yarn_type, ratio, amount_per_meter, total_amount, company_id, created_at)
VALUES (?,?,?,?,?,?,?,?,?)`
_, err := tx.ExecContext(ctx, query,
ratio.ID, ratio.PlanID, ratio.YarnName, ratio.YarnType,
ratio.Ratio, ratio.AmountPerMeter, ratio.TotalAmount,
ratio.CompanyID, ratio.CreatedAt,
)
if err != nil {
return fmt.Errorf("insert yarn ratio: %w", err)
}
}
return nil
}
func (r *PlanRepo) GetByID(ctx context.Context, id string) (*model.ProductionPlan, error) {
query := `SELECT id, plan_code, purchaser_id, product_name, fabric_code, color, color_code,
target_quantity, completed_quantity, production_price, yarn_usage_per_meter,
status, remark, start_time, created_by, created_at, updated_at
FROM ilm_production_plan WHERE id = ?`
plan := &model.ProductionPlan{}
err := r.db.QueryRowContext(ctx, query, id).Scan(
&plan.ID, &plan.PlanCode, &plan.PurchaserID, &plan.ProductName,
&plan.FabricCode, &plan.Color, &plan.ColorCode,
&plan.TargetQuantity, &plan.CompletedQuantity,
&plan.ProductionPrice, &plan.YarnUsagePerMeter,
&plan.Status, &plan.Remark, &plan.StartTime,
&plan.CreatedBy, &plan.CreatedAt, &plan.UpdatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, nil
}
return nil, fmt.Errorf("get plan by id: %w", err)
}
// Load yarn ratios
yarnRows, err := r.db.QueryContext(ctx,
`SELECT id, plan_id, yarn_name, yarn_type, ratio, amount_per_meter, total_amount, company_id, created_at
FROM ilm_yarn_ratio WHERE plan_id = ? ORDER BY created_at`, id)
if err != nil {
return nil, fmt.Errorf("get yarn ratios: %w", err)
}
defer yarnRows.Close()
for yarnRows.Next() {
var yr model.YarnRatio
if err := yarnRows.Scan(
&yr.ID, &yr.PlanID, &yr.YarnName, &yr.YarnType,
&yr.Ratio, &yr.AmountPerMeter, &yr.TotalAmount,
&yr.CompanyID, &yr.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan yarn ratio: %w", err)
}
plan.YarnRatios = append(plan.YarnRatios, yr)
}
// Load factories
factoryRows, err := r.db.QueryContext(ctx,
`SELECT pf.id, pf.plan_id, pf.factory_id, pf.factory_type, pf.created_at,
COALESCE(c.name, '') as factory_name
FROM ilm_plan_factory pf
LEFT JOIN ilm_company c ON c.id = pf.factory_id
WHERE pf.plan_id = ? ORDER BY pf.created_at`, id)
if err != nil {
return nil, fmt.Errorf("get plan factories: %w", err)
}
defer factoryRows.Close()
for factoryRows.Next() {
var pf model.PlanFactory
if err := factoryRows.Scan(
&pf.ID, &pf.PlanID, &pf.FactoryID, &pf.FactoryType,
&pf.CreatedAt, &pf.FactoryName,
); err != nil {
return nil, fmt.Errorf("scan plan factory: %w", err)
}
plan.Factories = append(plan.Factories, pf)
}
return plan, nil
}
func (r *PlanRepo) Update(ctx context.Context, plan *model.ProductionPlan) error {
query := `UPDATE ilm_production_plan SET
product_name = ?, fabric_code = ?, color = ?, color_code = ?,
target_quantity = ?, production_price = ?, yarn_usage_per_meter = ?,
status = ?, remark = ?, updated_at = ?
WHERE id = ?`
_, err := r.db.ExecContext(ctx, query,
plan.ProductName, plan.FabricCode, plan.Color, plan.ColorCode,
plan.TargetQuantity, plan.ProductionPrice, plan.YarnUsagePerMeter,
plan.Status, plan.Remark, time.Now(),
plan.ID,
)
if err != nil {
return fmt.Errorf("update plan: %w", err)
}
return nil
}
func (r *PlanRepo) Delete(ctx context.Context, id, companyID string) error {
_, err := r.db.ExecContext(ctx,
`DELETE FROM ilm_production_plan WHERE id = ? AND purchaser_id = ?`,
id, companyID,
)
if err != nil {
return fmt.Errorf("delete plan: %w", err)
}
return nil
}
func (r *PlanRepo) AssignFactory(ctx context.Context, planID, factoryID, factoryType string) error {
id := uuid.New().String()
_, err := r.db.ExecContext(ctx,
`INSERT INTO ilm_plan_factory (id, plan_id, factory_id, factory_type, created_at)
VALUES (?, ?, ?, ?, ?)`,
id, planID, factoryID, factoryType, time.Now(),
)
if err != nil {
return fmt.Errorf("assign factory: %w", err)
}
return nil
}
func (r *PlanRepo) GetSteps(ctx context.Context, planID string) ([]model.ProcessStep, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT ps.id, ps.plan_id, ps.step_type, ps.status, ps.notes,
ps.operator_id, ps.company_id, ps.timestamp, ps.created_at,
COALESCE(su.real_name, '') as operator_name
FROM ilm_process_step ps
LEFT JOIN sys_user su ON su.user_id = ps.operator_id
WHERE ps.plan_id = ? ORDER BY ps.created_at`, planID)
if err != nil {
return nil, fmt.Errorf("get steps: %w", err)
}
defer rows.Close()
var steps []model.ProcessStep
for rows.Next() {
var s model.ProcessStep
if err := rows.Scan(
&s.ID, &s.PlanID, &s.StepType, &s.Status, &s.Notes,
&s.OperatorID, &s.CompanyID, &s.Timestamp, &s.CreatedAt,
&s.OperatorName,
); err != nil {
return nil, fmt.Errorf("scan step: %w", err)
}
steps = append(steps, s)
}
return steps, nil
}
func (r *PlanRepo) GetInventory(ctx context.Context, planID string) ([]model.InventoryRecord, error) {
rows, err := r.db.QueryContext(ctx,
`SELECT ir.id, ir.plan_id, ir.warehouse_id, ir.quantity, ir.rolls,
ir.price_per_meter, ir.price_note, ir.warehouse_location,
ir.operator_id, ir.company_id, ir.created_at,
COALESCE(su.real_name, '') as operator_name
FROM ilm_inventory_record ir
LEFT JOIN sys_user su ON su.user_id = ir.operator_id
WHERE ir.plan_id = ? ORDER BY ir.created_at DESC`, planID)
if err != nil {
return nil, fmt.Errorf("get inventory: %w", err)
}
defer rows.Close()
var records []model.InventoryRecord
for rows.Next() {
var rec model.InventoryRecord
if err := rows.Scan(
&rec.ID, &rec.PlanID, &rec.WarehouseID, &rec.Quantity, &rec.Rolls,
&rec.PricePerMeter, &rec.PriceNote, &rec.WarehouseLocation,
&rec.OperatorID, &rec.CompanyID, &rec.CreatedAt,
&rec.OperatorName,
); err != nil {
return nil, fmt.Errorf("scan inventory record: %w", err)
}
records = append(records, rec)
}
return records, nil
}
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)
FROM ilm_production_plan WHERE purchaser_id = ?`
err = r.db.QueryRowContext(ctx, query, companyID).Scan(&total, &producing, &completed)
if err != nil {
return 0, 0, 0, fmt.Errorf("count by status: %w", err)
}
return
}
func (r *PlanRepo) Recent(ctx context.Context, companyID string, limit int) ([]model.ProductionPlan, error) {
query := `SELECT id, plan_code, purchaser_id, product_name, fabric_code, color, color_code,
target_quantity, completed_quantity, production_price, yarn_usage_per_meter,
status, remark, start_time, created_by, created_at, updated_at
FROM ilm_production_plan WHERE purchaser_id = ?
ORDER BY created_at DESC LIMIT ?`
rows, err := r.db.QueryContext(ctx, query, companyID, limit)
if err != nil {
return nil, fmt.Errorf("recent plans: %w", err)
}
defer rows.Close()
var plans []model.ProductionPlan
for rows.Next() {
var plan model.ProductionPlan
if err := rows.Scan(
&plan.ID, &plan.PlanCode, &plan.PurchaserID, &plan.ProductName,
&plan.FabricCode, &plan.Color, &plan.ColorCode,
&plan.TargetQuantity, &plan.CompletedQuantity,
&plan.ProductionPrice, &plan.YarnUsagePerMeter,
&plan.Status, &plan.Remark, &plan.StartTime,
&plan.CreatedBy, &plan.CreatedAt, &plan.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan recent plan: %w", err)
}
plans = append(plans, plan)
}
return plans, nil
}

View File

@ -0,0 +1,264 @@
package repository
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/model"
)
type ProductRepo struct {
db *sql.DB
}
func NewProductRepo(db *sql.DB) *ProductRepo {
return &ProductRepo{db: db}
}
func (r *ProductRepo) List(ctx context.Context, companyID string) ([]model.Product, error) {
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
FROM products WHERE company_id = ? ORDER BY created_at DESC`
rows, err := r.db.QueryContext(ctx, query, companyID)
if err != nil {
return nil, fmt.Errorf("list products: %w", err)
}
defer rows.Close()
var products []model.Product
for rows.Next() {
var p model.Product
if err := rows.Scan(
&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.CreatedAt, &p.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan product: %w", err)
}
products = append(products, p)
}
return products, nil
}
func (r *ProductRepo) Create(ctx context.Context, p *model.Product) error {
query := `INSERT INTO products
(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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
_, 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.CreatedAt, p.UpdatedAt,
)
if err != nil {
return fmt.Errorf("insert product: %w", err)
}
return nil
}
func (r *ProductRepo) Update(ctx context.Context, p *model.Product) error {
query := `UPDATE products SET
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 = ?
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.ID,
)
if err != nil {
return fmt.Errorf("update product: %w", err)
}
return nil
}
func (r *ProductRepo) Delete(ctx context.Context, id, companyID string) error {
_, err := r.db.ExecContext(ctx,
`DELETE FROM products WHERE id = ? AND company_id = ?`,
id, companyID,
)
if err != nil {
return fmt.Errorf("delete product: %w", err)
}
return nil
}
func (r *ProductRepo) Inbound(ctx context.Context, tx *sql.Tx, rec *model.ProductInventoryRecord) error {
query := `INSERT INTO ilm_product_inventory
(id, product_id, batch_no, rolls, meters, notes, operator_id, company_id, warehouse_id, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?)`
_, err := tx.ExecContext(ctx, query,
rec.ID, rec.ProductID, rec.BatchNo, rec.Rolls, rec.Meters,
rec.Notes, rec.OperatorID, rec.CompanyID, rec.WarehouseID, rec.CreatedAt,
)
if err != nil {
return fmt.Errorf("insert inbound record: %w", err)
}
return nil
}
func (r *ProductRepo) UpdateStock(ctx context.Context, tx *sql.Tx, productID string, deltaMeters float64, deltaRolls int) error {
query := `UPDATE products SET
total_stock = total_stock + ?,
raw_fabric_meters = raw_fabric_meters + ?,
raw_fabric_rolls = raw_fabric_rolls + ?,
updated_at = ?
WHERE id = ?`
_, err := tx.ExecContext(ctx, query, deltaMeters, deltaMeters, deltaRolls, time.Now(), productID)
if err != nil {
return fmt.Errorf("update stock: %w", err)
}
return nil
}
func (r *ProductRepo) Outbound(ctx context.Context, tx *sql.Tx, rec *model.ProductOutboundRecord) error {
query := `INSERT INTO ilm_product_outbound
(id, product_id, batch_no, rolls, meters, outbound_type, notes,
operator_id, company_id, recipient_company_id, recipient_company_name,
source_batch_id, washing_plan_id, created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
_, err := tx.ExecContext(ctx, query,
rec.ID, rec.ProductID, rec.BatchNo, rec.Rolls, rec.Meters,
rec.OutboundType, rec.Notes,
rec.OperatorID, rec.CompanyID,
rec.RecipientCompanyID, rec.RecipientCompanyName,
rec.SourceBatchID, rec.WashingPlanID, rec.CreatedAt,
)
if err != nil {
return fmt.Errorf("insert outbound record: %w", err)
}
return nil
}
func (r *ProductRepo) Records(ctx context.Context, productID string) ([]model.ProductInventoryRecord, error) {
query := `SELECT id, product_id, batch_no, rolls, meters, notes,
operator_id, company_id, warehouse_id, created_at
FROM ilm_product_inventory
WHERE product_id = ? ORDER BY created_at DESC`
rows, err := r.db.QueryContext(ctx, query, productID)
if err != nil {
return nil, fmt.Errorf("list records: %w", err)
}
defer rows.Close()
var records []model.ProductInventoryRecord
for rows.Next() {
var rec model.ProductInventoryRecord
if err := rows.Scan(
&rec.ID, &rec.ProductID, &rec.BatchNo, &rec.Rolls, &rec.Meters,
&rec.Notes, &rec.OperatorID, &rec.CompanyID, &rec.WarehouseID, &rec.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan record: %w", err)
}
records = append(records, rec)
}
return records, nil
}
func (r *ProductRepo) UpdatePrice(ctx context.Context, tx *sql.Tx, productID string, newPrice float64) error {
_, err := tx.ExecContext(ctx,
`UPDATE products SET production_price = ?, updated_at = ? WHERE id = ?`,
newPrice, time.Now(), productID,
)
if err != nil {
return fmt.Errorf("update price: %w", err)
}
return nil
}
func (r *ProductRepo) GetPrice(ctx context.Context, productID string) (*float64, error) {
var price *float64
err := r.db.QueryRowContext(ctx,
`SELECT production_price FROM products WHERE id = ?`, productID,
).Scan(&price)
if err != nil {
return nil, fmt.Errorf("get price: %w", err)
}
return price, nil
}
func (r *ProductRepo) InsertPriceHistory(ctx context.Context, tx *sql.Tx, productID string, oldPrice *float64, newPrice float64, reason, changedBy string) error {
id := uuid.New().String()
var reasonPtr *string
if reason != "" {
reasonPtr = &reason
}
var changedByPtr *string
if changedBy != "" {
changedByPtr = &changedBy
}
_, err := tx.ExecContext(ctx,
`INSERT INTO ilm_product_price_history
(id, product_id, old_price, new_price, reason, changed_by, created_at)
VALUES (?,?,?,?,?,?,?)`,
id, productID, oldPrice, newPrice, reasonPtr, changedByPtr, time.Now(),
)
if err != nil {
return fmt.Errorf("insert price history: %w", err)
}
return nil
}
func (r *ProductRepo) PriceHistory(ctx context.Context, productID string) ([]model.ProductPriceHistory, error) {
query := `SELECT id, product_id, old_price, new_price, reason, changed_by, created_at
FROM ilm_product_price_history
WHERE product_id = ? ORDER BY created_at DESC`
rows, err := r.db.QueryContext(ctx, query, productID)
if err != nil {
return nil, fmt.Errorf("list price history: %w", err)
}
defer rows.Close()
var history []model.ProductPriceHistory
for rows.Next() {
var h model.ProductPriceHistory
if err := rows.Scan(
&h.ID, &h.ProductID, &h.OldPrice, &h.NewPrice,
&h.Reason, &h.ChangedBy, &h.CreatedAt,
); err != nil {
return nil, fmt.Errorf("scan price history: %w", err)
}
history = append(history, h)
}
return history, nil
}
func (r *ProductRepo) Count(ctx context.Context, companyID string) (int, error) {
var count int
err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM products WHERE company_id = ?`, companyID,
).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count products: %w", err)
}
return count, nil
}
func (r *ProductRepo) TotalStock(ctx context.Context, companyID string) (float64, error) {
var total float64
err := r.db.QueryRowContext(ctx,
`SELECT COALESCE(SUM(total_stock), 0) FROM products WHERE company_id = ?`, companyID,
).Scan(&total)
if err != nil {
return 0, fmt.Errorf("total stock: %w", err)
}
return total, nil
}

View File

@ -0,0 +1,86 @@
package repository
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/model"
)
type WashingPlanRepo struct {
db *sql.DB
}
func NewWashingPlanRepo(db *sql.DB) *WashingPlanRepo {
return &WashingPlanRepo{db: db}
}
func (r *WashingPlanRepo) Create(ctx context.Context, wp *model.WashingPlan) error {
query := `INSERT INTO ilm_washing_plan
(id, plan_code, company_id, product_id, washing_factory_id,
planned_meters, washing_price, estimated_shrinkage_rate,
estimated_washed_meters, estimated_total_cost,
status, notes, created_by, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`
_, err := r.db.ExecContext(ctx, query,
wp.ID, wp.PlanCode, wp.CompanyID, wp.ProductID, wp.WashingFactoryID,
wp.PlannedMeters, wp.WashingPrice, wp.EstimatedShrinkageRate,
wp.EstimatedWashedMeters, wp.EstimatedTotalCost,
wp.Status, wp.Notes, wp.CreatedBy, wp.CreatedAt, wp.UpdatedAt,
)
if err != nil {
return fmt.Errorf("insert washing plan: %w", err)
}
return nil
}
func (r *WashingPlanRepo) List(ctx context.Context, companyID string) ([]model.WashingPlan, error) {
query := `SELECT id, plan_code, company_id, product_id, washing_factory_id,
planned_meters, washing_price, estimated_shrinkage_rate,
estimated_washed_meters, estimated_total_cost,
actual_shrinkage_rate, actual_washed_meters, actual_total_cost,
washing_date, status, notes, created_by, created_at, updated_at
FROM ilm_washing_plan WHERE company_id = ?
ORDER BY created_at DESC`
rows, err := r.db.QueryContext(ctx, query, companyID)
if err != nil {
return nil, fmt.Errorf("list washing plans: %w", err)
}
defer rows.Close()
var plans []model.WashingPlan
for rows.Next() {
var wp model.WashingPlan
if err := rows.Scan(
&wp.ID, &wp.PlanCode, &wp.CompanyID, &wp.ProductID, &wp.WashingFactoryID,
&wp.PlannedMeters, &wp.WashingPrice, &wp.EstimatedShrinkageRate,
&wp.EstimatedWashedMeters, &wp.EstimatedTotalCost,
&wp.ActualShrinkageRate, &wp.ActualWashedMeters, &wp.ActualTotalCost,
&wp.WashingDate, &wp.Status, &wp.Notes, &wp.CreatedBy,
&wp.CreatedAt, &wp.UpdatedAt,
); err != nil {
return nil, fmt.Errorf("scan washing plan: %w", err)
}
plans = append(plans, wp)
}
return plans, nil
}
func (r *WashingPlanRepo) GeneratePlanCode(ctx context.Context) (string, error) {
today := time.Now().Format("20060102")
prefix := "WP" + today
var count int
err := r.db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM ilm_washing_plan WHERE plan_code LIKE ?`,
prefix+"%",
).Scan(&count)
if err != nil {
return "", fmt.Errorf("generate plan code: %w", err)
}
return fmt.Sprintf("%s%03d", prefix, count+1), nil
}

View File

@ -0,0 +1,41 @@
package service
import (
"context"
"database/sql"
"fmt"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/model"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/repository"
)
type APService struct {
repo *repository.APRepo
db *sql.DB
}
func NewAPService(repo *repository.APRepo, db *sql.DB) *APService {
return &APService{repo: repo, db: db}
}
func (s *APService) List(ctx context.Context, companyID string) ([]model.AccountsPayable, error) {
return s.repo.List(ctx, companyID)
}
func (s *APService) Pay(ctx context.Context, apID, companyID string, amount float64) error {
if amount <= 0 {
return fmt.Errorf("amount must be positive")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if err := s.repo.Pay(ctx, tx, apID, amount); err != nil {
return err
}
return tx.Commit()
}

View File

@ -0,0 +1,69 @@
package service
import (
"context"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/model"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/repository"
)
type DashboardStats struct {
TotalPlans int `json:"total_plans"`
ProducingPlans int `json:"producing_plans"`
CompletedPlans int `json:"completed_plans"`
TotalProducts int `json:"total_products"`
TotalStock float64 `json:"total_stock"`
UnpaidAmount float64 `json:"unpaid_amount"`
RecentPlans []model.ProductionPlan `json:"recent_plans"`
}
type DashboardService struct {
planRepo *repository.PlanRepo
productRepo *repository.ProductRepo
apRepo *repository.APRepo
}
func NewDashboardService(planRepo *repository.PlanRepo, productRepo *repository.ProductRepo, apRepo *repository.APRepo) *DashboardService {
return &DashboardService{
planRepo: planRepo,
productRepo: productRepo,
apRepo: apRepo,
}
}
func (s *DashboardService) Stats(ctx context.Context, companyID string) (*DashboardStats, error) {
total, producing, completed, err := s.planRepo.CountByStatus(ctx, companyID)
if err != nil {
return nil, err
}
productCount, err := s.productRepo.Count(ctx, companyID)
if err != nil {
return nil, err
}
totalStock, err := s.productRepo.TotalStock(ctx, companyID)
if err != nil {
return nil, err
}
unpaid, err := s.apRepo.TotalUnpaid(ctx, companyID)
if err != nil {
return nil, err
}
recentPlans, err := s.planRepo.Recent(ctx, companyID, 5)
if err != nil {
return nil, err
}
return &DashboardStats{
TotalPlans: total,
ProducingPlans: producing,
CompletedPlans: completed,
TotalProducts: productCount,
TotalStock: totalStock,
UnpaidAmount: unpaid,
RecentPlans: recentPlans,
}, nil
}

View File

@ -0,0 +1,20 @@
package service
import (
"context"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/model"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/repository"
)
type FactoryService struct {
repo *repository.FactoryRepo
}
func NewFactoryService(repo *repository.FactoryRepo) *FactoryService {
return &FactoryService{repo: repo}
}
func (s *FactoryService) List(ctx context.Context, companyID string) ([]model.CompanyRelationship, error) {
return s.repo.ListRelationships(ctx, companyID)
}

View File

@ -0,0 +1,213 @@
package service
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/model"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/repository"
"github.com/muyuqingfeng/iloom/shared/pkg/types"
)
type PlanService struct {
repo *repository.PlanRepo
db *sql.DB
}
func NewPlanService(repo *repository.PlanRepo, db *sql.DB) *PlanService {
return &PlanService{repo: repo, db: db}
}
type CreatePlanRequest struct {
ProductName string `json:"product_name"`
FabricCode string `json:"fabric_code"`
Color string `json:"color"`
ColorCode string `json:"color_code"`
TargetQuantity float64 `json:"target_quantity"`
ProductionPrice *float64 `json:"production_price"`
YarnUsagePerMeter *float64 `json:"yarn_usage_per_meter"`
Remark string `json:"remark"`
YarnRatios []CreateYarnRatio `json:"yarn_ratios"`
}
type CreateYarnRatio struct {
YarnName string `json:"yarn_name"`
YarnType string `json:"yarn_type"`
Ratio float64 `json:"ratio"`
AmountPerMeter float64 `json:"amount_per_meter"`
TotalAmount float64 `json:"total_amount"`
}
type UpdatePlanRequest struct {
ProductName string `json:"product_name"`
FabricCode string `json:"fabric_code"`
Color string `json:"color"`
ColorCode string `json:"color_code"`
TargetQuantity float64 `json:"target_quantity"`
ProductionPrice *float64 `json:"production_price"`
YarnUsagePerMeter *float64 `json:"yarn_usage_per_meter"`
Status string `json:"status"`
Remark string `json:"remark"`
}
func (s *PlanService) List(ctx context.Context, companyID string, page types.Pagination, status string) ([]model.ProductionPlan, int, error) {
page.Normalize()
return s.repo.List(ctx, companyID, page, status)
}
func (s *PlanService) Create(ctx context.Context, companyID, userID string, req CreatePlanRequest) (*model.ProductionPlan, error) {
now := time.Now()
planCode := fmt.Sprintf("PP%s%03d", now.Format("20060102"), now.UnixMilli()%1000)
var remarkPtr *string
if req.Remark != "" {
remarkPtr = &req.Remark
}
plan := &model.ProductionPlan{
ID: uuid.New().String(),
PlanCode: planCode,
PurchaserID: companyID,
ProductName: req.ProductName,
FabricCode: req.FabricCode,
Color: req.Color,
ColorCode: req.ColorCode,
TargetQuantity: req.TargetQuantity,
CompletedQuantity: 0,
ProductionPrice: req.ProductionPrice,
YarnUsagePerMeter: req.YarnUsagePerMeter,
Status: string(types.PlanPending),
Remark: remarkPtr,
CreatedBy: &userID,
CreatedAt: now,
UpdatedAt: now,
}
var yarnRatios []model.YarnRatio
for _, yr := range req.YarnRatios {
var yarnTypePtr *string
if yr.YarnType != "" {
yarnTypePtr = &yr.YarnType
}
yarnRatios = append(yarnRatios, model.YarnRatio{
ID: uuid.New().String(),
PlanID: plan.ID,
YarnName: yr.YarnName,
YarnType: yarnTypePtr,
Ratio: yr.Ratio,
AmountPerMeter: yr.AmountPerMeter,
TotalAmount: yr.TotalAmount,
CompanyID: &companyID,
CreatedAt: now,
})
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if err := s.repo.Create(ctx, tx, plan); err != nil {
return nil, err
}
if len(yarnRatios) > 0 {
if err := s.repo.CreateYarnRatios(ctx, tx, yarnRatios); err != nil {
return nil, err
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit tx: %w", err)
}
plan.YarnRatios = yarnRatios
return plan, nil
}
func (s *PlanService) GetByID(ctx context.Context, id, companyID string) (*model.ProductionPlan, error) {
plan, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if plan == nil {
return nil, fmt.Errorf("plan not found")
}
if plan.PurchaserID != companyID {
return nil, fmt.Errorf("plan not found")
}
steps, err := s.repo.GetSteps(ctx, id)
if err != nil {
return nil, err
}
plan.ProcessSteps = steps
inventory, err := s.repo.GetInventory(ctx, id)
if err != nil {
return nil, err
}
plan.InventoryRecords = inventory
return plan, nil
}
func (s *PlanService) Update(ctx context.Context, id, companyID string, req UpdatePlanRequest) error {
plan, err := s.repo.GetByID(ctx, id)
if err != nil {
return err
}
if plan == nil || plan.PurchaserID != companyID {
return fmt.Errorf("plan not found")
}
if req.ProductName != "" {
plan.ProductName = req.ProductName
}
if req.FabricCode != "" {
plan.FabricCode = req.FabricCode
}
if req.Color != "" {
plan.Color = req.Color
}
if req.ColorCode != "" {
plan.ColorCode = req.ColorCode
}
if req.TargetQuantity > 0 {
plan.TargetQuantity = req.TargetQuantity
}
if req.ProductionPrice != nil {
plan.ProductionPrice = req.ProductionPrice
}
if req.YarnUsagePerMeter != nil {
plan.YarnUsagePerMeter = req.YarnUsagePerMeter
}
if req.Status != "" {
plan.Status = req.Status
}
if req.Remark != "" {
plan.Remark = &req.Remark
}
return s.repo.Update(ctx, plan)
}
func (s *PlanService) Delete(ctx context.Context, id, companyID string) error {
return s.repo.Delete(ctx, id, companyID)
}
func (s *PlanService) AssignFactory(ctx context.Context, planID, factoryID, factoryType string) error {
return s.repo.AssignFactory(ctx, planID, factoryID, factoryType)
}
func (s *PlanService) GetSteps(ctx context.Context, planID string) ([]model.ProcessStep, error) {
return s.repo.GetSteps(ctx, planID)
}
func (s *PlanService) GetInventory(ctx context.Context, planID string) ([]model.InventoryRecord, error) {
return s.repo.GetInventory(ctx, planID)
}

View File

@ -0,0 +1,244 @@
package service
import (
"context"
"database/sql"
"fmt"
"time"
"github.com/google/uuid"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/model"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/repository"
)
type ProductService struct {
repo *repository.ProductRepo
db *sql.DB
}
func NewProductService(repo *repository.ProductRepo, db *sql.DB) *ProductService {
return &ProductService{repo: repo, db: db}
}
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"`
YarnUsagePerMeter float64 `json:"yarn_usage_per_meter"`
ProductionPrice *float64 `json:"production_price"`
YarnTypes []string `json:"yarn_types"`
YarnRatios []float64 `json:"yarn_ratios"`
}
type UpdateProductRequest struct {
ProductName string `json:"product_name"`
FabricCode string `json:"fabric_code"`
Color string `json:"color"`
ColorCode string `json:"color_code"`
Weight float64 `json:"weight"`
YarnUsagePerMeter float64 `json:"yarn_usage_per_meter"`
ProductionPrice *float64 `json:"production_price"`
YarnTypes []string `json:"yarn_types"`
YarnRatios []float64 `json:"yarn_ratios"`
}
type InboundRequest struct {
BatchNo string `json:"batch_no"`
Rolls int `json:"rolls"`
Meters float64 `json:"meters"`
Notes string `json:"notes"`
WarehouseID string `json:"warehouse_id"`
}
type OutboundRequest struct {
BatchNo string `json:"batch_no"`
Rolls int `json:"rolls"`
Meters float64 `json:"meters"`
OutboundType string `json:"outbound_type"`
Notes string `json:"notes"`
RecipientCompanyID string `json:"recipient_company_id"`
RecipientCompanyName string `json:"recipient_company_name"`
SourceBatchID string `json:"source_batch_id"`
WashingPlanID string `json:"washing_plan_id"`
}
func (s *ProductService) List(ctx context.Context, companyID string) ([]model.Product, error) {
return s.repo.List(ctx, companyID)
}
func (s *ProductService) Create(ctx context.Context, companyID string, req CreateProductRequest) (*model.Product, error) {
now := time.Now()
p := &model.Product{
ID: uuid.New().String(),
CompanyID: companyID,
ProductName: req.ProductName,
FabricCode: req.FabricCode,
Color: req.Color,
ColorCode: req.ColorCode,
Weight: req.Weight,
YarnUsagePerMeter: req.YarnUsagePerMeter,
ProductionPrice: req.ProductionPrice,
TotalStock: 0,
RawFabricMeters: 0,
RawFabricRolls: 0,
YarnTypes: req.YarnTypes,
YarnRatios: req.YarnRatios,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.repo.Create(ctx, p); err != nil {
return nil, err
}
return p, nil
}
func (s *ProductService) Update(ctx context.Context, id, companyID string, req UpdateProductRequest) error {
p := &model.Product{
ID: id,
ProductName: req.ProductName,
FabricCode: req.FabricCode,
Color: req.Color,
ColorCode: req.ColorCode,
Weight: req.Weight,
YarnUsagePerMeter: req.YarnUsagePerMeter,
ProductionPrice: req.ProductionPrice,
YarnTypes: req.YarnTypes,
YarnRatios: req.YarnRatios,
}
return s.repo.Update(ctx, p)
}
func (s *ProductService) Delete(ctx context.Context, id, companyID string) error {
return s.repo.Delete(ctx, id, companyID)
}
func (s *ProductService) Inbound(ctx context.Context, productID, companyID, userID string, req InboundRequest) error {
now := time.Now()
var notesPtr *string
if req.Notes != "" {
notesPtr = &req.Notes
}
var warehousePtr *string
if req.WarehouseID != "" {
warehousePtr = &req.WarehouseID
}
rec := &model.ProductInventoryRecord{
ID: uuid.New().String(),
ProductID: productID,
BatchNo: req.BatchNo,
Rolls: req.Rolls,
Meters: req.Meters,
Notes: notesPtr,
OperatorID: &userID,
CompanyID: &companyID,
WarehouseID: warehousePtr,
CreatedAt: now,
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if err := s.repo.Inbound(ctx, tx, rec); err != nil {
return err
}
if err := s.repo.UpdateStock(ctx, tx, productID, req.Meters, req.Rolls); err != nil {
return err
}
return tx.Commit()
}
func (s *ProductService) Outbound(ctx context.Context, productID, companyID, userID string, req OutboundRequest) error {
now := time.Now()
var notesPtr *string
if req.Notes != "" {
notesPtr = &req.Notes
}
var recipientIDPtr, recipientNamePtr, sourceBatchPtr, washingPlanPtr *string
if req.RecipientCompanyID != "" {
recipientIDPtr = &req.RecipientCompanyID
}
if req.RecipientCompanyName != "" {
recipientNamePtr = &req.RecipientCompanyName
}
if req.SourceBatchID != "" {
sourceBatchPtr = &req.SourceBatchID
}
if req.WashingPlanID != "" {
washingPlanPtr = &req.WashingPlanID
}
outboundType := req.OutboundType
if outboundType == "" {
outboundType = "normal"
}
rec := &model.ProductOutboundRecord{
ID: uuid.New().String(),
ProductID: productID,
BatchNo: req.BatchNo,
Rolls: req.Rolls,
Meters: req.Meters,
OutboundType: outboundType,
Notes: notesPtr,
OperatorID: &userID,
CompanyID: &companyID,
RecipientCompanyID: recipientIDPtr,
RecipientCompanyName: recipientNamePtr,
SourceBatchID: sourceBatchPtr,
WashingPlanID: washingPlanPtr,
CreatedAt: now,
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if err := s.repo.Outbound(ctx, tx, rec); err != nil {
return err
}
if err := s.repo.UpdateStock(ctx, tx, productID, -req.Meters, -req.Rolls); err != nil {
return err
}
return tx.Commit()
}
func (s *ProductService) Records(ctx context.Context, productID string) ([]model.ProductInventoryRecord, error) {
return s.repo.Records(ctx, productID)
}
func (s *ProductService) UpdatePrice(ctx context.Context, productID, companyID, userID string, newPrice float64, reason string) error {
oldPrice, err := s.repo.GetPrice(ctx, productID)
if err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if err := s.repo.UpdatePrice(ctx, tx, productID, newPrice); err != nil {
return err
}
if err := s.repo.InsertPriceHistory(ctx, tx, productID, oldPrice, newPrice, reason, userID); err != nil {
return err
}
return tx.Commit()
}
func (s *ProductService) PriceHistory(ctx context.Context, productID string) ([]model.ProductPriceHistory, error) {
return s.repo.PriceHistory(ctx, productID)
}

View File

@ -0,0 +1,76 @@
package service
import (
"context"
"fmt"
"time"
"github.com/google/uuid"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/model"
"github.com/muyuqingfeng/iloom/purchaser-service/internal/repository"
)
type WashingPlanService struct {
repo *repository.WashingPlanRepo
}
func NewWashingPlanService(repo *repository.WashingPlanRepo) *WashingPlanService {
return &WashingPlanService{repo: repo}
}
type CreateWashingPlanRequest struct {
ProductID string `json:"product_id" binding:"required"`
WashingFactoryID string `json:"washing_factory_id"`
PlannedMeters float64 `json:"planned_meters" binding:"required,gt=0"`
WashingPrice float64 `json:"washing_price" binding:"required,gt=0"`
EstimatedShrinkageRate float64 `json:"estimated_shrinkage_rate"`
Notes string `json:"notes"`
}
func (s *WashingPlanService) Create(ctx context.Context, companyID, userID string, req CreateWashingPlanRequest) (*model.WashingPlan, error) {
planCode, err := s.repo.GeneratePlanCode(ctx)
if err != nil {
return nil, err
}
now := time.Now()
estimatedWashedMeters := req.PlannedMeters * (1 - req.EstimatedShrinkageRate/100)
estimatedTotalCost := req.PlannedMeters * req.WashingPrice
var washingFactoryPtr *string
if req.WashingFactoryID != "" {
washingFactoryPtr = &req.WashingFactoryID
}
var notesPtr *string
if req.Notes != "" {
notesPtr = &req.Notes
}
wp := &model.WashingPlan{
ID: uuid.New().String(),
PlanCode: planCode,
CompanyID: companyID,
ProductID: req.ProductID,
WashingFactoryID: washingFactoryPtr,
PlannedMeters: req.PlannedMeters,
WashingPrice: req.WashingPrice,
EstimatedShrinkageRate: req.EstimatedShrinkageRate,
EstimatedWashedMeters: &estimatedWashedMeters,
EstimatedTotalCost: &estimatedTotalCost,
Status: "pending",
Notes: notesPtr,
CreatedBy: &userID,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.repo.Create(ctx, wp); err != nil {
return nil, fmt.Errorf("create washing plan: %w", err)
}
return wp, nil
}
func (s *WashingPlanService) List(ctx context.Context, companyID string) ([]model.WashingPlan, error) {
return s.repo.List(ctx, companyID)
}

463
scripts/init-iloom.sql Normal file
View File

@ -0,0 +1,463 @@
-- iloom Business Tables for muyu_wms Database
-- All tables use ilm_ prefix to coexist with muyu-apiserver tables
-- This script should be run AFTER muyu's init.sql
USE `muyu_wms`;
-- ==================== iloom Roles & Permissions ====================
INSERT IGNORE INTO `sys_role` (`role_id`, `role_name`, `role_key`, `role_desc`, `sort_order`, `status`) VALUES
('r_010', '采购商', 'purchaser', 'iloom purchaser role', 10, 1),
('r_011', '纺织厂', 'textile', 'iloom textile factory role', 11, 1),
('r_012', '水洗厂', 'washing', 'iloom washing factory role', 12, 1);
INSERT IGNORE INTO `casbin_rule` (`ptype`, `v0`, `v1`, `v2`) VALUES
('p', 'purchaser', '/api/v1/purchaser/*', '*'),
('p', 'purchaser', '/api/v1/common/*', '*'),
('p', 'purchaser', '/api/v1/auth/*', '*'),
('p', 'textile', '/api/v1/textile/*', '*'),
('p', 'textile', '/api/v1/common/*', '*'),
('p', 'textile', '/api/v1/auth/*', '*'),
('p', 'washing', '/api/v1/washing/*', '*'),
('p', 'washing', '/api/v1/common/*', '*'),
('p', 'washing', '/api/v1/auth/*', '*');
-- ==================== Company & Multi-tenancy ====================
CREATE TABLE IF NOT EXISTS `ilm_company` (
`id` VARCHAR(36) NOT NULL,
`name` VARCHAR(100) NOT NULL,
`type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT 'purchaser|textile|washing',
`address` VARCHAR(255) NOT NULL DEFAULT '',
`phone` VARCHAR(20) NOT NULL DEFAULT '',
`contact_person` VARCHAR(50) NOT NULL DEFAULT '',
`status` TINYINT NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_type` (`type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `ilm_company_member` (
`id` VARCHAR(36) NOT NULL,
`company_id` VARCHAR(36) NOT NULL,
`user_id` VARCHAR(32) NOT NULL,
`role` VARCHAR(20) NOT NULL DEFAULT 'member' COMMENT 'owner|admin|member',
`is_master` TINYINT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_company_user` (`company_id`, `user_id`),
KEY `idx_user_id` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `ilm_company_relationship` (
`id` VARCHAR(36) NOT NULL,
`from_company_id` VARCHAR(36) NOT NULL,
`to_company_id` VARCHAR(36) NOT NULL,
`relationship_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT 'supplier|customer|partner',
`status` VARCHAR(20) NOT NULL DEFAULT 'active',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_relationship` (`from_company_id`, `to_company_id`, `relationship_type`),
KEY `idx_to_company` (`to_company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==================== Product Extension ====================
CREATE TABLE IF NOT EXISTS `ilm_product_ext` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`product_id` VARCHAR(32) NOT NULL,
`company_id` VARCHAR(36) NOT NULL,
`fabric_code` VARCHAR(50) NOT NULL DEFAULT '',
`color_code` VARCHAR(50) NOT NULL DEFAULT '',
`yarn_usage_per_meter` DECIMAL(10,4) DEFAULT NULL,
`current_price` DECIMAL(10,2) DEFAULT NULL,
`warp_weight` DECIMAL(10,2) DEFAULT NULL,
`weft_weight` DECIMAL(10,2) DEFAULT NULL,
`total_weight` DECIMAL(10,2) DEFAULT NULL,
`stock_meters` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`stock_rolls` INT NOT NULL DEFAULT 0,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_product_company` (`product_id`, `company_id`),
KEY `idx_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==================== Production Plans ====================
CREATE TABLE IF NOT EXISTS `ilm_production_plan` (
`id` VARCHAR(36) NOT NULL,
`plan_code` VARCHAR(50) NOT NULL,
`purchaser_id` VARCHAR(36) NOT NULL COMMENT 'company_id of purchaser',
`product_name` VARCHAR(100) NOT NULL DEFAULT '',
`fabric_code` VARCHAR(50) NOT NULL DEFAULT '',
`color` VARCHAR(50) NOT NULL DEFAULT '',
`color_code` VARCHAR(50) NOT NULL DEFAULT '',
`target_quantity` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`completed_quantity` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`production_price` DECIMAL(10,2) DEFAULT NULL,
`yarn_usage_per_meter` DECIMAL(10,4) DEFAULT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'draft' COMMENT 'draft|producing|completed|cancelled',
`remark` TEXT,
`start_time` DATETIME DEFAULT NULL,
`created_by` VARCHAR(32) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_plan_code` (`plan_code`),
KEY `idx_purchaser` (`purchaser_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `ilm_plan_factory` (
`id` VARCHAR(36) NOT NULL,
`plan_id` VARCHAR(36) NOT NULL,
`factory_id` VARCHAR(36) NOT NULL,
`factory_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT 'textile|washing',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_plan` (`plan_id`),
KEY `idx_factory` (`factory_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `ilm_yarn_ratio` (
`id` VARCHAR(36) NOT NULL,
`plan_id` VARCHAR(36) NOT NULL,
`yarn_name` VARCHAR(100) NOT NULL DEFAULT '',
`yarn_type` VARCHAR(50) NOT NULL DEFAULT '',
`ratio` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
`amount_per_meter` DECIMAL(10,4) DEFAULT NULL,
`total_amount` DECIMAL(12,2) DEFAULT NULL,
`company_id` VARCHAR(36) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_plan` (`plan_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==================== Production Process ====================
CREATE TABLE IF NOT EXISTS `ilm_process_step` (
`id` VARCHAR(36) NOT NULL,
`plan_id` VARCHAR(36) NOT NULL,
`step_type` VARCHAR(30) NOT NULL DEFAULT '' COMMENT 'warping|sizing|weaving|inspection|finishing',
`status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending|in_progress|completed|rejected',
`notes` TEXT,
`operator_id` VARCHAR(32) NOT NULL DEFAULT '',
`company_id` VARCHAR(36) NOT NULL DEFAULT '',
`timestamp` DATETIME DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_plan` (`plan_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==================== Warehousing ====================
CREATE TABLE IF NOT EXISTS `ilm_warehouse` (
`id` VARCHAR(36) NOT NULL,
`name` VARCHAR(100) NOT NULL,
`location` VARCHAR(255) NOT NULL DEFAULT '',
`company_id` VARCHAR(36) NOT NULL,
`capacity` DECIMAL(12,2) DEFAULT NULL,
`status` TINYINT NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `ilm_inventory_record` (
`id` VARCHAR(36) NOT NULL,
`plan_id` VARCHAR(36) NOT NULL DEFAULT '',
`warehouse_id` VARCHAR(36) NOT NULL DEFAULT '',
`quantity` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`rolls` INT DEFAULT NULL,
`price_per_meter` DECIMAL(10,2) DEFAULT NULL,
`price_note` TEXT,
`warehouse_location` VARCHAR(100) NOT NULL DEFAULT '',
`operator_id` VARCHAR(32) NOT NULL DEFAULT '',
`company_id` VARCHAR(36) NOT NULL DEFAULT '',
`record_type` VARCHAR(20) NOT NULL DEFAULT 'inbound' COMMENT 'inbound|outbound',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_plan` (`plan_id`),
KEY `idx_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==================== Product Inventory (Purchaser) ====================
CREATE TABLE IF NOT EXISTS `ilm_product_inventory` (
`id` VARCHAR(36) NOT NULL,
`product_id` VARCHAR(32) NOT NULL DEFAULT '',
`batch_no` VARCHAR(50) NOT NULL DEFAULT '',
`rolls` INT NOT NULL DEFAULT 0,
`meters` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`record_type` VARCHAR(20) NOT NULL DEFAULT 'inbound' COMMENT 'inbound|outbound',
`notes` TEXT,
`warehouse_id` VARCHAR(36) NOT NULL DEFAULT '',
`plan_code` VARCHAR(50) NOT NULL DEFAULT '',
`factory_id` VARCHAR(36) NOT NULL DEFAULT '',
`source` VARCHAR(20) NOT NULL DEFAULT '' COMMENT 'self|textile',
`price_per_meter` DECIMAL(10,2) DEFAULT NULL,
`operator_id` VARCHAR(32) NOT NULL DEFAULT '',
`company_id` VARCHAR(36) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_product` (`product_id`),
KEY `idx_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `ilm_product_outbound` (
`id` VARCHAR(36) NOT NULL,
`product_id` VARCHAR(32) NOT NULL DEFAULT '',
`batch_no` VARCHAR(50) NOT NULL DEFAULT '',
`rolls` INT NOT NULL DEFAULT 0,
`meters` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`outbound_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT 'sale|washing|transfer',
`notes` TEXT,
`washing_plan_id` VARCHAR(36) NOT NULL DEFAULT '',
`recipient_company_id` VARCHAR(36) NOT NULL DEFAULT '',
`operator_id` VARCHAR(32) NOT NULL DEFAULT '',
`company_id` VARCHAR(36) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_product` (`product_id`),
KEY `idx_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==================== Price History ====================
CREATE TABLE IF NOT EXISTS `ilm_product_price_history` (
`id` VARCHAR(36) NOT NULL,
`product_id` VARCHAR(32) NOT NULL DEFAULT '',
`price` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`reason` TEXT,
`changed_by` VARCHAR(32) NOT NULL DEFAULT '',
`company_id` VARCHAR(36) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_product` (`product_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `ilm_production_price_history` (
`id` VARCHAR(36) NOT NULL,
`plan_id` VARCHAR(36) NOT NULL DEFAULT '',
`price` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`reason` TEXT,
`changed_by` VARCHAR(32) NOT NULL DEFAULT '',
`company_id` VARCHAR(36) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_plan` (`plan_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==================== Accounts Payable ====================
CREATE TABLE IF NOT EXISTS `ilm_accounts_payable` (
`id` VARCHAR(36) NOT NULL,
`purchaser_id` VARCHAR(36) NOT NULL,
`factory_id` VARCHAR(36) NOT NULL,
`factory_type` VARCHAR(20) NOT NULL DEFAULT '',
`total_amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`paid_amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending|partial|paid',
`notes` TEXT,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_purchaser` (`purchaser_id`),
KEY `idx_factory` (`factory_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `ilm_ap_item` (
`id` VARCHAR(36) NOT NULL,
`payable_id` VARCHAR(36) NOT NULL,
`plan_id` VARCHAR(36) NOT NULL DEFAULT '',
`amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`description` TEXT,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_payable` (`payable_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==================== Washing Plans ====================
CREATE TABLE IF NOT EXISTS `ilm_washing_plan` (
`id` VARCHAR(36) NOT NULL,
`plan_code` VARCHAR(50) NOT NULL DEFAULT '',
`purchaser_id` VARCHAR(36) NOT NULL,
`washing_factory_id` VARCHAR(36) NOT NULL DEFAULT '',
`product_name` VARCHAR(100) NOT NULL DEFAULT '',
`fabric_code` VARCHAR(50) NOT NULL DEFAULT '',
`color` VARCHAR(50) NOT NULL DEFAULT '',
`quantity` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`unit_price` DECIMAL(10,2) DEFAULT NULL,
`status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending|accepted|processing|completed|rejected',
`notes` TEXT,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_purchaser` (`purchaser_id`),
KEY `idx_washing_factory` (`washing_factory_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==================== Washing Process ====================
CREATE TABLE IF NOT EXISTS `ilm_washing_step` (
`id` VARCHAR(36) NOT NULL,
`washing_plan_id` VARCHAR(36) NOT NULL,
`step_type` VARCHAR(30) NOT NULL DEFAULT '',
`status` VARCHAR(20) NOT NULL DEFAULT 'pending',
`notes` TEXT,
`operator_id` VARCHAR(32) NOT NULL DEFAULT '',
`company_id` VARCHAR(36) NOT NULL DEFAULT '',
`timestamp` DATETIME DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_washing_plan` (`washing_plan_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `ilm_washing_completion` (
`id` VARCHAR(36) NOT NULL,
`washing_plan_id` VARCHAR(36) NOT NULL,
`completed_quantity` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`quality_notes` TEXT,
`operator_id` VARCHAR(32) NOT NULL DEFAULT '',
`company_id` VARCHAR(36) NOT NULL DEFAULT '',
`completed_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_washing_plan` (`washing_plan_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==================== Finished Products ====================
CREATE TABLE IF NOT EXISTS `ilm_finished_product` (
`id` VARCHAR(36) NOT NULL,
`product_name` VARCHAR(100) NOT NULL DEFAULT '',
`fabric_code` VARCHAR(50) NOT NULL DEFAULT '',
`color` VARCHAR(50) NOT NULL DEFAULT '',
`stock_meters` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`stock_rolls` INT NOT NULL DEFAULT 0,
`company_id` VARCHAR(36) NOT NULL,
`washing_plan_id` VARCHAR(36) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `ilm_finished_product_inventory` (
`id` VARCHAR(36) NOT NULL,
`finished_product_id` VARCHAR(36) NOT NULL,
`batch_no` VARCHAR(50) NOT NULL DEFAULT '',
`rolls` INT NOT NULL DEFAULT 0,
`meters` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`record_type` VARCHAR(20) NOT NULL DEFAULT 'inbound',
`notes` TEXT,
`operator_id` VARCHAR(32) NOT NULL DEFAULT '',
`company_id` VARCHAR(36) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_finished_product` (`finished_product_id`),
KEY `idx_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==================== Yarn Stock ====================
CREATE TABLE IF NOT EXISTS `ilm_yarn_stock` (
`id` VARCHAR(36) NOT NULL,
`yarn_name` VARCHAR(100) NOT NULL DEFAULT '',
`yarn_type` VARCHAR(50) NOT NULL DEFAULT '',
`current_stock` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`unit` VARCHAR(20) NOT NULL DEFAULT 'kg',
`company_id` VARCHAR(36) NOT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `ilm_yarn_stock_record` (
`id` VARCHAR(36) NOT NULL,
`yarn_stock_id` VARCHAR(36) NOT NULL,
`quantity` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`record_type` VARCHAR(20) NOT NULL DEFAULT 'in' COMMENT 'in|out',
`notes` TEXT,
`operator_id` VARCHAR(32) NOT NULL DEFAULT '',
`company_id` VARCHAR(36) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_yarn_stock` (`yarn_stock_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==================== Payments ====================
CREATE TABLE IF NOT EXISTS `ilm_payment` (
`id` VARCHAR(36) NOT NULL,
`payer_company_id` VARCHAR(36) NOT NULL,
`payee_company_id` VARCHAR(36) NOT NULL,
`amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`payment_type` VARCHAR(20) NOT NULL DEFAULT '' COMMENT 'production|washing',
`plan_id` VARCHAR(36) NOT NULL DEFAULT '',
`status` VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT 'pending|confirmed|rejected',
`notes` TEXT,
`confirmed_at` DATETIME DEFAULT NULL,
`confirmed_by` VARCHAR(32) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_payer` (`payer_company_id`),
KEY `idx_payee` (`payee_company_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ==================== Notifications & Misc ====================
CREATE TABLE IF NOT EXISTS `ilm_notification` (
`id` VARCHAR(36) NOT NULL,
`user_id` VARCHAR(32) NOT NULL,
`company_id` VARCHAR(36) NOT NULL DEFAULT '',
`title` VARCHAR(200) NOT NULL DEFAULT '',
`message` TEXT,
`type` VARCHAR(30) NOT NULL DEFAULT 'info' COMMENT 'info|warning|success|error',
`is_read` TINYINT NOT NULL DEFAULT 0,
`link` VARCHAR(255) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_unread` (`user_id`, `is_read`),
KEY `idx_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `ilm_share_link` (
`id` VARCHAR(36) NOT NULL,
`code` VARCHAR(20) NOT NULL,
`company_id` VARCHAR(36) NOT NULL,
`created_by` VARCHAR(32) NOT NULL DEFAULT '',
`resource_type` VARCHAR(30) NOT NULL DEFAULT '',
`resource_id` VARCHAR(36) NOT NULL DEFAULT '',
`expires_at` DATETIME DEFAULT NULL,
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`),
KEY `idx_company` (`company_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `ilm_audit_log` (
`id` VARCHAR(36) NOT NULL,
`user_id` VARCHAR(32) NOT NULL DEFAULT '',
`company_id` VARCHAR(36) NOT NULL DEFAULT '',
`action` VARCHAR(50) NOT NULL DEFAULT '',
`resource_type` VARCHAR(50) NOT NULL DEFAULT '',
`resource_id` VARCHAR(36) NOT NULL DEFAULT '',
`old_value` JSON DEFAULT NULL,
`new_value` JSON DEFAULT NULL,
`ip` VARCHAR(50) NOT NULL DEFAULT '',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user` (`user_id`),
KEY `idx_company` (`company_id`),
KEY `idx_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

639
scripts/init-schemas.sql Normal file
View File

@ -0,0 +1,639 @@
-- iloom 微服务数据库初始化脚本
-- 创建 5 个 Schema迁移表结构建立跨 Schema 视图
-- =============================================
-- 1. 创建 Schema
-- =============================================
CREATE SCHEMA IF NOT EXISTS common;
CREATE SCHEMA IF NOT EXISTS purchaser;
CREATE SCHEMA IF NOT EXISTS textile;
CREATE SCHEMA IF NOT EXISTS washing;
CREATE SCHEMA IF NOT EXISTS audit;
-- =============================================
-- 2. 创建枚举类型(在 public schema所有 schema 共享)
-- =============================================
DO $$ BEGIN
CREATE TYPE app_role AS ENUM ('purchaser', 'textile', 'washing');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE factory_type AS ENUM ('textile', 'washing');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE plan_status AS ENUM ('pending', 'producing', 'completed');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE step_status AS ENUM ('pending', 'active', 'completed', 'rejected');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE step_type AS ENUM ('confirm', 'yarn_purchase', 'dyeing', 'machine_start', 'fabric_warehouse');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE warehouse_type AS ENUM ('raw_fabric', 'fabric', 'finished', 'yarn');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
DO $$ BEGIN
CREATE TYPE payment_status AS ENUM ('pending', 'completed');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- =============================================
-- 3. common Schema 表
-- =============================================
CREATE TABLE IF NOT EXISTS common.companies (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
role app_role NOT NULL,
address TEXT,
contact_phone TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS common.profiles (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
phone TEXT,
company_id UUID REFERENCES common.companies(id),
is_master BOOLEAN NOT NULL DEFAULT true,
master_id UUID REFERENCES common.profiles(id),
display_name TEXT,
image_url TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS common.company_members (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES common.companies(id),
user_id UUID NOT NULL REFERENCES common.profiles(id),
role TEXT NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS common.company_relationships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
purchaser_company_id UUID NOT NULL REFERENCES common.companies(id),
factory_company_id UUID NOT NULL REFERENCES common.companies(id),
factory_type factory_type NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS common.notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
recipient_id UUID NOT NULL,
sender_id UUID,
sender_company_id UUID REFERENCES common.companies(id),
title TEXT NOT NULL,
content TEXT NOT NULL,
type TEXT NOT NULL,
plan_id UUID,
is_read BOOLEAN DEFAULT false,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE IF NOT EXISTS common.share_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL,
created_by UUID NOT NULL,
short_code TEXT NOT NULL UNIQUE,
factory_type TEXT NOT NULL,
hide_sensitive BOOLEAN DEFAULT false,
status TEXT NOT NULL DEFAULT 'pending',
expires_at TIMESTAMPTZ,
clicked_at TIMESTAMPTZ,
click_count INT DEFAULT 0,
used_at TIMESTAMPTZ,
response_result TEXT,
reject_reason TEXT,
responded_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS common.user_feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
type TEXT NOT NULL,
content TEXT NOT NULL,
contact TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS common.crash_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
error_message TEXT NOT NULL,
error_stack TEXT,
component_stack TEXT,
url TEXT,
user_agent TEXT,
user_description TEXT,
status TEXT NOT NULL DEFAULT 'new',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================
-- 4. purchaser Schema 表
-- =============================================
CREATE TABLE IF NOT EXISTS purchaser.production_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_code TEXT NOT NULL,
purchaser_id UUID NOT NULL REFERENCES common.companies(id),
product_name TEXT NOT NULL,
fabric_code TEXT NOT NULL,
color TEXT NOT NULL,
color_code TEXT NOT NULL DEFAULT '',
target_quantity NUMERIC NOT NULL DEFAULT 0,
completed_quantity NUMERIC NOT NULL DEFAULT 0,
production_price NUMERIC,
yarn_usage_per_meter NUMERIC,
status plan_status NOT NULL DEFAULT 'pending',
remark TEXT,
start_time TIMESTAMPTZ,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS purchaser.plan_factories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES purchaser.production_plans(id) ON DELETE CASCADE,
factory_id UUID NOT NULL REFERENCES common.companies(id),
factory_type factory_type NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS purchaser.yarn_ratios (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES purchaser.production_plans(id) ON DELETE CASCADE,
yarn_name TEXT NOT NULL,
yarn_type TEXT,
ratio NUMERIC NOT NULL DEFAULT 0,
amount_per_meter NUMERIC NOT NULL DEFAULT 0,
total_amount NUMERIC NOT NULL DEFAULT 0,
company_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS purchaser.products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES common.companies(id),
product_name TEXT NOT NULL,
fabric_code TEXT NOT NULL,
color TEXT NOT NULL,
color_code TEXT NOT NULL DEFAULT '',
weight NUMERIC NOT NULL DEFAULT 0,
warp_weight NUMERIC,
weft_weight NUMERIC,
total_weight NUMERIC,
yarn_usage_per_meter NUMERIC NOT NULL DEFAULT 0,
production_price NUMERIC,
total_stock NUMERIC NOT NULL DEFAULT 0,
raw_fabric_meters NUMERIC NOT NULL DEFAULT 0,
raw_fabric_rolls INT NOT NULL DEFAULT 0,
image_url TEXT,
yarn_types TEXT[],
yarn_ratios NUMERIC[],
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS purchaser.product_inventory_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES purchaser.products(id) ON DELETE CASCADE,
batch_no TEXT NOT NULL,
rolls INT NOT NULL DEFAULT 0,
meters NUMERIC NOT NULL DEFAULT 0,
notes TEXT,
operator_id UUID,
company_id UUID,
warehouse_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS purchaser.product_outbound_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES purchaser.products(id) ON DELETE CASCADE,
batch_no TEXT NOT NULL DEFAULT '',
rolls INT NOT NULL DEFAULT 0,
meters NUMERIC NOT NULL DEFAULT 0,
outbound_type TEXT NOT NULL DEFAULT 'normal',
notes TEXT,
operator_id UUID,
company_id UUID,
recipient_company_id UUID,
recipient_company_name TEXT,
source_batch_id UUID,
washing_plan_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS purchaser.product_yarn_ratios (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES purchaser.products(id) ON DELETE CASCADE,
yarn_name TEXT NOT NULL,
yarn_type TEXT,
ratio NUMERIC NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS purchaser.product_price_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES purchaser.products(id) ON DELETE CASCADE,
company_id UUID NOT NULL,
old_price NUMERIC NOT NULL DEFAULT 0,
new_price NUMERIC NOT NULL DEFAULT 0,
change_amount NUMERIC NOT NULL DEFAULT 0,
change_reason TEXT,
operator_id UUID,
operator_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS purchaser.production_price_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES purchaser.production_plans(id) ON DELETE CASCADE,
old_price NUMERIC NOT NULL DEFAULT 0,
new_price NUMERIC NOT NULL DEFAULT 0,
change_amount NUMERIC NOT NULL DEFAULT 0,
change_reason TEXT,
operator_id UUID,
operator_name TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS purchaser.accounts_payable (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES purchaser.production_plans(id),
purchaser_id UUID NOT NULL REFERENCES common.companies(id),
textile_factory_id UUID NOT NULL REFERENCES common.companies(id),
price_per_meter NUMERIC NOT NULL DEFAULT 0,
total_quantity NUMERIC NOT NULL DEFAULT 0,
total_amount NUMERIC NOT NULL DEFAULT 0,
paid_quantity NUMERIC NOT NULL DEFAULT 0,
paid_amount NUMERIC NOT NULL DEFAULT 0,
unpaid_quantity NUMERIC NOT NULL DEFAULT 0,
unpaid_amount NUMERIC NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS purchaser.accounts_payable_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
accounts_payable_id UUID NOT NULL REFERENCES purchaser.accounts_payable(id) ON DELETE CASCADE,
inventory_record_id UUID,
payment_id UUID,
quantity NUMERIC NOT NULL DEFAULT 0,
price_per_meter NUMERIC NOT NULL DEFAULT 0,
amount NUMERIC NOT NULL DEFAULT 0,
rolls INT,
status TEXT NOT NULL DEFAULT 'pending',
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS purchaser.washing_plans (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_code TEXT NOT NULL,
company_id UUID NOT NULL REFERENCES common.companies(id),
product_id UUID NOT NULL REFERENCES purchaser.products(id),
washing_factory_id UUID REFERENCES common.companies(id),
planned_meters NUMERIC NOT NULL DEFAULT 0,
washing_price NUMERIC NOT NULL DEFAULT 0,
estimated_shrinkage_rate NUMERIC NOT NULL DEFAULT 0,
estimated_washed_meters NUMERIC,
estimated_total_cost NUMERIC,
actual_shrinkage_rate NUMERIC,
actual_washed_meters NUMERIC,
actual_total_cost NUMERIC,
washing_date TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'pending',
notes TEXT,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================
-- 5. textile Schema 表
-- =============================================
CREATE TABLE IF NOT EXISTS textile.plan_process_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES purchaser.production_plans(id) ON DELETE CASCADE,
step_type step_type NOT NULL,
status step_status NOT NULL DEFAULT 'pending',
notes TEXT,
operator_id UUID,
company_id UUID,
timestamp TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS textile.warehouses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES common.companies(id),
name TEXT NOT NULL,
type warehouse_type NOT NULL,
location TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS textile.inventory_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES purchaser.production_plans(id),
warehouse_id UUID NOT NULL REFERENCES textile.warehouses(id),
quantity NUMERIC NOT NULL DEFAULT 0,
rolls INT,
price_per_meter NUMERIC,
price_note TEXT,
warehouse_location TEXT,
operator_id UUID,
company_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS textile.yarn_stock (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES common.companies(id),
name TEXT NOT NULL,
spec TEXT,
quantity NUMERIC NOT NULL DEFAULT 0,
unit TEXT NOT NULL DEFAULT 'kg',
min_stock NUMERIC NOT NULL DEFAULT 0,
warehouse_id UUID REFERENCES textile.warehouses(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS textile.yarn_stock_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
yarn_stock_id UUID REFERENCES textile.yarn_stock(id),
company_id UUID NOT NULL REFERENCES common.companies(id),
record_type TEXT NOT NULL,
quantity NUMERIC NOT NULL DEFAULT 0,
unit TEXT NOT NULL DEFAULT 'kg',
batch_no TEXT,
plan_id UUID,
notes TEXT,
operator_id UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS textile.payments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
plan_id UUID NOT NULL REFERENCES purchaser.production_plans(id),
from_company_id UUID NOT NULL REFERENCES common.companies(id),
to_company_id UUID NOT NULL REFERENCES common.companies(id),
amount NUMERIC NOT NULL DEFAULT 0,
quantity NUMERIC NOT NULL DEFAULT 0,
price_per_meter NUMERIC NOT NULL DEFAULT 0,
status payment_status NOT NULL DEFAULT 'pending',
paid_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================
-- 6. washing Schema 表
-- =============================================
CREATE TABLE IF NOT EXISTS washing.washing_process_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
washing_plan_id UUID NOT NULL REFERENCES purchaser.washing_plans(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES common.companies(id),
step_type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
notes TEXT,
operator_id UUID,
timestamp TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS washing.washing_plan_completions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
washing_plan_id UUID NOT NULL REFERENCES purchaser.washing_plans(id),
company_id UUID NOT NULL REFERENCES common.companies(id),
actual_shrinkage_rate NUMERIC NOT NULL,
actual_washed_meters NUMERIC NOT NULL,
actual_washing_cost NUMERIC,
unit_cost NUMERIC,
rolls INT NOT NULL DEFAULT 0,
washing_date DATE NOT NULL,
notes TEXT,
completed_by UUID,
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_product_id UUID,
auto_imported BOOLEAN DEFAULT false,
warehouse_id UUID,
warehouse_location TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS washing.finished_products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
company_id UUID NOT NULL REFERENCES common.companies(id),
product_name TEXT NOT NULL,
fabric_code TEXT NOT NULL,
color TEXT NOT NULL,
color_code TEXT,
weight NUMERIC,
shrinkage_rate NUMERIC NOT NULL DEFAULT 0,
washed_meters NUMERIC NOT NULL DEFAULT 0,
stock_meters NUMERIC NOT NULL DEFAULT 0,
stock_rolls INT NOT NULL DEFAULT 0,
rolls INT,
washing_cost NUMERIC,
unit_cost NUMERIC,
status TEXT NOT NULL DEFAULT 'active',
notes TEXT,
product_id UUID,
washing_plan_id UUID,
warehouse_id UUID,
warehouse_location TEXT,
created_by UUID,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS washing.finished_product_inventory_records (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
finished_product_id UUID NOT NULL REFERENCES washing.finished_products(id) ON DELETE CASCADE,
company_id UUID NOT NULL REFERENCES common.companies(id),
record_type TEXT NOT NULL,
rolls INT NOT NULL DEFAULT 0,
meters NUMERIC NOT NULL DEFAULT 0,
batch_no TEXT,
notes TEXT,
operator_id UUID,
warehouse_id UUID,
warehouse_location TEXT,
washing_completion_id UUID,
related_order_id UUID,
related_order_type TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================
-- 7. audit Schema 表
-- =============================================
CREATE TABLE IF NOT EXISTS audit.audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID,
company_id UUID,
action TEXT NOT NULL,
resource_type TEXT NOT NULL,
resource_id UUID,
details JSONB,
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS audit.data_audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
table_name TEXT NOT NULL,
record_id TEXT NOT NULL,
operation TEXT NOT NULL,
old_values JSONB,
new_values JSONB,
changed_by UUID,
error_message TEXT,
validation_passed BOOLEAN NOT NULL DEFAULT true,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- =============================================
-- 8. 跨 Schema 只读视图
-- =============================================
-- textile-service 需要读取分配给自己的计划
CREATE OR REPLACE VIEW textile.v_assigned_plans AS
SELECT
pp.id, pp.plan_code, pp.product_name, pp.fabric_code,
pp.color, pp.color_code, pp.target_quantity, pp.completed_quantity,
pp.production_price, pp.yarn_usage_per_meter, pp.status,
pp.remark, pp.start_time, pp.created_at, pp.updated_at,
pf.factory_id, pf.id AS plan_factory_id,
c.name AS purchaser_name
FROM purchaser.production_plans pp
JOIN purchaser.plan_factories pf ON pp.id = pf.plan_id
JOIN common.companies c ON pp.purchaser_id = c.id
WHERE pf.factory_type = 'textile';
-- washing-service 需要读取分配给自己的水洗计划
CREATE OR REPLACE VIEW washing.v_assigned_washing_plans AS
SELECT
wp.id, wp.plan_code, wp.company_id, wp.product_id,
wp.washing_factory_id, wp.planned_meters, wp.washing_price,
wp.estimated_shrinkage_rate, wp.estimated_washed_meters,
wp.estimated_total_cost, wp.actual_shrinkage_rate,
wp.actual_washed_meters, wp.actual_total_cost,
wp.washing_date, wp.status, wp.notes, wp.created_at, wp.updated_at,
p.product_name, p.fabric_code, p.color, p.color_code,
c.name AS purchaser_name
FROM purchaser.washing_plans wp
LEFT JOIN purchaser.products p ON wp.product_id = p.id
LEFT JOIN common.companies c ON wp.company_id = c.id;
-- purchaser-service 需要读取计划的流程步骤
CREATE OR REPLACE VIEW purchaser.v_plan_steps AS
SELECT
ps.id, ps.plan_id, ps.step_type, ps.status,
ps.notes, ps.operator_id, ps.company_id, ps.timestamp, ps.created_at,
pr.display_name AS operator_name
FROM textile.plan_process_steps ps
LEFT JOIN common.profiles pr ON ps.operator_id = pr.id;
-- purchaser-service 需要读取入库记录
CREATE OR REPLACE VIEW purchaser.v_plan_inventory AS
SELECT
ir.id, ir.plan_id, ir.warehouse_id, ir.quantity, ir.rolls,
ir.price_per_meter, ir.price_note, ir.warehouse_location,
ir.operator_id, ir.company_id, ir.created_at,
w.name AS warehouse_name,
pr.display_name AS operator_name
FROM textile.inventory_records ir
LEFT JOIN textile.warehouses w ON ir.warehouse_id = w.id
LEFT JOIN common.profiles pr ON ir.operator_id = pr.id;
-- =============================================
-- 9. 索引
-- =============================================
CREATE INDEX IF NOT EXISTS idx_profiles_username ON common.profiles(username);
CREATE INDEX IF NOT EXISTS idx_profiles_company ON common.profiles(company_id);
CREATE INDEX IF NOT EXISTS idx_company_members_company ON common.company_members(company_id);
CREATE INDEX IF NOT EXISTS idx_company_relationships_purchaser ON common.company_relationships(purchaser_company_id);
CREATE INDEX IF NOT EXISTS idx_company_relationships_factory ON common.company_relationships(factory_company_id);
CREATE INDEX IF NOT EXISTS idx_notifications_recipient ON common.notifications(recipient_id);
CREATE INDEX IF NOT EXISTS idx_share_links_code ON common.share_links(short_code);
CREATE INDEX IF NOT EXISTS idx_plans_purchaser ON purchaser.production_plans(purchaser_id);
CREATE INDEX IF NOT EXISTS idx_plans_status ON purchaser.production_plans(status);
CREATE INDEX IF NOT EXISTS idx_plan_factories_plan ON purchaser.plan_factories(plan_id);
CREATE INDEX IF NOT EXISTS idx_plan_factories_factory ON purchaser.plan_factories(factory_id);
CREATE INDEX IF NOT EXISTS idx_yarn_ratios_plan ON purchaser.yarn_ratios(plan_id);
CREATE INDEX IF NOT EXISTS idx_products_company ON purchaser.products(company_id);
CREATE INDEX IF NOT EXISTS idx_product_inventory_product ON purchaser.product_inventory_records(product_id);
CREATE INDEX IF NOT EXISTS idx_ap_purchaser ON purchaser.accounts_payable(purchaser_id);
CREATE INDEX IF NOT EXISTS idx_washing_plans_company ON purchaser.washing_plans(company_id);
CREATE INDEX IF NOT EXISTS idx_washing_plans_factory ON purchaser.washing_plans(washing_factory_id);
CREATE INDEX IF NOT EXISTS idx_process_steps_plan ON textile.plan_process_steps(plan_id);
CREATE INDEX IF NOT EXISTS idx_inventory_plan ON textile.inventory_records(plan_id);
CREATE INDEX IF NOT EXISTS idx_yarn_stock_company ON textile.yarn_stock(company_id);
CREATE INDEX IF NOT EXISTS idx_yarn_records_stock ON textile.yarn_stock_records(yarn_stock_id);
CREATE INDEX IF NOT EXISTS idx_payments_to ON textile.payments(to_company_id);
CREATE INDEX IF NOT EXISTS idx_washing_steps_plan ON washing.washing_process_steps(washing_plan_id);
CREATE INDEX IF NOT EXISTS idx_completions_plan ON washing.washing_plan_completions(washing_plan_id);
CREATE INDEX IF NOT EXISTS idx_finished_products_company ON washing.finished_products(company_id);
CREATE INDEX IF NOT EXISTS idx_fp_inventory_product ON washing.finished_product_inventory_records(finished_product_id);
-- =============================================
-- 10. 权限授予
-- =============================================
GRANT USAGE ON SCHEMA common TO iloom;
GRANT USAGE ON SCHEMA purchaser TO iloom;
GRANT USAGE ON SCHEMA textile TO iloom;
GRANT USAGE ON SCHEMA washing TO iloom;
GRANT USAGE ON SCHEMA audit TO iloom;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA common TO iloom;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA purchaser TO iloom;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA textile TO iloom;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA washing TO iloom;
GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA audit TO iloom;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA common TO iloom;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA purchaser TO iloom;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA textile TO iloom;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA washing TO iloom;
GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA audit TO iloom;
ALTER DEFAULT PRIVILEGES IN SCHEMA common GRANT ALL ON TABLES TO iloom;
ALTER DEFAULT PRIVILEGES IN SCHEMA purchaser GRANT ALL ON TABLES TO iloom;
ALTER DEFAULT PRIVILEGES IN SCHEMA textile GRANT ALL ON TABLES TO iloom;
ALTER DEFAULT PRIVILEGES IN SCHEMA washing GRANT ALL ON TABLES TO iloom;
ALTER DEFAULT PRIVILEGES IN SCHEMA audit GRANT ALL ON TABLES TO iloom;

View File

@ -0,0 +1,19 @@
package main
import (
"fmt"
"golang.org/x/crypto/bcrypt"
)
func main() {
hash := "$2a$10$S5XPWRXUnexAt9KJw2CefeHMa6aude7j5dm5qgWM1YMQAAwRgsGXa"
passwords := []string{"admin123", "muyu2026", "admin", "123456", "password", "Aa123456"}
for _, p := range passwords {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(p))
if err == nil {
fmt.Printf("MATCH: %s\n", p)
} else {
fmt.Printf("%-12s → %v\n", p, err)
}
}
}

45
shared/go.mod Normal file
View File

@ -0,0 +1,45 @@
module github.com/muyuqingfeng/iloom/shared
go 1.23
toolchain go1.24.4
require (
github.com/gin-gonic/gin v1.10.0
github.com/go-sql-driver/mysql v1.8.1
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
golang.org/x/crypto v0.33.0
google.golang.org/grpc v1.72.0
google.golang.org/protobuf v1.36.5
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
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/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.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/goccy/go-json v0.10.2 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

119
shared/go.sum Normal file
View File

@ -0,0 +1,119 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
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/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-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=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
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/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=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/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/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/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/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=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
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/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=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY=
go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI=
go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ=
go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE=
go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A=
go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU=
go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk=
go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w=
go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k=
go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=

12
shared/pkg/auth/claims.go Normal file
View File

@ -0,0 +1,12 @@
package auth
import "github.com/golang-jwt/jwt/v5"
type Claims struct {
jwt.RegisteredClaims
UserID string `json:"uid"`
CompanyID string `json:"cid"`
Role string `json:"role"`
IsMaster bool `json:"master"`
Username string `json:"uname"`
}

91
shared/pkg/auth/jwt.go Normal file
View File

@ -0,0 +1,91 @@
package auth
import (
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
)
const (
defaultAccessTTL = 7200 * time.Second
refreshTTL = 30 * 24 * time.Hour
)
type JWTManager struct {
secret []byte
refreshSecret []byte
accessTTL time.Duration
refreshTTL time.Duration
}
func NewJWTManager(secret, refreshSecret string) *JWTManager {
return &JWTManager{
secret: []byte(secret),
refreshSecret: []byte(refreshSecret),
accessTTL: defaultAccessTTL,
refreshTTL: refreshTTL,
}
}
func (m *JWTManager) GenerateAccessToken(claims Claims) (string, error) {
now := time.Now()
claims.RegisteredClaims = jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(m.accessTTL)),
IssuedAt: jwt.NewNumericDate(now),
Subject: claims.UserID,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.secret)
}
func (m *JWTManager) GenerateRefreshToken(userID string) (string, error) {
now := time.Now()
claims := jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(m.refreshTTL)),
IssuedAt: jwt.NewNumericDate(now),
Subject: userID,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(m.refreshSecret)
}
func (m *JWTManager) ParseAccessToken(tokenStr string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return m.secret, nil
})
if err != nil {
return nil, fmt.Errorf("parse access token: %w", err)
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid access token")
}
return claims, nil
}
func (m *JWTManager) ParseRefreshToken(tokenStr string) (string, error) {
token, err := jwt.ParseWithClaims(tokenStr, &jwt.RegisteredClaims{}, func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return m.refreshSecret, nil
})
if err != nil {
return "", fmt.Errorf("parse refresh token: %w", err)
}
claims, ok := token.Claims.(*jwt.RegisteredClaims)
if !ok || !token.Valid {
return "", fmt.Errorf("invalid refresh token")
}
return claims.Subject, nil
}

View File

@ -0,0 +1,15 @@
package auth
import "golang.org/x/crypto/bcrypt"
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func CheckPassword(password, hash string) bool {
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
}

View File

@ -0,0 +1,71 @@
package database
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/go-sql-driver/mysql"
)
type Config struct {
Host string
Port int
User string
Password string
DBName string
MaxConns int
MinConns int
}
func (c Config) DSN() string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?parseTime=true&charset=utf8mb4&collation=utf8mb4_unicode_ci&loc=Local",
c.User, c.Password, c.Host, c.Port, c.DBName)
}
func NewDB(cfg Config) (*sql.DB, error) {
db, err := sql.Open("mysql", cfg.DSN())
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
if cfg.MaxConns > 0 {
db.SetMaxOpenConns(cfg.MaxConns)
}
if cfg.MinConns > 0 {
db.SetMaxIdleConns(cfg.MinConns)
}
db.SetConnMaxLifetime(30 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return db, nil
}
func NewDBFromDSN(dsn string) (*sql.DB, error) {
db, err := sql.Open("mysql", dsn)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(30 * time.Minute)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
db.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return db, nil
}

View File

@ -0,0 +1 @@
package database

27
shared/pkg/database/tx.go Normal file
View File

@ -0,0 +1,27 @@
package database
import (
"context"
"database/sql"
"fmt"
)
func WithTx(ctx context.Context, db *sql.DB, fn func(tx *sql.Tx) error) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
if err := fn(tx); err != nil {
if rbErr := tx.Rollback(); rbErr != nil {
return fmt.Errorf("rollback failed: %v (original: %w)", rbErr, err)
}
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit tx: %w", err)
}
return nil
}

7
shared/pkg/event/bus.go Normal file
View File

@ -0,0 +1,7 @@
package event
type EventBus interface {
Publish(topic string, payload interface{}) error
Subscribe(topic string, handler func(payload []byte)) error
Close() error
}

View File

@ -0,0 +1 @@
package event

Some files were not shown because too many files have changed in this diff Show More