// Package httpapi 路由装配。各业务模块在各自包内实现 handler,这里只负责挂载, // 并按配置在真实外部依赖(微信/支付/OSS/ASR)与 mock 之间切换。 package httpapi import ( "context" "log/slog" "net/http" "github.com/gin-gonic/gin" "github.com/redis/go-redis/v9" "gorm.io/gorm" "dudu/server/internal/asr" "dudu/server/internal/auth" "dudu/server/internal/billing" "dudu/server/internal/config" "dudu/server/internal/feedback" "dudu/server/internal/gateway" "dudu/server/internal/quota" "dudu/server/internal/telemetry" "dudu/server/internal/user" ) type Deps struct { Cfg config.Config DB *gorm.DB RDB *redis.Client JWT *auth.JWT Quota *quota.Manager } // pickProvider 按配置选择 ASR Provider(17E)。ASR_PROVIDER=gummy 且 DASHSCOPE_API_KEY // 就绪时装配真实 gummy;缺 key 则告警并降级 mock。返回值 kind 为装配结果("gummy"/"mock")。 func pickProvider(cfg config.Config) (asr.Provider, string) { switch cfg.ASRProvider { case "gummy": if cfg.DashScopeAPIKey != "" { return asr.NewGummy(cfg.DashScopeAPIKey), "gummy" } slog.Warn("ASR_PROVIDER=gummy but DASHSCOPE_API_KEY empty, falling back to mock") return asr.NewMock(), "mock" default: return asr.NewMock(), "mock" } } // pickWechat 微信凭证未配置(#2A 申请中)时使用 mock。返回 kind="mock"(真实实现就绪前恒为 mock)。 func pickWechat(cfg config.Config) (auth.WechatClient, string) { if cfg.WechatWebAppID == "" { return auth.MockWechat{}, "mock" } return auth.MockWechat{}, "mock" // TODO(5C-真实): 开放平台实现,凭证就绪后替换 } // pickPay 商户号未配置(#2B 申请中)时使用 mock。返回 kind="mock"。 // ⚠️⚠️ 上线前必须替换为真实 PayClient!MockPay 不验签,/v1/pay/notify 等于公开充值接口。 func pickPay(cfg config.Config) (billing.PayClient, string) { if cfg.WxPayMchID == "" { return billing.MockPay{}, "mock" } // TODO(7D-真实)【上线阻断项】: wechatpay-go 实现 + APIv3 验签,商户号就绪后替换,否则充值接口可被伪造。 return billing.MockPay{}, "mock" } // pickStorage OSS 未配置时图片落本地盘。返回 kind="local"(OSS 就绪后改为 "oss")。 func pickStorage(cfg config.Config) (feedback.Storage, string) { if cfg.OSSBucket == "" { return feedback.LocalStorage{Dir: "var/uploads"}, "local" } return feedback.LocalStorage{Dir: "var/uploads"}, "local" // TODO(8C-真实): OSS 实现 } func Register(r *gin.Engine, d *Deps) { r.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) }) provider, providerKind := pickProvider(d.Cfg) wechat, wechatKind := pickWechat(d.Cfg) pay, payKind := pickPay(d.Cfg) storage, storageKind := pickStorage(d.Cfg) // 启动时打印各外部依赖的装配结果(real/mock),便于部署核对(17E)。 slog.Info("dependency assembly", "asr_provider", providerKind, "wechat", wechatKind, "pay", payKind, "storage", storageKind) authH := &auth.Handlers{DB: d.DB, RDB: d.RDB, JWT: d.JWT, Wechat: wechat} billH := &billing.Handlers{DB: d.DB, Pay: pay, Quota: d.Quota} userH := &user.Handlers{DB: d.DB, Quota: d.Quota, AppVersions: d.Cfg.AppLatest} fbH := &feedback.Handlers{DB: d.DB, RDB: d.RDB, Storage: storage} telH := telemetry.New(d.DB, d.RDB) gwH := &gateway.Handler{Provider: provider, Quota: d.Quota, RDB: d.RDB} // 后台任务 bg := context.Background() billH.StartWatchdog(bg) d.Quota.StartDailyReconcile(bg) telH.StartDailyAggregate(bg) v1 := r.Group("/v1") // 🔓 无需登录 v1.POST("/auth/qr", authH.CreateQr) v1.GET("/auth/qr/:state", authH.PollQr) v1.GET("/auth/wechat/callback", authH.QrCallback) v1.POST("/auth/wechat", authH.MobileLogin) v1.GET("/packs", billH.Packs) // ⚠️ 部署前必须切换到真实 PayClient(验签)!当前 MockPay 不验签, // /v1/pay/notify 等于一个无鉴权的公开充值接口(任意人可伪造支付成功回调充值)。 // 上线前务必让 pickPay 返回带 APIv3 验签的真实实现(见 pickPay 的 TODO(7D-真实))。 v1.POST("/pay/notify", billH.Notify) // metrics/batch 可匿名上报;OptionalMiddleware 在带合法 JWT 时注入 user_id 以关联用户(17F)。 v1.POST("/metrics/batch", d.JWT.OptionalMiddleware(), telH.Batch) v1.GET("/app/latest", userH.AppLatest) // 需登录 authed := v1.Group("", d.JWT.Middleware()) authed.POST("/auth/logout", authH.Logout) authed.GET("/me", userH.Me) authed.POST("/orders", billH.CreateOrder) authed.GET("/orders/:id", billH.OrderStatus) authed.POST("/feedback", fbH.Submit) authed.GET("/asr/stream", gwH.HandleWS) }