- 标注:AI 预标注直写 labels_json(去候选确认两阶段);重叠去重(minIoU);全量标注按钮 - 训练:脚本迁移入 server/training/(Go 化 prepare_yolo/analyze_rfdetr,保留 train_server.py);tflite 产物自检并入训练流程(check_tflite) - 数据目录/权重不进 git;.gitignore 迁移至仓库根
173 lines
5.9 KiB
Go
173 lines
5.9 KiB
Go
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
|
||
}
|