mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 08:14: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,292 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ResourceEntry is one distributable object from the published release.
|
||||
type ResourceEntry struct {
|
||||
URL string `json:"url"`
|
||||
RelativePath string `json:"relative_path"`
|
||||
Bytes uint64 `json:"bytes"`
|
||||
BLAKE3 string `json:"blake3,omitempty"`
|
||||
Present bool `json:"present"`
|
||||
SizeMatch bool `json:"size_match"`
|
||||
}
|
||||
|
||||
// SnapshotSummary is a subset of official-sync-snapshot.json / catalog.status.
|
||||
type SnapshotSummary struct {
|
||||
AppVersion string `json:"app_version,omitempty"`
|
||||
BundleVersion string `json:"bundle_version,omitempty"`
|
||||
ConnectionGroupName string `json:"connection_group_name,omitempty"`
|
||||
AddressablesRoot string `json:"addressables_root,omitempty"`
|
||||
VersionID string `json:"version_id,omitempty"`
|
||||
CompletedUnixSeconds *uint64 `json:"completed_unix_seconds,omitempty"`
|
||||
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"`
|
||||
GameMainConfig *GameMainConfigSummary `json:"game_main_config,omitempty"`
|
||||
}
|
||||
|
||||
// LauncherMetadataSummary mirrors the resource-relevant part of Rust's launcher metadata snapshot.
|
||||
type LauncherMetadataSummary struct {
|
||||
LauncherVersion string `json:"launcher_version,omitempty"`
|
||||
GameLatestVersion string `json:"game_latest_version,omitempty"`
|
||||
GameLatestFilePath string `json:"game_latest_file_path,omitempty"`
|
||||
GameLowestVersion string `json:"game_lowest_version,omitempty"`
|
||||
GameStartExeName string `json:"game_start_exe_name,omitempty"`
|
||||
GameStartParams []string `json:"game_start_params,omitempty"`
|
||||
ManifestURL string `json:"manifest_url,omitempty"`
|
||||
ManifestSource string `json:"manifest_source,omitempty"`
|
||||
ManifestFileCount int `json:"manifest_file_count,omitempty"`
|
||||
}
|
||||
|
||||
// GameMainConfigSummary mirrors the resource-relevant decrypted GameMainConfig fields.
|
||||
type GameMainConfigSummary struct {
|
||||
ServerInfoDataURL string `json:"server_info_data_url,omitempty"`
|
||||
DefaultConnectionGroup string `json:"default_connection_group,omitempty"`
|
||||
}
|
||||
|
||||
// ReleaseIndex is the in-memory view of a published resource root.
|
||||
type ReleaseIndex struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
ResourceRoot string `json:"resource_root"`
|
||||
Source string `json:"source"` // "rpc" | "resource_root" | "empty"
|
||||
RPCAvailable bool `json:"rpc_available"`
|
||||
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
|
||||
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
|
||||
ManifestVersion int `json:"manifest_version,omitempty"`
|
||||
Entries []ResourceEntry `json:"entries"`
|
||||
// byRel maps relative path (host/path...) to entry index.
|
||||
byRel map[string]int
|
||||
// MissingOnDisk lists relative paths present in the index but absent on disk.
|
||||
MissingOnDisk []string `json:"missing_on_disk,omitempty"`
|
||||
}
|
||||
|
||||
// Summary returns a JSON-serializable overview without the full entry list.
|
||||
type ReleaseSummary struct {
|
||||
ResourceRoot string `json:"resource_root"`
|
||||
Source string `json:"source"`
|
||||
RPCAvailable bool `json:"rpc_available"`
|
||||
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
|
||||
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
|
||||
ManifestVersion int `json:"manifest_version,omitempty"`
|
||||
EntryCount int `json:"entry_count"`
|
||||
PresentCount int `json:"present_count"`
|
||||
MissingCount int `json:"missing_count"`
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
|
||||
// Summary builds a compact release overview.
|
||||
func (idx *ReleaseIndex) Summary() ReleaseSummary {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
present := 0
|
||||
for _, e := range idx.Entries {
|
||||
if e.Present && e.SizeMatch {
|
||||
present++
|
||||
}
|
||||
}
|
||||
return ReleaseSummary{
|
||||
ResourceRoot: idx.ResourceRoot,
|
||||
Source: idx.Source,
|
||||
RPCAvailable: idx.RPCAvailable,
|
||||
DoctorHealthy: idx.DoctorHealthy,
|
||||
Snapshot: idx.Snapshot,
|
||||
ManifestVersion: idx.ManifestVersion,
|
||||
EntryCount: len(idx.Entries),
|
||||
PresentCount: present,
|
||||
MissingCount: len(idx.MissingOnDisk),
|
||||
Ready: idx.ResourceRoot != "" && present > 0,
|
||||
}
|
||||
}
|
||||
|
||||
// Lookup returns the entry for a host/path relative path.
|
||||
func (idx *ReleaseIndex) Lookup(rel string) (ResourceEntry, bool) {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
rel = filepath.ToSlash(rel)
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
i, ok := idx.byRel[rel]
|
||||
if !ok {
|
||||
return ResourceEntry{}, false
|
||||
}
|
||||
return idx.Entries[i], true
|
||||
}
|
||||
|
||||
// List returns a stable page of entries.
|
||||
func (idx *ReleaseIndex) List(offset, limit int) (items []ResourceEntry, total int) {
|
||||
idx.mu.RLock()
|
||||
defer idx.mu.RUnlock()
|
||||
total = len(idx.Entries)
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
if offset >= total {
|
||||
return []ResourceEntry{}, total
|
||||
}
|
||||
end := offset + limit
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
out := make([]ResourceEntry, end-offset)
|
||||
copy(out, idx.Entries[offset:end])
|
||||
return out, total
|
||||
}
|
||||
|
||||
// BuildIndexFromManifestEntries builds an index against resourceRoot.
|
||||
func BuildIndexFromManifestEntries(
|
||||
resourceRoot string,
|
||||
source string,
|
||||
rpcAvailable bool,
|
||||
doctorHealthy *bool,
|
||||
snapshot *SnapshotSummary,
|
||||
manifestVersion int,
|
||||
entries []manifestEntry,
|
||||
) (*ReleaseIndex, error) {
|
||||
rootAbs, err := filepath.Abs(resourceRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx := &ReleaseIndex{
|
||||
ResourceRoot: rootAbs,
|
||||
Source: source,
|
||||
RPCAvailable: rpcAvailable,
|
||||
DoctorHealthy: doctorHealthy,
|
||||
Snapshot: snapshot,
|
||||
ManifestVersion: manifestVersion,
|
||||
byRel: make(map[string]int),
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool {
|
||||
return entries[i].URL < entries[j].URL
|
||||
})
|
||||
for _, raw := range entries {
|
||||
rel := filepath.ToSlash(raw.Destination)
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
if rel == "" {
|
||||
// Derive from URL when destination is empty.
|
||||
rel = relativePathFromURL(raw.URL)
|
||||
}
|
||||
if rel == "" {
|
||||
continue
|
||||
}
|
||||
entry := ResourceEntry{
|
||||
URL: raw.URL,
|
||||
RelativePath: rel,
|
||||
Bytes: raw.Bytes,
|
||||
BLAKE3: raw.BLAKE3,
|
||||
}
|
||||
abs, err := ResolveUnderRoot(rootAbs, rel, false)
|
||||
if err != nil {
|
||||
entry.Present = false
|
||||
entry.SizeMatch = false
|
||||
idx.MissingOnDisk = append(idx.MissingOnDisk, rel)
|
||||
} else {
|
||||
info, err := os.Lstat(abs)
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
entry.Present = false
|
||||
entry.SizeMatch = false
|
||||
idx.MissingOnDisk = append(idx.MissingOnDisk, rel)
|
||||
} else {
|
||||
entry.Present = true
|
||||
entry.SizeMatch = uint64(info.Size()) == raw.Bytes || raw.Bytes == 0
|
||||
if !entry.SizeMatch {
|
||||
idx.MissingOnDisk = append(idx.MissingOnDisk, rel+"#size_mismatch")
|
||||
}
|
||||
}
|
||||
}
|
||||
idx.byRel[rel] = len(idx.Entries)
|
||||
idx.Entries = append(idx.Entries, entry)
|
||||
}
|
||||
return idx, nil
|
||||
}
|
||||
|
||||
// LoadIndexFromResourceRoot reads official-download-manifest.json under root.
|
||||
// Used for tests and --resource-root fallback without RPC.
|
||||
func LoadIndexFromResourceRoot(resourceRoot string) (*ReleaseIndex, error) {
|
||||
rootAbs, err := filepath.Abs(resourceRoot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
manifestPath := filepath.Join(rootAbs, "official-download-manifest.json")
|
||||
data, err := os.ReadFile(manifestPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read download manifest: %w", err)
|
||||
}
|
||||
var manifest fileManifest
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil, fmt.Errorf("parse download manifest: %w", err)
|
||||
}
|
||||
if manifest.Version != 0 && manifest.Version != 1 {
|
||||
return nil, fmt.Errorf("unsupported download manifest version %d", manifest.Version)
|
||||
}
|
||||
entries := make([]manifestEntry, 0, len(manifest.Entries))
|
||||
for _, e := range manifest.Entries {
|
||||
entries = append(entries, manifestEntry{
|
||||
URL: e.URL,
|
||||
Destination: e.Destination,
|
||||
Bytes: e.Bytes,
|
||||
BLAKE3: e.BLAKE3,
|
||||
})
|
||||
}
|
||||
var snapshot *SnapshotSummary
|
||||
if snapData, err := os.ReadFile(filepath.Join(rootAbs, "official-sync-snapshot.json")); err == nil {
|
||||
var s fileSnapshot
|
||||
if json.Unmarshal(snapData, &s) == nil {
|
||||
snapshot = &SnapshotSummary{
|
||||
AppVersion: s.AppVersion,
|
||||
BundleVersion: s.BundleVersion,
|
||||
ConnectionGroupName: s.ConnectionGroupName,
|
||||
AddressablesRoot: s.AddressablesRoot,
|
||||
LauncherMetadata: s.LauncherMetadata,
|
||||
GameMainConfig: s.GameMainConfigBootstrap,
|
||||
}
|
||||
}
|
||||
}
|
||||
return BuildIndexFromManifestEntries(rootAbs, "resource_root", false, nil, snapshot, manifest.Version, entries)
|
||||
}
|
||||
|
||||
type manifestEntry struct {
|
||||
URL string
|
||||
Destination string
|
||||
Bytes uint64
|
||||
BLAKE3 string
|
||||
}
|
||||
|
||||
type fileManifest struct {
|
||||
Version int `json:"version"`
|
||||
Entries map[string]struct {
|
||||
URL string `json:"url"`
|
||||
Destination string `json:"destination"`
|
||||
Bytes uint64 `json:"bytes"`
|
||||
BLAKE3 string `json:"blake3"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
|
||||
type fileSnapshot struct {
|
||||
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,omitempty"`
|
||||
GameMainConfigBootstrap *GameMainConfigSummary `json:"game_main_config_bootstrap,omitempty"`
|
||||
}
|
||||
|
||||
func relativePathFromURL(rawURL string) string {
|
||||
const prefix = "https://"
|
||||
if !strings.HasPrefix(rawURL, prefix) {
|
||||
return ""
|
||||
}
|
||||
rest := strings.TrimPrefix(rawURL, prefix)
|
||||
rest = strings.SplitN(rest, "?", 2)[0]
|
||||
rest = strings.SplitN(rest, "#", 2)[0]
|
||||
return filepath.ToSlash(rest)
|
||||
}
|
||||
Reference in New Issue
Block a user