feat: bootstrap pay service
This commit is contained in:
commit
9109dff287
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
.DS_Store
|
||||
37
cmd/pay/main.go
Normal file
37
cmd/pay/main.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
6
config/local.yaml
Normal file
6
config/local.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
app:
|
||||
name: chatapppay
|
||||
env: local
|
||||
http_addr: ":8082"
|
||||
grpc_addr: ":9002"
|
||||
shutdown_timeout: 10s
|
||||
20
go.mod
Normal file
20
go.mod
Normal file
@ -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
|
||||
20
go.sum
Normal file
20
go.sum
Normal file
@ -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=
|
||||
39
internal/app/app.go
Normal file
39
internal/app/app.go
Normal file
@ -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()
|
||||
}
|
||||
75
internal/config/config.go
Normal file
75
internal/config/config.go
Normal file
@ -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
|
||||
}
|
||||
67
internal/service/pay/service.go
Normal file
67
internal/service/pay/service.go
Normal file
@ -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"
|
||||
}
|
||||
74
internal/transport/grpc/server.go
Normal file
74
internal/transport/grpc/server.go
Normal file
@ -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
|
||||
}
|
||||
90
internal/transport/http/server.go
Normal file
90
internal/transport/http/server.go
Normal file
@ -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)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user