package service import ( "context" "os" "path/filepath" "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{} var AppVersion = &appVersionService{} // GetUpdate 客户端版本更新检查(公开接口,无需登录态;仅 Android 调用): // 返回最新一条版本记录,无记录时返回空结构(客户端视为无需更新)。 func (s *appVersionService) GetUpdate(ctx context.Context, req *dto.AppUpdateReq) (*dto.AppUpdateRes, error) { latest, err := dao.AppVersion.Latest(ctx) if err != nil { return nil, err } if latest == nil { return &dto.AppUpdateRes{}, nil } return &dto.AppUpdateRes{ Version: latest.Version, Notes: latest.Notes, }, 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 下发新版本:先单写者串行落库(查重 + 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) } if filepath.Ext(req.File.Filename) != ".apk" { return nil, gerror.NewCode(common.CodeApkInvalid, "仅支持 .apk 文件") } now := gtime.Now() err := common.Serial().Submit(ctx, func() error { exists, err := dao.AppVersion.GetByVersion(ctx, req.Version) if err != nil { return err } if exists != nil { return gerror.NewCode(common.CodeVersionDuplicate) } return dao.AppVersion.Insert(ctx, &entity.AppVersion{ Version: req.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, req.Version); delErr != nil { g.Log().Errorf(ctx, "APK 保存失败后补偿删除记录失败: %+v", delErr) } return nil, err } return &dto.AdminAppVersionAddRes{}, 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 }