Merge remote-tracking branch 'origin/main' into feat/pay-v2-integration
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 25s
ci-pangolin / Lint — shellcheck (push) Successful in 29s
ci-pangolin / Cleartext Scan — Android 禁明文 (push) Successful in 22s
ci-pangolin / OpenAPI Sync Check (push) Successful in 40s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 19s
ci-pangolin / Flutter — analyze + test (push) Failing after 4m59s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 1m51s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Failing after 1m33s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Failing after 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m59s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (push) Failing after 4s

# Conflicts:
#	docs/index.html
#	server/cmd/server/main.go
This commit is contained in:
wangjia
2026-07-11 16:44:05 +08:00
228 changed files with 13418 additions and 1106 deletions
+40 -38
View File
@@ -123,8 +123,9 @@ func main() {
r := chi.NewRouter()
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(corsMiddleware(corsAllowedOrigins()))
r.Use(apierr.Middleware)
// CORS:Web 用户中心(pangolin.yanmeiai.com/user)跨域调 /v1/*;原生端不受影响。
r.Use(httpapi.NewCORS())
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
@@ -136,6 +137,19 @@ func main() {
// so it can call the API with plain relative fetch()s (no CORS).
r.Get("/buy", serveBuyPage)
// Public (no auth): 客户端安装包下载(官网下载按钮直链)。CI
// (scripts/ci/deploy-client.sh)把最新安装包 scp 到 DOWNLOADS_DIR,按平台固定
// 文件名覆盖;目录不存在也不影响启动,只是请求 404(见 DownloadsHandler 注释)。
downloadsHandler := httpapi.NewDownloadsHandler(os.Getenv("DOWNLOADS_DIR"))
r.Get("/downloads/*", downloadsHandler.Serve)
// Public (no auth): 客户端自动更新版本清单。VERSION_MANIFEST 可配置清单路径
// (默认 /etc/pangolin/version.yaml);deploy/single-node/deploy.sh 安装仓库内
// 默认清单,scripts/ci/release-client.sh 在每次 client-v* 发版时改写其
// version/build_number。每次请求都重新读文件,发版脚本改完立即生效,无需重启。
versionHandler := httpapi.NewVersionHandler(os.Getenv("VERSION_MANIFEST"))
r.Get("/version", versionHandler.Serve)
// Optional probe ingest route. sharedProbeStore is reused by the scheduler
// (below) when both are enabled, so they share one Redis-backed store.
var sharedProbeStore *probe.Store
@@ -325,7 +339,23 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
// ── Usage ─────────────────────────────────────────────────────────────────
usageStore := usage.NewStore(sqlDB)
usageSvc := usage.NewService(usageStore, rdb, nil, time.Hour)
// Ad verifier: real AdMob SSV in production; a放行式 DevVerifier when
// ADS_DEV_MODE=1 (or no AdMob configured) so the placeholder看广告加时 flow
// works end-to-end before the real ad SDK is wired in. Nonce replay
// protection still applies either way.
var adVerifier usage.AdVerifier
if os.Getenv("ADS_DEV_MODE") == "1" {
adVerifier = usage.DevVerifier{}
slog.Warn("ads: DevVerifier enabled (ADS_DEV_MODE=1) — accepts any receipt; not for production")
} else if os.Getenv("ADMOB_SSV") == "1" {
adVerifier = usage.NewAdMobVerifier("", nil, 0, nil)
} else {
// Default (current state): no real ad network yet → placeholder verifier
// so免费版看广告加时 is functional in the field.
adVerifier = usage.DevVerifier{}
slog.Warn("ads: no ad network configured — using DevVerifier placeholder")
}
usageSvc := usage.NewService(usageStore, rdb, adVerifier, time.Hour)
usageHandler := usage.NewUsageHandler(usageSvc)
deviceUsageHandler := usage.NewDeviceUsageHandler(usageSvc)
adsHandler := usage.NewAdsUnlockHandler(usageSvc)
@@ -378,6 +408,9 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
v1.Post("/auth/login", authHandler.Login)
v1.Post("/auth/refresh", authHandler.Refresh)
v1.Post("/auth/logout", authHandler.Logout)
// App→网页免登录换票的公开一端;票据本身即凭证,无需 Bearer(见下方
// 受保护分组里的签票端 /auth/web-ticket)。
v1.Post("/auth/web-ticket/exchange", authHandler.WebTicketExchange)
if totpHandler != nil {
v1.Post("/auth/login/totp", totpHandler.LoginTOTP)
}
@@ -408,6 +441,10 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
}
})
protected.Post("/redeem", redeemHandler.ServeHTTP)
if authHandler != nil {
// 签票端要求已登录(拿当前 JWT 的 uid/uuid);兑换端见上方公开分组。
protected.Post("/auth/web-ticket", authHandler.WebTicket)
}
protected.Get("/usage", usageHandler.ServeHTTP)
protected.Get("/usage/devices", deviceUsageHandler.ServeHTTP)
protected.Post("/ads/unlock", adsHandler.ServeHTTP)
@@ -592,39 +629,4 @@ func (a authDeviceRegistrar) CheckDeviceLimit(ctx context.Context, userID int64)
return &auth.DeviceLimit{MaxDevices: st.MaxDevices, Devices: briefs}, nil
}
// corsAllowedOrigins 从 env CORS_ALLOWED_ORIGINS(逗号分隔)读跨域白名单;默认放行官网/
// 用户中心域。控制面走 Bearer token(无 cookie 会话),故不放行 credentials,只白名单回显 Origin。
func corsAllowedOrigins() map[string]bool {
raw := os.Getenv("CORS_ALLOWED_ORIGINS")
if raw == "" {
raw = "https://pangolin.yanmeiai.com"
}
out := map[string]bool{}
for _, o := range strings.Split(raw, ",") {
if o = strings.TrimSpace(o); o != "" {
out[o] = true
}
}
return out
}
// corsMiddleware 按白名单回显 Access-Control-Allow-Origin 并应答预检 OPTIONS。只放行白名单内
// Origin(不用 "*"),允许 Authorization/Content-Type 头。非白名单来源不加任何 CORS 头(浏览器自然拦)。
func corsMiddleware(allowed map[string]bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" && allowed[origin] {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Max-Age", "600")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// (CORS 由 internal/httpapi.NewCORS() 统一提供,原 main 内联 corsMiddleware 已移除)