package service import ( "context" "os" "path/filepath" "regexp" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/net/ghttp" "github.com/gogf/gf/v2/os/gtime" "observer-server/biz/dao" "observer-server/biz/model/dto" "observer-server/biz/model/entity" "observer-server/common" ) // appVersionService App 版本业务:客户端更新检查(取最新)、管理端下发(APK 上传 + 记录)。 // APK 存固定文件名(覆盖式,目录永远只有一个文件),见 common/apk_store.go。 type appVersionService struct{} // apkNameRe APK 文件名规范:observer-x.y.z.apk(版本号从文件名识别) var apkNameRe = regexp.MustCompile(`^observer-(\d+\.\d+\.\d+)\.apk$`) var AppVersion = &appVersionService{} // GetUpdate 客户端版本更新检查(公开接口,无需登录态;仅 Android 调用): // 返回最新一条版本记录 + 模型热更新目录(多模型体系,与 GET /api/v1/models 同构)。 // 无版本记录时返回空结构(客户端视为无需更新);无发布模型时 models 不返回。 func (s *appVersionService) GetUpdate(ctx context.Context, req *dto.AppUpdateReq) (*dto.AppUpdateRes, error) { res := &dto.AppUpdateRes{} latest, err := dao.AppVersion.Latest(ctx) if err != nil { return nil, err } if latest != nil { res.Version = latest.Version res.Notes = latest.Notes } catalog, err := ModelVersion.ClientCatalog(ctx) if err != nil { return nil, err } if len(catalog.List) > 0 { res.Models = catalog.List } return res, nil } // AdminListVersions 管理端版本记录分页(按下发时间倒序) func (s *appVersionService) AdminListVersions(ctx context.Context, req *dto.AdminAppVersionListReq) (*dto.AdminAppVersionListRes, error) { page, size := common.NormalizePage(req.Page, req.Size) list, total, err := dao.AppVersion.Page(ctx, page, size) if err != nil { return nil, err } items := make([]*dto.AdminAppVersionItem, 0, len(list)) for _, v := range list { items = append(items, &dto.AdminAppVersionItem{ Id: v.Id, Version: v.Version, Notes: v.Notes, CreatedAt: v.CreatedAt, }) } return &dto.AdminAppVersionListRes{Total: total, List: items}, nil } // AdminAddVersion 下发新版本:版本号从文件名识别(observer-x.y.z.apk), // 先单写者串行落库(查重 + UNIQUE 兜底),再保存 APK 覆盖固定文件; // 文件保存失败时删除记录补偿,保证「记录存在 ⟺ 文件存在」。 func (s *appVersionService) AdminAddVersion(ctx context.Context, req *dto.AdminAppVersionAddReq) (*dto.AdminAppVersionAddRes, error) { if req.File == nil { return nil, gerror.NewCode(common.CodeApkInvalid) } version, err := parseVersionFromFilename(req.File.Filename) if err != nil { return nil, err } now := gtime.Now() err = common.Serial().Submit(ctx, func() error { exists, err := dao.AppVersion.GetByVersion(ctx, version) if err != nil { return err } if exists != nil { return gerror.NewCode(common.CodeVersionDuplicate) } return dao.AppVersion.Insert(ctx, &entity.AppVersion{ Version: version, Notes: req.Notes, CreatedAt: now, UpdatedAt: now, }) }) if err != nil { return nil, err } if err := saveApk(ctx, req.File); err != nil { if delErr := dao.AppVersion.DeleteByVersion(ctx, version); delErr != nil { g.Log().Errorf(ctx, "APK 保存失败后补偿删除记录失败: %+v", delErr) } return nil, err } return &dto.AdminAppVersionAddRes{}, nil } // parseVersionFromFilename 从 APK 文件名识别版本号:observer-1.0.1.apk → 1.0.1。 // 文件名是上传版本号的唯一事实来源(打包产物即按此命名)。 func parseVersionFromFilename(name string) (string, error) { m := apkNameRe.FindStringSubmatch(filepath.Base(name)) if m == nil { return "", gerror.NewCode(common.CodeApkInvalid, "APK 文件名须为 observer-x.y.z.apk 格式(如 observer-1.0.1.apk)") } return m[1], nil } // AdminDeleteVersion 删除版本记录(Serial 串行,防与下发并发交错): // 删最新版本时联动删除 APK 文件(客户端 update 返回空不再提示、下载 404); // 删历史版本仅删记录、不动文件(固定文件永远对应最新版本)。 // 先删记录再删文件:记录删除是核心操作,文件删除失败仅记日志不阻断 // (记录没了客户端不会误提示更新,残留旧文件无害)。 func (s *appVersionService) AdminDeleteVersion(ctx context.Context, req *dto.AdminAppVersionDeleteReq) (*dto.AdminAppVersionDeleteRes, error) { err := common.Serial().Submit(ctx, func() error { v, err := dao.AppVersion.GetById(ctx, req.Id) if err != nil { return err } if v == nil { return gerror.NewCode(common.CodeVersionNotFound) } latest, err := dao.AppVersion.Latest(ctx) if err != nil { return err } if err := dao.AppVersion.DeleteById(ctx, req.Id); err != nil { return err } if latest != nil && latest.Id == v.Id { if err := os.Remove(common.ApkFilePath(ctx)); err != nil && !os.IsNotExist(err) { g.Log().Errorf(ctx, "删除最新版本 %s 后清理 APK 失败: %+v", v.Version, err) } } return nil }) if err != nil { return nil, err } return &dto.AdminAppVersionDeleteRes{}, nil } // saveApk 保存 APK:先落临时文件再原子重命名覆盖固定文件名, // 保证目录下永远只保留最新一个文件(异常中断不产生半截正式文件)。 func saveApk(ctx context.Context, f *ghttp.UploadFile) error { dir := common.ApkDir(ctx) if err := os.MkdirAll(dir, 0o755); err != nil { return gerror.Wrap(err, "创建 APK 目录失败") } saved, err := f.Save(dir) if err != nil { _ = os.Remove(filepath.Join(dir, filepath.Base(f.Filename))) return gerror.Wrap(err, "APK 保存失败") } if err := os.Rename(filepath.Join(dir, saved), common.ApkFilePath(ctx)); err != nil { _ = os.Remove(filepath.Join(dir, saved)) return gerror.Wrap(err, "APK 文件更新失败") } return nil }