Files
updates/internal/storage/local.go
lafay 3ac1a2ccf5
Some checks failed
Build Updates Server / build (push) Failing after 10s
Build Updates Server / build-docker (push) Has been skipped
feat(apk): 增加公开APK管理接口和CI/CD工作流
- 新增获取最新APK版本信息和下载APK的公开接口
- 更新README文档,添加APK管理接口的使用说明
- 实现CI/CD工作流,支持自动构建和部署
- 优化APK存储逻辑,确保只保留最新APK文件
2026-03-26 03:29:44 +08:00

279 lines
7.1 KiB
Go

package storage
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
"expo-updates-server-go/internal/model"
)
var ErrUnsupportedRuntime = errors.New("Unsupported runtime version")
var ErrUnsupportedPlatform = errors.New("Unsupported platform")
type LocalStore struct {
root string
}
func NewLocalStore(root string) *LocalStore {
return &LocalStore{root: root}
}
func (s *LocalStore) UpdatesRoot() string {
return s.root
}
func (s *LocalStore) LatestReleasePath(runtimeVersion string, platform string) (string, error) {
if platform != "ios" && platform != "android" {
return "", ErrUnsupportedPlatform
}
runtimeDir := filepath.Join(s.root, runtimeVersion)
runtimeStat, err := os.Stat(runtimeDir)
if err != nil || !runtimeStat.IsDir() {
return "", ErrUnsupportedRuntime
}
platformDir := filepath.Join(runtimeDir, platform)
platformStat, err := os.Stat(platformDir)
if err != nil || !platformStat.IsDir() {
return "", ErrUnsupportedPlatform
}
return platformDir, nil
}
func (s *LocalStore) HasRollbackMarker(releasePath string) bool {
_, err := os.Stat(filepath.Join(releasePath, "rollback"))
return err == nil
}
func (s *LocalStore) ReadMetadata(releasePath string) (model.MetadataFile, []byte, time.Time, error) {
var out model.MetadataFile
metaPath := filepath.Join(releasePath, "metadata.json")
b, err := os.ReadFile(metaPath)
if err != nil {
return out, nil, time.Time{}, err
}
if err := json.Unmarshal(b, &out); err != nil {
return out, nil, time.Time{}, err
}
fi, err := os.Stat(metaPath)
if err != nil {
return out, nil, time.Time{}, err
}
return out, b, fi.ModTime(), nil
}
func (s *LocalStore) ReadExpoConfig(releasePath string) (map[string]any, error) {
filePath := filepath.Join(releasePath, "expoConfig.json")
b, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
var out map[string]any
if err := json.Unmarshal(b, &out); err != nil {
return nil, err
}
return out, nil
}
func (s *LocalStore) OpenAssetSafe(asset string) ([]byte, error) {
clean := filepath.Clean(asset)
if strings.HasPrefix(clean, "..") {
return nil, fmt.Errorf("invalid asset path")
}
return os.ReadFile(clean)
}
func (s *LocalStore) PublishZip(runtimeVersion string, platform string, zipReader io.ReaderAt, size int64) (string, error) {
if platform != "ios" && platform != "android" {
return "", fmt.Errorf("%w: %s", ErrUnsupportedPlatform, platform)
}
runtimeDir := filepath.Join(s.root, runtimeVersion)
if err := os.MkdirAll(runtimeDir, 0o755); err != nil {
return "", err
}
targetDir := filepath.Join(runtimeDir, platform)
tmpDir := targetDir + ".tmp"
_ = os.RemoveAll(tmpDir)
_ = os.RemoveAll(targetDir)
if err := unzipToDir(zipReader, size, tmpDir); err != nil {
return "", err
}
manifestPath := filepath.Join(tmpDir, "metadata.json")
if _, err := os.Stat(manifestPath); err != nil {
return "", fmt.Errorf("published zip must contain metadata.json at root")
}
if err := os.Rename(tmpDir, targetDir); err != nil {
return "", err
}
return targetDir, nil
}
func (s *LocalStore) CreateRollback(runtimeVersion string, platform string) (string, error) {
if platform != "ios" && platform != "android" {
return "", fmt.Errorf("%w: %s", ErrUnsupportedPlatform, platform)
}
releaseDir := filepath.Join(s.root, runtimeVersion, platform)
if _, err := os.Stat(releaseDir); err != nil {
return "", err
}
path := filepath.Join(releaseDir, "rollback")
if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
return "", err
}
return path, nil
}
func (s *LocalStore) ListReleases(runtimeVersion string) ([]model.ReleaseInfo, error) {
runtimeDir := filepath.Join(s.root, runtimeVersion)
runtimeStat, err := os.Stat(runtimeDir)
if err != nil || !runtimeStat.IsDir() {
return nil, ErrUnsupportedRuntime
}
entries, err := os.ReadDir(runtimeDir)
if err != nil {
return nil, err
}
out := make([]model.ReleaseInfo, 0, len(entries))
for _, e := range entries {
if !e.IsDir() {
continue
}
if e.Name() != "ios" && e.Name() != "android" {
continue
}
full := filepath.Join(runtimeDir, e.Name())
fi, err := os.Stat(full)
if err != nil {
continue
}
out = append(out, model.ReleaseInfo{
RuntimeVersion: runtimeVersion,
ReleaseID: e.Name(),
Path: full,
CreatedAt: fi.ModTime(),
})
}
sort.Slice(out, func(i, j int) bool { return out[i].ReleaseID < out[j].ReleaseID })
return out, nil
}
func (s *LocalStore) SaveAPK(runtimeVersion string, reader io.Reader) (string, error) {
apkDir := filepath.Join(s.root, "apk")
if err := os.MkdirAll(apkDir, 0o755); err != nil {
return "", err
}
// 删除所有旧的 APK 文件,只保留最新的一个
entries, err := os.ReadDir(apkDir)
if err != nil && !os.IsNotExist(err) {
return "", err
}
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".apk") {
oldPath := filepath.Join(apkDir, e.Name())
if err := os.Remove(oldPath); err != nil {
// 记录错误但不中断,继续保存新文件
fmt.Printf("Warning: failed to remove old APK %s: %v\n", oldPath, err)
}
}
}
filename := fmt.Sprintf("%s.apk", runtimeVersion)
apkPath := filepath.Join(apkDir, filename)
tmpPath := apkPath + ".tmp"
f, err := os.Create(tmpPath)
if err != nil {
return "", err
}
_, copyErr := io.Copy(f, reader)
syncErr := f.Sync()
closeErr := f.Close()
if copyErr != nil {
os.Remove(tmpPath)
return "", copyErr
}
if syncErr != nil {
os.Remove(tmpPath)
return "", syncErr
}
if closeErr != nil {
os.Remove(tmpPath)
return "", closeErr
}
if err := os.Rename(tmpPath, apkPath); err != nil {
os.Remove(tmpPath)
return "", err
}
return apkPath, nil
}
func (s *LocalStore) GetAPKPath(runtimeVersion string) (string, error) {
filename := fmt.Sprintf("%s.apk", runtimeVersion)
apkPath := filepath.Join(s.root, "apk", filename)
if _, err := os.Stat(apkPath); err != nil {
return "", err
}
return apkPath, nil
}
func (s *LocalStore) ListAPKs() ([]model.APKInfo, error) {
apkDir := filepath.Join(s.root, "apk")
if _, err := os.Stat(apkDir); err != nil {
if os.IsNotExist(err) {
return []model.APKInfo{}, nil
}
return nil, err
}
entries, err := os.ReadDir(apkDir)
if err != nil {
return nil, err
}
out := make([]model.APKInfo, 0, len(entries))
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".apk") {
continue
}
runtimeVersion := strings.TrimSuffix(e.Name(), ".apk")
full := filepath.Join(apkDir, e.Name())
fi, err := os.Stat(full)
if err != nil {
continue
}
out = append(out, model.APKInfo{
RuntimeVersion: runtimeVersion,
Path: full,
Size: fi.Size(),
CreatedAt: fi.ModTime(),
})
}
// 按版本号降序排列,确保最新的在前
sort.Slice(out, func(i, j int) bool { return out[i].RuntimeVersion > out[j].RuntimeVersion })
return out, nil
}
func (s *LocalStore) GetLatestAPK() (*model.APKInfo, error) {
apks, err := s.ListAPKs()
if err != nil {
return nil, err
}
if len(apks) == 0 {
return nil, nil
}
// 由于 SaveAPK 会删除旧文件,理论上只会有一个 APK
// 但为了安全起见,返回版本号最大的那个
return &apks[0], nil
}