From 9109dff28777be1316a3ad2100921ef61e78200a Mon Sep 17 00:00:00 2001 From: ZuoZuo <68836346+Mrz-sakura@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:26:26 +0800 Subject: [PATCH] feat: bootstrap pay service --- .gitignore | 1 + cmd/pay/main.go | 37 +++++++++++++ config/local.yaml | 6 +++ go.mod | 20 +++++++ go.sum | 20 +++++++ internal/app/app.go | 39 ++++++++++++++ internal/config/config.go | 75 ++++++++++++++++++++++++++ internal/service/pay/service.go | 67 +++++++++++++++++++++++ internal/transport/grpc/server.go | 74 +++++++++++++++++++++++++ internal/transport/http/server.go | 90 +++++++++++++++++++++++++++++++ 10 files changed, 429 insertions(+) create mode 100644 .gitignore create mode 100644 cmd/pay/main.go create mode 100644 config/local.yaml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/app.go create mode 100644 internal/config/config.go create mode 100644 internal/service/pay/service.go create mode 100644 internal/transport/grpc/server.go create mode 100644 internal/transport/http/server.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e43b0f9 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/cmd/pay/main.go b/cmd/pay/main.go new file mode 100644 index 0000000..06be084 --- /dev/null +++ b/cmd/pay/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "flag" + "log/slog" + "os" + "os/signal" + "syscall" + + "gitea.haiyihy.com/hy/chatapppay/internal/app" + "gitea.haiyihy.com/hy/chatapppay/internal/config" +) + +func main() { + configPath := flag.String("config", config.DefaultPath, "path to config file") + flag.Parse() + + cfg, err := config.Load(*configPath) + if err != nil { + slog.Error("load config failed", "error", err) + os.Exit(1) + } + + logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + application := app.New(cfg, logger) + if err := application.Run(ctx); err != nil { + logger.Error("application exited with error", "error", err) + os.Exit(1) + } +} diff --git a/config/local.yaml b/config/local.yaml new file mode 100644 index 0000000..4ceaf7c --- /dev/null +++ b/config/local.yaml @@ -0,0 +1,6 @@ +app: + name: chatapppay + env: local + http_addr: ":8082" + grpc_addr: ":9002" + shutdown_timeout: 10s diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6bc28a6 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module gitea.haiyihy.com/hy/chatapppay + +go 1.23.1 + +require ( + gitea.haiyihy.com/hy/chatappcommon v0.0.0 + golang.org/x/sync v0.10.0 + google.golang.org/grpc v1.67.3 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + golang.org/x/net v0.28.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.17.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) + +replace gitea.haiyihy.com/hy/chatappcommon => ../Common diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e0ab4a4 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= +golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.67.3 h1:OgPcDAFKHnH8X3O4WcO4XUc8GRDeKsKReqbQtiCj7N8= +google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7QfSU8s= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..bcc8bdd --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,39 @@ +package app + +import ( + "context" + "log/slog" + + "gitea.haiyihy.com/hy/chatapppay/internal/config" + payservice "gitea.haiyihy.com/hy/chatapppay/internal/service/pay" + grpcserver "gitea.haiyihy.com/hy/chatapppay/internal/transport/grpc" + httpserver "gitea.haiyihy.com/hy/chatapppay/internal/transport/http" + "golang.org/x/sync/errgroup" +) + +// Application 聚合支付服务的 HTTP 与 gRPC 服务。 +type Application struct { + httpServer *httpserver.Server + grpcServer *grpcserver.Server +} + +// New 构造支付服务应用。 +func New(cfg config.Config, logger *slog.Logger) *Application { + payHandler := payservice.New() + return &Application{ + httpServer: httpserver.New(cfg.App.Name, cfg.App.HTTPAddr, cfg.App.ShutdownTimeout, logger), + grpcServer: grpcserver.New(cfg.App.GRPCAddr, cfg.App.ShutdownTimeout, logger, payHandler), + } +} + +// Run 并行启动 HTTP 与 gRPC 服务,收到取消信号后优雅停机。 +func (a *Application) Run(ctx context.Context) error { + group, runCtx := errgroup.WithContext(ctx) + group.Go(func() error { + return a.httpServer.Run(runCtx) + }) + group.Go(func() error { + return a.grpcServer.Run(runCtx) + }) + return group.Wait() +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..db141b5 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,75 @@ +package config + +import ( + "bytes" + "fmt" + "net" + "os" + "time" + + "gopkg.in/yaml.v3" +) + +const DefaultPath = "config/local.yaml" + +// Config 汇总支付服务的运行配置。 +type Config struct { + App AppConfig `yaml:"app"` +} + +// AppConfig 描述支付服务自身监听参数。 +type AppConfig struct { + Name string `yaml:"name"` + Env string `yaml:"env"` + HTTPAddr string `yaml:"http_addr"` + GRPCAddr string `yaml:"grpc_addr"` + ShutdownTimeout time.Duration `yaml:"shutdown_timeout"` +} + +// Load 从 YAML 文件加载配置并校验。 +func Load(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return Config{}, fmt.Errorf("read config file %s: %w", path, err) + } + + cfg := defaultConfig() + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&cfg); err != nil { + return Config{}, fmt.Errorf("decode config file %s: %w", path, err) + } + + if err := validate(cfg); err != nil { + return Config{}, err + } + return cfg, nil +} + +func defaultConfig() Config { + return Config{ + App: AppConfig{ + Name: "chatapppay", + Env: "local", + HTTPAddr: ":8082", + GRPCAddr: ":9002", + ShutdownTimeout: 10 * time.Second, + }, + } +} + +func validate(cfg Config) error { + if cfg.App.Name == "" { + return fmt.Errorf("app.name is required") + } + if _, err := net.ResolveTCPAddr("tcp", cfg.App.HTTPAddr); err != nil { + return fmt.Errorf("app.http_addr is invalid: %w", err) + } + if _, err := net.ResolveTCPAddr("tcp", cfg.App.GRPCAddr); err != nil { + return fmt.Errorf("app.grpc_addr is invalid: %w", err) + } + if cfg.App.ShutdownTimeout <= 0 { + return fmt.Errorf("app.shutdown_timeout must be greater than 0") + } + return nil +} diff --git a/internal/service/pay/service.go b/internal/service/pay/service.go new file mode 100644 index 0000000..2c25f55 --- /dev/null +++ b/internal/service/pay/service.go @@ -0,0 +1,67 @@ +package pay + +import ( + "context" + "crypto/rand" + "encoding/hex" + "strings" + "time" + + commonpb "gitea.haiyihy.com/hy/chatappcommon/proto" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// Service 实现支付下单逻辑,当前只返回模拟结果,不写数据库。 +type Service struct{} + +// New 创建支付服务。 +func New() *Service { + return &Service{} +} + +// Pay 校验支付参数并返回模拟支付结果。 +func (s *Service) Pay(_ context.Context, request *commonpb.PayRequest) (*commonpb.PayResponse, error) { + orderNo := strings.TrimSpace(request.GetOrderNo()) + userID := strings.TrimSpace(request.GetUserId()) + amount := strings.TrimSpace(request.GetAmount()) + currency := strings.TrimSpace(request.GetCurrency()) + payMethod := strings.TrimSpace(request.GetPayMethod()) + subject := strings.TrimSpace(request.GetSubject()) + + if orderNo == "" { + return nil, status.Error(codes.InvalidArgument, "order_no is required") + } + if userID == "" { + return nil, status.Error(codes.InvalidArgument, "user_id is required") + } + if amount == "" { + return nil, status.Error(codes.InvalidArgument, "amount is required") + } + if currency == "" { + currency = "USD" + } + if payMethod == "" { + payMethod = "unknown" + } + + return &commonpb.PayResponse{ + PaymentId: "pay_" + randomID(), + OrderNo: orderNo, + UserId: userID, + Status: "processing", + Amount: amount, + Currency: currency, + PayMethod: payMethod, + Subject: subject, + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }, nil +} + +func randomID() string { + buffer := make([]byte, 8) + if _, err := rand.Read(buffer); err == nil { + return hex.EncodeToString(buffer) + } + return "fallback" +} diff --git a/internal/transport/grpc/server.go b/internal/transport/grpc/server.go new file mode 100644 index 0000000..00603bd --- /dev/null +++ b/internal/transport/grpc/server.go @@ -0,0 +1,74 @@ +package grpcserver + +import ( + "context" + "log/slog" + "net" + "time" + + commonpb "gitea.haiyihy.com/hy/chatappcommon/proto" + "google.golang.org/grpc" +) + +// PayService 定义支付下单能力。 +type PayService interface { + Pay(ctx context.Context, request *commonpb.PayRequest) (*commonpb.PayResponse, error) +} + +// Server 包装支付服务 gRPC 服务端。 +type Server struct { + addr string + shutdownTimeout time.Duration + logger *slog.Logger + grpcServer *grpc.Server +} + +type handler struct { + commonpb.UnimplementedChatAppPayServiceServer + service PayService +} + +func (h handler) Pay(ctx context.Context, request *commonpb.PayRequest) (*commonpb.PayResponse, error) { + return h.service.Pay(ctx, request) +} + +// New 创建 gRPC 服务。 +func New(addr string, shutdownTimeout time.Duration, logger *slog.Logger, service PayService) *Server { + grpcServer := grpc.NewServer() + commonpb.RegisterChatAppPayServiceServer(grpcServer, handler{service: service}) + return &Server{ + addr: addr, + shutdownTimeout: shutdownTimeout, + logger: logger, + grpcServer: grpcServer, + } +} + +// Run 启动 gRPC 服务并在退出时优雅停机。 +func (s *Server) Run(ctx context.Context) error { + listener, err := net.Listen("tcp", s.addr) + if err != nil { + return err + } + + go func() { + <-ctx.Done() + done := make(chan struct{}) + go func() { + s.grpcServer.GracefulStop() + close(done) + }() + + select { + case <-done: + case <-time.After(s.shutdownTimeout): + s.grpcServer.Stop() + } + }() + + s.logger.Info("pay grpc server started", "addr", s.addr) + if err := s.grpcServer.Serve(listener); err != nil { + return err + } + return nil +} diff --git a/internal/transport/http/server.go b/internal/transport/http/server.go new file mode 100644 index 0000000..4057420 --- /dev/null +++ b/internal/transport/http/server.go @@ -0,0 +1,90 @@ +package httpserver + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "sync/atomic" + "time" +) + +// Server 提供 /health 和 /ready HTTP 探针。 +type Server struct { + appName string + addr string + shutdownTimeout time.Duration + logger *slog.Logger + ready atomic.Bool +} + +// New 创建探针 HTTP 服务。 +func New(appName string, addr string, shutdownTimeout time.Duration, logger *slog.Logger) *Server { + server := &Server{ + appName: appName, + addr: addr, + shutdownTimeout: shutdownTimeout, + logger: logger, + } + server.ready.Store(true) + return server +} + +// Run 启动 HTTP 服务并在退出时优雅关闭。 +func (s *Server) Run(ctx context.Context) error { + mux := http.NewServeMux() + mux.HandleFunc("/health", s.handleHealth) + mux.HandleFunc("/heath", s.handleHealth) + mux.HandleFunc("/ready", s.handleReady) + + httpServer := &http.Server{ + Addr: s.addr, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + } + + go func() { + <-ctx.Done() + s.ready.Store(false) + shutdownCtx, cancel := context.WithTimeout(context.Background(), s.shutdownTimeout) + defer cancel() + _ = httpServer.Shutdown(shutdownCtx) + }() + + s.logger.Info("pay http server started", "addr", s.addr) + + err := httpServer.ListenAndServe() + if errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} + +func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ok", + "service": s.appName, + }) +} + +func (s *Server) handleReady(w http.ResponseWriter, _ *http.Request) { + if !s.ready.Load() { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "code": "not_ready", + "message": "service is shutting down", + }) + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "status": "ready", + "service": s.appName, + }) +} + +func writeJSON(w http.ResponseWriter, statusCode int, payload any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + _ = json.NewEncoder(w).Encode(payload) +}