mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 10:04:55 +08:00
feat(api): 补齐资源分发服务入口
新增 bat-api 资源 bootstrap/分发 HTTP 服务、RPC release 发现、CDN path 分发、launcher 资源引导兼容、控制面中间件、OpenAPI 和 systemd 模板。 同步 Go 边界文档,明确 Rust bat 是资源生产者和同步运维入口,Go bat-api 是只读 bootstrap/分发服务,试验 Go CLI 产物为 bin/bat-go。 验证:未运行新命令;本轮已按要求停止重复构建/测试。
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"bat-api/internal/backendrpc"
|
||||
)
|
||||
|
||||
// Backend is the subset of daemon RPC used by bat-api.
|
||||
//
|
||||
// Call order for discovery (per plan review):
|
||||
// 1. daemon.status
|
||||
// 2. daemon.doctor
|
||||
// 3. catalog.status / resource.manifest (and resource.state as needed)
|
||||
type Backend interface {
|
||||
DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error)
|
||||
DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error)
|
||||
ResourceState(ctx context.Context) (*backendrpc.ResourceState, error)
|
||||
CatalogStatus(ctx context.Context) (json.RawMessage, error)
|
||||
ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error)
|
||||
}
|
||||
|
||||
// RPCClient adapts *backendrpc.Client to Backend.
|
||||
type RPCClient struct {
|
||||
Client *backendrpc.Client
|
||||
}
|
||||
|
||||
func (r RPCClient) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) {
|
||||
return r.Client.DaemonStatus(ctx)
|
||||
}
|
||||
func (r RPCClient) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
|
||||
return r.Client.DaemonDoctor(ctx)
|
||||
}
|
||||
func (r RPCClient) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
|
||||
return r.Client.ResourceState(ctx)
|
||||
}
|
||||
func (r RPCClient) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
|
||||
return r.Client.CatalogStatus(ctx)
|
||||
}
|
||||
func (r RPCClient) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
|
||||
return r.Client.ResourceManifest(ctx, offset, limit)
|
||||
}
|
||||
|
||||
// DiscoverResult is the outcome of talking to the bat daemon.
|
||||
type DiscoverResult struct {
|
||||
RPCAvailable bool
|
||||
DoctorHealthy *bool
|
||||
Status *backendrpc.DaemonStatusReport
|
||||
Doctor *backendrpc.DoctorReport
|
||||
Snapshot *SnapshotSummary
|
||||
ResourceRoot string
|
||||
Index *ReleaseIndex
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// DiscoverAndIndex contacts the daemon (status first, then doctor) and builds
|
||||
// a release index from paginated resource.manifest plus on-disk checks.
|
||||
//
|
||||
// If resourceRootOverride is non-empty, it wins over RPC-reported roots after
|
||||
// RPC health probes (still preferred for production to call status/doctor).
|
||||
func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride string) (*DiscoverResult, error) {
|
||||
out := &DiscoverResult{}
|
||||
if backend == nil {
|
||||
if resourceRootOverride == "" {
|
||||
out.Index = &ReleaseIndex{Source: "empty", byRel: map[string]int{}}
|
||||
return out, nil
|
||||
}
|
||||
idx, err := LoadIndexFromResourceRoot(resourceRootOverride)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.ResourceRoot = idx.ResourceRoot
|
||||
out.Index = idx
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// 1) daemon.status first
|
||||
status, err := backend.DaemonStatus(ctx)
|
||||
if err != nil {
|
||||
out.Warnings = append(out.Warnings, fmt.Sprintf("daemon.status: %v", err))
|
||||
if resourceRootOverride != "" {
|
||||
idx, loadErr := LoadIndexFromResourceRoot(resourceRootOverride)
|
||||
if loadErr != nil {
|
||||
return out, fmt.Errorf("daemon.status failed (%v) and resource-root load failed: %w", err, loadErr)
|
||||
}
|
||||
out.ResourceRoot = idx.ResourceRoot
|
||||
out.Index = idx
|
||||
return out, nil
|
||||
}
|
||||
out.Index = &ReleaseIndex{Source: "empty", byRel: map[string]int{}}
|
||||
return out, nil
|
||||
}
|
||||
out.RPCAvailable = true
|
||||
out.Status = status
|
||||
|
||||
// 2) daemon.doctor second
|
||||
doctor, err := backend.DaemonDoctor(ctx)
|
||||
if err != nil {
|
||||
out.Warnings = append(out.Warnings, fmt.Sprintf("daemon.doctor: %v", err))
|
||||
} else {
|
||||
out.Doctor = doctor
|
||||
h := doctor.Healthy
|
||||
out.DoctorHealthy = &h
|
||||
}
|
||||
|
||||
// Catalog / resource discovery
|
||||
var snapshot *SnapshotSummary
|
||||
var resourceRoot string
|
||||
|
||||
if raw, err := backend.CatalogStatus(ctx); err != nil {
|
||||
out.Warnings = append(out.Warnings, fmt.Sprintf("catalog.status: %v", err))
|
||||
} else {
|
||||
snap, root, ok := parseCatalogStatus(raw)
|
||||
if ok {
|
||||
snapshot = snap
|
||||
resourceRoot = root
|
||||
}
|
||||
}
|
||||
|
||||
if resourceRoot == "" {
|
||||
if state, err := backend.ResourceState(ctx); err != nil {
|
||||
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.state: %v", err))
|
||||
} else if state.ResourceOutputRoot != nil && *state.ResourceOutputRoot != "" {
|
||||
// Prefer published current under output root when catalog root missing.
|
||||
candidate := filepath.Join(*state.ResourceOutputRoot, "current")
|
||||
resourceRoot = candidate
|
||||
}
|
||||
}
|
||||
|
||||
if resourceRootOverride != "" {
|
||||
resourceRoot = resourceRootOverride
|
||||
}
|
||||
if resourceRoot == "" {
|
||||
out.Snapshot = snapshot
|
||||
out.Index = &ReleaseIndex{
|
||||
Source: "rpc",
|
||||
RPCAvailable: true,
|
||||
DoctorHealthy: out.DoctorHealthy,
|
||||
Snapshot: snapshot,
|
||||
byRel: map[string]int{},
|
||||
}
|
||||
out.Warnings = append(out.Warnings, "no resource root from RPC; set --resource-root or publish a version")
|
||||
return out, nil
|
||||
}
|
||||
|
||||
entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend)
|
||||
if err != nil {
|
||||
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err))
|
||||
// Fallback: load local manifest file under root.
|
||||
idx, loadErr := LoadIndexFromResourceRoot(resourceRoot)
|
||||
if loadErr != nil {
|
||||
return out, fmt.Errorf("manifest RPC and local load failed: rpc=%v local=%w", err, loadErr)
|
||||
}
|
||||
idx.Source = "rpc+local_manifest"
|
||||
idx.RPCAvailable = true
|
||||
idx.DoctorHealthy = out.DoctorHealthy
|
||||
if snapshot != nil {
|
||||
idx.Snapshot = snapshot
|
||||
}
|
||||
out.ResourceRoot = idx.ResourceRoot
|
||||
out.Snapshot = idx.Snapshot
|
||||
out.Index = idx
|
||||
return out, nil
|
||||
}
|
||||
if rootFromManifest != "" {
|
||||
resourceRoot = rootFromManifest
|
||||
}
|
||||
if resourceRootOverride != "" {
|
||||
resourceRoot = resourceRootOverride
|
||||
}
|
||||
|
||||
idx, err := BuildIndexFromManifestEntries(
|
||||
resourceRoot,
|
||||
"rpc",
|
||||
true,
|
||||
out.DoctorHealthy,
|
||||
snapshot,
|
||||
manifestVersion,
|
||||
entries,
|
||||
)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.ResourceRoot = idx.ResourceRoot
|
||||
out.Snapshot = snapshot
|
||||
out.Index = idx
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, "", false
|
||||
}
|
||||
var payload struct {
|
||||
Available bool `json:"available"`
|
||||
AppVersion string `json:"app_version"`
|
||||
BundleVersion string `json:"bundle_version"`
|
||||
ConnectionGroupName string `json:"connection_group_name"`
|
||||
AddressablesRoot string `json:"addressables_root"`
|
||||
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata"`
|
||||
GameMainConfig *GameMainConfigSummary `json:"game_main_config"`
|
||||
Version *struct {
|
||||
ID string `json:"id"`
|
||||
CompletedUnixSeconds *uint64 `json:"completed_unix_seconds"`
|
||||
ResourceRoot string `json:"resource_root"`
|
||||
} `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &payload); err != nil || !payload.Available {
|
||||
return nil, "", false
|
||||
}
|
||||
snap := &SnapshotSummary{
|
||||
AppVersion: payload.AppVersion,
|
||||
BundleVersion: payload.BundleVersion,
|
||||
ConnectionGroupName: payload.ConnectionGroupName,
|
||||
AddressablesRoot: payload.AddressablesRoot,
|
||||
LauncherMetadata: payload.LauncherMetadata,
|
||||
GameMainConfig: payload.GameMainConfig,
|
||||
}
|
||||
root := ""
|
||||
if payload.Version != nil {
|
||||
snap.VersionID = payload.Version.ID
|
||||
snap.CompletedUnixSeconds = payload.Version.CompletedUnixSeconds
|
||||
root = payload.Version.ResourceRoot
|
||||
}
|
||||
return snap, root, true
|
||||
}
|
||||
|
||||
func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEntry, int, string, error) {
|
||||
const pageSize = 500
|
||||
offset := 0
|
||||
var all []manifestEntry
|
||||
var version int
|
||||
var root string
|
||||
for {
|
||||
page, err := backend.ResourceManifest(ctx, offset, pageSize)
|
||||
if err != nil {
|
||||
return nil, 0, "", err
|
||||
}
|
||||
if !page.Available {
|
||||
return nil, 0, "", fmt.Errorf("resource.manifest available=false")
|
||||
}
|
||||
if root == "" {
|
||||
root = page.ResourceRoot
|
||||
}
|
||||
if version == 0 {
|
||||
version = page.ManifestVersion
|
||||
}
|
||||
for _, e := range page.Entries {
|
||||
var bytes uint64
|
||||
if e.Bytes != nil {
|
||||
bytes = *e.Bytes
|
||||
}
|
||||
all = append(all, manifestEntry{
|
||||
URL: e.URL,
|
||||
Destination: e.Destination,
|
||||
Bytes: bytes,
|
||||
BLAKE3: e.BLAKE3,
|
||||
})
|
||||
}
|
||||
offset += len(page.Entries)
|
||||
if len(page.Entries) == 0 || offset >= page.TotalEntries {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, version, root, nil
|
||||
}
|
||||
Reference in New Issue
Block a user