Files
files/internal/health/readiness.go
2026-09-09 16:42:21 +08:00

144 lines
4.0 KiB
Go

package health
import (
"context"
"fmt"
"net/http"
"runtime/debug"
"strings"
"time"
"git.apinb.com/ops/files/internal/config"
"git.apinb.com/ops/files/internal/impl"
"git.apinb.com/ops/files/internal/jobs"
"git.apinb.com/ops/files/internal/runtimeinfo"
"git.apinb.com/ops/files/internal/storage"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type CheckResult struct {
Name string `json:"name"`
Required bool `json:"required"`
Ready bool `json:"ready"`
Message string `json:"message,omitempty"`
}
type Readiness struct {
Ready bool `json:"ready"`
CheckedAt time.Time `json:"checked_at"`
Checks []CheckResult `json:"checks,omitempty"`
Version string `json:"version"`
}
type FileCounts struct {
Total int64 `json:"total"`
Ready int64 `json:"ready"`
Pending int64 `json:"pending"`
}
func Evaluate(ctx context.Context) Readiness {
result, _ := evaluate(ctx)
return result
}
func evaluate(ctx context.Context) (Readiness, storage.RuntimeStatus) {
result := Readiness{Ready: true, CheckedAt: time.Now().UTC(), Version: runtimeVersion()}
result.add("config", true, config.Validate())
result.add("database", true, pingDatabase(ctx, impl.DBService))
storageStatus, storageErr := impl.StorageService.Status(ctx)
if storageErr == nil && !storageStatus.Writable {
storageErr = fmt.Errorf("文件存储当前不可写")
}
result.add("storage", true, storageErr)
worker := jobs.Status()
if worker.Running {
result.add("pending_upload_cleanup", true, nil)
} else {
result.add("pending_upload_cleanup", true, fmt.Errorf("过期上传清理任务未运行"))
}
integrity := jobs.IntegrityStatusSnapshot()
if integrity.Running {
result.add("integrity_worker", true, nil)
} else {
result.add("integrity_worker", true, fmt.Errorf("文件完整性任务未运行"))
}
if integrity.Missing+integrity.SizeMismatch+integrity.ETagMismatch > 0 {
result.add("file_integrity", false, fmt.Errorf("发现文件缺失或校验不一致"))
} else {
result.add("file_integrity", false, nil)
}
return result, storageStatus
}
func runtimeVersion() string {
info, ok := debug.ReadBuildInfo()
if !ok || strings.TrimSpace(info.Main.Version) == "" {
return "unknown"
}
return info.Main.Version
}
func Ready(c *gin.Context) {
result := Evaluate(c.Request.Context())
status := http.StatusOK
if !result.Ready {
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{"ready": result.Ready})
}
func Status(c *gin.Context) {
result, storageStatus := evaluate(c.Request.Context())
status := http.StatusOK
if !result.Ready {
status = http.StatusServiceUnavailable
}
counts, countErr := fileCounts(c.Request.Context())
if countErr != nil {
result.add("file_ledger", true, countErr)
status = http.StatusServiceUnavailable
}
c.JSON(status, gin.H{
"readiness": result,
"workers": gin.H{
"pending_upload_cleanup": jobs.Status(),
"integrity": jobs.IntegrityStatusSnapshot(),
},
"storage": storageStatus,
"disk": storageStatus.Disk,
"files": counts,
"activity": runtimeinfo.Snapshot(),
})
}
func fileCounts(ctx context.Context) (FileCounts, error) {
var counts FileCounts
err := impl.DBService.WithContext(ctx).Raw(`
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE status = 'ready') AS ready,
COUNT(*) FILTER (WHERE status = 'pending') AS pending
FROM files_object
WHERE deleted_at IS NULL`).Scan(&counts).Error
return counts, err
}
func (r *Readiness) add(name string, required bool, err error) {
check := CheckResult{Name: name, Required: required, Ready: err == nil}
if err != nil {
check.Message = strings.TrimSpace(err.Error())
if required {
r.Ready = false
}
}
r.Checks = append(r.Checks, check)
}
func pingDatabase(ctx context.Context, db *gorm.DB) error {
if db == nil {
return fmt.Errorf("数据库未初始化")
}
sqlDB, err := db.DB()
if err != nil {
return err
}
checkCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
return sqlDB.PingContext(checkCtx)
}