2026-05-26 01:02:12 +08:00

429 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package app
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"strings"
"sync"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
healthgrpc "google.golang.org/grpc/health/grpc_health_v1"
walletv1 "hyapp.local/api/proto/wallet/v1"
"hyapp/pkg/appcode"
"hyapp/pkg/grpchealth"
"hyapp/pkg/grpcshutdown"
"hyapp/pkg/healthhttp"
"hyapp/pkg/logx"
"hyapp/pkg/rocketmqx"
"hyapp/pkg/walletmq"
"hyapp/services/wallet-service/internal/client"
"hyapp/services/wallet-service/internal/client/googleplay"
"hyapp/services/wallet-service/internal/config"
walletservice "hyapp/services/wallet-service/internal/service/wallet"
mysqlstorage "hyapp/services/wallet-service/internal/storage/mysql"
grpcserver "hyapp/services/wallet-service/internal/transport/grpc"
)
// App 装配 wallet-service gRPC 入口。
type App struct {
server *grpc.Server
listener net.Listener
health *grpchealth.ServingChecker
healthHTTP *healthhttp.Server
mysqlRepo *mysqlstorage.Repository
walletSvc *walletservice.Service
activityConn *grpc.ClientConn
outboxProducer *rocketmqx.Producer
outboxWorkerCfg config.OutboxWorkerConfig
walletOutboxTopic string
nodeID string
redPacketExpiryWorkerCfg config.RedPacketExpiryWorkerConfig
stopWorker context.CancelFunc
workers sync.WaitGroup
closeOnce sync.Once
}
// New 初始化 wallet-service。
func New(cfg config.Config) (*App, error) {
startupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// wallet-service 的账户、交易、分录和 outbox 必须共享同一个 MySQL 事务边界。
repository, err := mysqlstorage.Open(startupCtx, cfg.MySQLDSN)
if err != nil {
return nil, err
}
listener, err := net.Listen("tcp", cfg.GRPCAddr)
if err != nil {
_ = repository.Close()
return nil, err
}
activityConn, err := grpc.Dial(cfg.ActivityServiceAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
_ = listener.Close()
_ = repository.Close()
return nil, err
}
var outboxProducer *rocketmqx.Producer
if cfg.RocketMQ.WalletOutbox.Enabled {
outboxProducer, err = rocketmqx.NewProducer(rocketMQProducerConfig(cfg.RocketMQ))
if err != nil {
_ = activityConn.Close()
_ = listener.Close()
_ = repository.Close()
return nil, err
}
}
server := grpc.NewServer(grpc.UnaryInterceptor(logx.UnaryServerInterceptor("wallet-service")))
svc := walletservice.New(repository, client.NewActivityAchievementClient(activityConn))
if cfg.GooglePlay.Enabled {
googleClient, err := googleplay.New(cfg.GooglePlay)
if err != nil {
if outboxProducer != nil {
_ = outboxProducer.Shutdown()
}
_ = activityConn.Close()
_ = listener.Close()
_ = repository.Close()
return nil, err
}
svc.SetGooglePlayClient(googleClient)
}
walletv1.RegisterWalletServiceServer(server, grpcserver.NewServer(svc))
health := grpchealth.NewServingChecker("wallet-service", grpchealth.Dependency{
Name: "mysql",
Check: repository.Ping,
})
healthgrpc.RegisterHealthServer(server, grpchealth.NewServer(health))
healthHTTP, err := healthhttp.New(cfg.HealthHTTPAddr, cfg.NodeID, health)
if err != nil {
if outboxProducer != nil {
_ = outboxProducer.Shutdown()
}
_ = activityConn.Close()
_ = listener.Close()
_ = repository.Close()
return nil, err
}
return &App{
server: server,
listener: listener,
health: health,
healthHTTP: healthHTTP,
mysqlRepo: repository,
walletSvc: svc,
activityConn: activityConn,
outboxProducer: outboxProducer,
outboxWorkerCfg: cfg.OutboxWorker,
walletOutboxTopic: cfg.RocketMQ.WalletOutbox.Topic,
nodeID: cfg.NodeID,
redPacketExpiryWorkerCfg: cfg.RedPacketExpiryWorker,
}, nil
}
// Run 启动 gRPC 服务。
func (a *App) Run() error {
a.runHealthHTTP()
if err := a.startMQ(); err != nil {
a.health.MarkStopped()
return err
}
a.runGiftWallProjectionWorker()
a.health.MarkServing()
err := a.server.Serve(a.listener)
a.health.MarkStopped()
a.closeGiftWallProjectionWorker()
a.shutdownMQ()
if errors.Is(err, grpc.ErrServerStopped) {
return nil
}
return err
}
// Close 关闭 gRPC 服务。
func (a *App) Close() {
a.closeOnce.Do(func() {
a.health.MarkDraining()
a.closeGiftWallProjectionWorker()
grpcshutdown.GracefulStop(a.server, 15*time.Second)
a.closeHealthHTTP()
if a.activityConn != nil {
_ = a.activityConn.Close()
}
a.shutdownMQ()
if a.mysqlRepo != nil {
// MySQL 连接池最后关闭,避免正在 drain 的扣费请求丢失提交能力。
_ = a.mysqlRepo.Close()
}
})
}
func (a *App) runGiftWallProjectionWorker() {
if a.walletSvc == nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
a.stopWorker = cancel
giftWallWorkerID := "gift-wall-" + a.nodeID
a.workers.Add(1)
go func() {
defer a.workers.Done()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
// 礼物墙是展示读模型worker 常驻在 wallet-service 内消费账务 outbox。
// 服务重启会回滚未提交的投影事务;已完成事件由 projection done 状态防重。
processed, err := a.walletSvc.ProjectPendingGiftWallEvents(ctx, giftWallWorkerID, 50, 30*time.Second)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "gift_wall_projection_failed", err, slog.String("worker_id", giftWallWorkerID))
}
if processed >= 50 {
// 批次打满说明还有积压,立即继续 drain避免固定 tick 放大展示延迟。
continue
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
badgeWorkerID := "badge-grant-" + a.nodeID
a.workers.Add(1)
go func() {
defer a.workers.Done()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
// 徽章展示槽位属于 activity-service 读模型wallet 只 relay 自己 outbox 中的资源赠送事实。
processed, err := a.walletSvc.ProjectPendingBadgeGrantEvents(ctx, badgeWorkerID, 50, 30*time.Second)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "badge_grant_projection_failed", err, slog.String("worker_id", badgeWorkerID))
}
if processed >= 50 {
continue
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
if a.outboxWorkerCfg.Enabled && a.outboxProducer != nil {
outboxWorkerID := "wallet-outbox-" + a.nodeID
a.workers.Add(1)
go func() {
defer a.workers.Done()
a.runWalletOutboxWorker(ctx, outboxWorkerID)
}()
}
if a.redPacketExpiryWorkerCfg.Enabled {
redPacketWorkerID := "red-packet-expiry-" + a.nodeID
a.workers.Add(1)
go func() {
defer a.workers.Done()
ticker := time.NewTicker(a.redPacketExpiryWorkerCfg.PollInterval)
defer ticker.Stop()
for {
result, err := a.walletSvc.ExpireRedPackets(ctx, a.redPacketExpiryWorkerCfg.AppCode, a.redPacketExpiryWorkerCfg.BatchSize)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "red_packet_expiry_failed", err, slog.String("worker_id", redPacketWorkerID))
}
if result.ExpiredCount > 0 {
logx.Info(ctx, "red_packet_expired",
slog.String("worker_id", redPacketWorkerID),
slog.Int64("expired_count", int64(result.ExpiredCount)),
slog.Int64("refunded_amount", result.RefundedAmount),
)
}
if result.ExpiredCount >= a.redPacketExpiryWorkerCfg.BatchSize {
continue
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}()
}
}
func (a *App) runWalletOutboxWorker(ctx context.Context, workerID string) {
options := a.outboxWorkerCfg
ticker := time.NewTicker(options.PollInterval)
defer ticker.Stop()
for {
processed, err := a.processWalletOutboxBatch(ctx, workerID, options)
if err != nil && !errors.Is(err, context.Canceled) {
logx.Error(ctx, "wallet_outbox_publish_failed", err, slog.String("worker_id", workerID))
}
if processed >= options.BatchSize {
continue
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}
func (a *App) processWalletOutboxBatch(ctx context.Context, workerID string, options config.OutboxWorkerConfig) (int, error) {
if a.mysqlRepo == nil || a.outboxProducer == nil {
return 0, nil
}
records, err := a.mysqlRepo.ClaimPendingWalletOutbox(ctx, workerID, options.BatchSize, time.Now().UTC().Add(options.PublishTimeout).UnixMilli())
if err != nil {
return 0, err
}
for _, record := range records {
if record.RetryCount >= options.MaxRetryCount {
markCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
err := a.mysqlRepo.MarkWalletOutboxDead(markCtx, record.EventID, deadWalletOutboxReason(record))
cancel()
if err != nil {
return 0, err
}
continue
}
publishCtx, cancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
err := a.publishWalletOutboxRecord(publishCtx, record)
cancel()
if err != nil {
nextRetryAtMS := time.Now().UTC().Add(walletOutboxBackoff(record.RetryCount+1, options)).UnixMilli()
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
markErr := a.mysqlRepo.MarkWalletOutboxRetryable(markCtx, record.EventID, err.Error(), nextRetryAtMS)
markCancel()
if markErr != nil {
return 0, markErr
}
continue
}
markCtx, markCancel := context.WithTimeout(appcode.WithContext(context.Background(), record.AppCode), options.PublishTimeout)
markErr := a.mysqlRepo.MarkWalletOutboxDelivered(markCtx, record.EventID)
markCancel()
if markErr != nil {
return 0, markErr
}
}
return len(records), nil
}
func (a *App) publishWalletOutboxRecord(ctx context.Context, record mysqlstorage.WalletOutboxRecord) error {
body, err := walletmq.EncodeWalletOutboxMessage(walletmq.WalletOutboxMessage{
AppCode: record.AppCode,
EventID: record.EventID,
EventType: record.EventType,
TransactionID: record.TransactionID,
CommandID: record.CommandID,
UserID: record.UserID,
AssetType: record.AssetType,
AvailableDelta: record.AvailableDelta,
FrozenDelta: record.FrozenDelta,
PayloadJSON: record.PayloadJSON,
OccurredAtMS: record.CreatedAtMS,
})
if err != nil {
return err
}
return a.outboxProducer.SendSync(ctx, rocketmqx.Message{
Topic: a.walletOutboxTopic,
Tag: walletmq.TagWalletOutboxEvent,
Keys: []string{record.EventID, record.TransactionID, record.CommandID},
Body: body,
})
}
func deadWalletOutboxReason(record mysqlstorage.WalletOutboxRecord) string {
last := strings.TrimSpace(record.LastError)
if last == "" {
return fmt.Sprintf("wallet outbox exceeded retry limit: retry_count=%d", record.RetryCount)
}
return last
}
func walletOutboxBackoff(retryCount int, options config.OutboxWorkerConfig) time.Duration {
if retryCount <= 0 {
return options.InitialBackoff
}
backoff := options.InitialBackoff
for i := 1; i < retryCount; i++ {
backoff *= 2
if backoff >= options.MaxBackoff {
return options.MaxBackoff
}
}
return backoff
}
func (a *App) closeGiftWallProjectionWorker() {
if a.stopWorker != nil {
a.stopWorker()
}
a.workers.Wait()
}
func (a *App) startMQ() error {
if a.outboxProducer == nil {
return nil
}
return a.outboxProducer.Start()
}
func (a *App) shutdownMQ() {
if a.outboxProducer == nil {
return
}
if err := a.outboxProducer.Shutdown(); err != nil {
logx.Warn(context.Background(), "rocketmq_producer_shutdown_failed", slog.String("error", err.Error()))
}
}
func rocketMQProducerConfig(cfg config.RocketMQConfig) rocketmqx.ProducerConfig {
return rocketmqx.ProducerConfig{
EndpointConfig: rocketmqx.EndpointConfig{
NameServers: cfg.NameServers,
NameServerDomain: cfg.NameServerDomain,
AccessKey: cfg.AccessKey,
SecretKey: cfg.SecretKey,
SecurityToken: cfg.SecurityToken,
Namespace: cfg.Namespace,
VIPChannel: cfg.VIPChannel,
},
GroupName: cfg.WalletOutbox.ProducerGroup,
SendTimeout: cfg.SendTimeout,
Retry: cfg.Retry,
}
}
func (a *App) runHealthHTTP() {
if a.healthHTTP == nil {
return
}
go func() {
if err := a.healthHTTP.Run(); err != nil {
logx.Error(context.Background(), "health_http_run_failed", err)
}
}()
}
func (a *App) closeHealthHTTP() {
if a.healthHTTP == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
_ = a.healthHTTP.Close(ctx)
}