feat: bootstrap pay service

This commit is contained in:
ZuoZuo 2026-04-04 15:26:26 +08:00
commit 9109dff287
10 changed files with 429 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.DS_Store

37
cmd/pay/main.go Normal file
View 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
View File

@ -0,0 +1,6 @@
app:
name: chatapppay
env: local
http_addr: ":8082"
grpc_addr: ":9002"
shutdown_timeout: 10s

20
go.mod Normal file
View 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
View 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
View 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
View 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
}

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

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

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