This commit is contained in:
2026-08-25 17:17:06 +08:00
parent e8812a0a73
commit 73ac7b85db
13 changed files with 175 additions and 9 deletions
+3 -2
View File
@@ -13,7 +13,7 @@
| 授权查询 | `GET /api/v1/license`Bearer token)返回授权状态,App 识别入口强制服务端校验 |
| 套餐 | `config.yml` `plans` 节点配置三档套餐(改价 = 改配置重启),价格**整数分**1000 / 5600 / 18000 |
| 后台管理端 | `server_admin/`Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销)、App 版本管理;构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token` |
| 版本管理 | 后台管理端上传 Android APK + 版本号/更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**iOS 走 App Store 自行更新) |
| 版本管理 | 后台管理端上传 Android APK + 版本号/更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**iOS 走 App Store 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 |
## 架构与数据流
@@ -161,7 +161,8 @@ App 版本更新检查(公开接口,无需 token,未登录/旧版本均可
| POST | `/admin/licenses/revoke` | 撤销授权 `{"phoneNum":"13800000000"}`(清授权保留账号) |
| POST | `/admin/licenses/remark` | 写账号备注 `{"phoneNum":"13800000000","remark":"..."}`(空串清空,≤200 字) |
| GET | `/admin/app-versions` | 版本记录列表,`page/size` 分页,按下发时间倒序 |
| POST | `/admin/app-versions` | 下发新版本(multipart/form-data):`version` + `notes` + `file`APK 文件,仅接受 `.apk`);版本号 x.y.z 必填且不可重复,APK 上传覆盖 `app.apkDir`/`observer-latest.apk`(目录永远只有一个文件);检测到新版本即强制更新,记录仅新增不删除 |
| POST | `/admin/app-versions` | 下发新版本(multipart/form-data):`version` + `notes` + `file`APK 文件,仅接受 `.apk`);版本号 x.y.z 必填且不可重复,APK 上传覆盖 `app.apkDir`/`observer-latest.apk`(目录永远只有一个文件);检测到新版本即强制更新 |
| POST | `/admin/app-versions/delete` | 删除版本记录 `{"id":1}`:删**最新版本**时联动删除 APK 文件(客户端不再提示更新、下载 404);删历史版本只删记录不动文件 |
管理页面(订单/授权)由 `server_admin/` 构建产物提供,访问 `http://<host>/admin/`。金额均为整数分,前端展示 ÷100 转元。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>视野管理端</title>
<script type="module" crossorigin src="/admin/assets/index-eisXhko8.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-B1AJjQ8v.css">
<script type="module" crossorigin src="/admin/assets/index-BS5pj_Bg.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-CesX8tXX.css">
</head>
<body>
<div id="app"></div>
+5
View File
@@ -48,3 +48,8 @@ func (c *cAdmin) ListAppVersions(ctx context.Context, req *dto.AdminAppVersionLi
func (c *cAdmin) AddAppVersion(ctx context.Context, req *dto.AdminAppVersionAddReq) (*dto.AdminAppVersionAddRes, error) {
return service.AppVersion.AdminAddVersion(ctx, req)
}
// DeleteAppVersion 删除版本记录
func (c *cAdmin) DeleteAppVersion(ctx context.Context, req *dto.AdminAppVersionDeleteReq) (*dto.AdminAppVersionDeleteRes, error) {
return service.AppVersion.AdminDeleteVersion(ctx, req)
}
+19
View File
@@ -64,6 +64,25 @@ func (d *appVersionDao) DeleteByVersion(ctx context.Context, version string) err
return err
}
// GetById 按主键查询版本记录,不存在返回 nil
func (d *appVersionDao) GetById(ctx context.Context, id int64) (*entity.AppVersion, error) {
var e entity.AppVersion
err := g.DB().Model(consts.TableAppVersion).Ctx(ctx).Where("id", id).Scan(&e)
if err != nil {
if common.IsNoRows(err) {
return nil, nil
}
return nil, err
}
return &e, nil
}
// DeleteById 按主键删除版本记录
func (d *appVersionDao) DeleteById(ctx context.Context, id int64) error {
_, err := g.DB().Model(consts.TableAppVersion).Ctx(ctx).Where("id", id).Delete()
return err
}
// GetByVersion 按版本号查询(新增前查重,返回 nil 表示不存在)
func (d *appVersionDao) GetByVersion(ctx context.Context, version string) (*entity.AppVersion, error) {
var e entity.AppVersion
+8
View File
@@ -124,3 +124,11 @@ type AdminAppVersionAddReq struct {
}
type AdminAppVersionAddRes struct{}
// AdminAppVersionDeleteReq 删除版本记录(删最新版本联动删除 APK 文件,删历史版本仅删记录)
type AdminAppVersionDeleteReq struct {
g.Meta `path:"/app-versions/delete" method:"post" summary:"删除版本" tags:"管理端"`
Id int64 `json:"id" v:"required|min:1" dc:"版本记录 id"`
}
type AdminAppVersionDeleteRes struct{}
+34
View File
@@ -94,6 +94,40 @@ func (s *appVersionService) AdminAddVersion(ctx context.Context, req *dto.AdminA
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 {
+73
View File
@@ -15,6 +15,7 @@ import (
"github.com/gogf/gf/v2/net/ghttp"
"observer-server/biz/dao"
"observer-server/biz/model/dto"
"observer-server/common"
)
@@ -153,3 +154,75 @@ func TestAppVersionList(t *testing.T) {
t.Fatalf("latest = %s, want %s", res.List[0].Version, base+"1")
}
}
// TestAppVersionDeleteLatest 删除最新版本:记录删除 + APK 文件联动删除
func TestAppVersionDeleteLatest(t *testing.T) {
ver := uniqueVersion()
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
Version: ver, File: newApkUpload(t, "v.apk", "apk-v1"),
}); err != nil {
t.Fatalf("add version: %v", err)
}
latest, err := dao.AppVersion.Latest(ctx())
if err != nil || latest == nil {
t.Fatalf("latest = %+v, %v", latest, err)
}
if _, err := AppVersion.AdminDeleteVersion(ctx(), &dto.AdminAppVersionDeleteReq{Id: latest.Id}); err != nil {
t.Fatalf("delete version: %v", err)
}
// 记录已删(测试库有历史残留记录,update 不再返回被删版本即可)
gone, err := dao.AppVersion.GetById(ctx(), latest.Id)
if err != nil || gone != nil {
t.Fatalf("record should be deleted: %+v, %v", gone, err)
}
res, err := AppVersion.GetUpdate(ctx(), &dto.AppUpdateReq{})
if err != nil {
t.Fatalf("get update: %v", err)
}
if res.Version == ver {
t.Fatalf("update still returns deleted version %s", ver)
}
// APK 文件联动删除(下载 404)
if _, err := os.Stat(common.ApkFilePath(ctx())); !os.IsNotExist(err) {
t.Fatalf("apk file should be removed, stat err = %v", err)
}
}
// TestAppVersionDeleteHistoric 删除历史版本:仅删记录,APK 文件保留(仍对应最新版本)
func TestAppVersionDeleteHistoric(t *testing.T) {
ver1 := uniqueVersion()
ver2 := uniqueVersion() + "1"
for _, v := range []string{ver1, ver2} {
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
Version: v, File: newApkUpload(t, "v.apk", "apk"),
}); err != nil {
t.Fatalf("add version %s: %v", v, err)
}
}
old, err := dao.AppVersion.GetByVersion(ctx(), ver1)
if err != nil || old == nil {
t.Fatalf("get by version: %+v, %v", old, err)
}
if _, err := AppVersion.AdminDeleteVersion(ctx(), &dto.AdminAppVersionDeleteReq{Id: old.Id}); err != nil {
t.Fatalf("delete version: %v", err)
}
// 最新记录仍是 ver2
res, err := AppVersion.GetUpdate(ctx(), &dto.AppUpdateReq{})
if err != nil {
t.Fatalf("get update: %v", err)
}
if res.Version != ver2 {
t.Fatalf("update = %+v, want %s", res, ver2)
}
// APK 文件保留
if _, err := os.Stat(common.ApkFilePath(ctx())); err != nil {
t.Fatalf("apk file should remain: %v", err)
}
}
// TestAppVersionDeleteNotFound 删除不存在的版本报错
func TestAppVersionDeleteNotFound(t *testing.T) {
if _, err := AppVersion.AdminDeleteVersion(ctx(), &dto.AdminAppVersionDeleteReq{Id: 999999999}); err == nil {
t.Fatal("delete missing version should fail")
}
}
Binary file not shown.
+1
View File
@@ -13,4 +13,5 @@ var (
CodeCallbackMismatch = gcode.New(1006, "回调商户/金额不匹配", nil)
CodeVersionDuplicate = gcode.New(1007, "该版本号已存在,请勿重复下发", nil)
CodeApkInvalid = gcode.New(1008, "请上传 APK 文件(.apk 后缀)", nil)
CodeVersionNotFound = gcode.New(1009, "版本记录不存在", nil)
)
+4 -2
View File
@@ -166,7 +166,8 @@ CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
| POST | /licenses/grant | 手动授权 `{phoneNum, planId}`:与支付回调同语义(自然日叠加、单写者串行事务),提交后清授权缓存 |
| POST | /licenses/revoke | 撤销授权 `{phoneNum}`:清空 `expires_at` **保留账号行**(不删密码),清缓存,客户端下次查询即 inactive |
| GET | /app-versions | 版本记录列表:分页(size 上限 100),按下发时间倒序 |
| POST | /app-versions | 下发新版本(multipart/form-data`version` + `notes` + `file` APK):`version` 必填 x.y.z 且 UNIQUE 不可重复(重复报错)、`notes` ≤500、仅接受 `.apk` 文件;APK 覆盖保存 `app.apkDir`/`observer-latest.apk`(目录永远只有一个文件);**仅新增不删除**(保留完整下发历史) |
| POST | /app-versions | 下发新版本(multipart/form-data`version` + `notes` + `file` APK):`version` 必填 x.y.z 且 UNIQUE 不可重复(重复报错)、`notes` ≤500、仅接受 `.apk` 文件;APK 覆盖保存 `app.apkDir`/`observer-latest.apk`(目录永远只有一个文件) |
| POST | /app-versions/delete | 删除版本 `{id}`(不存在报错):删**最新版本**时联动删除 APK 文件(客户端 update 返回空不再提示、下载 404);删历史版本仅删记录、不动文件 |
**结构决策**:管理端是跨表业务面,controller 聚合在 `biz/controller/admin.go`(避免同一 controller 挂客户端组 + 管理组时 `group.Bind` 重复注册路由),service 按「跨表业务流程归入所属表文件」归入各表文件(订单列表 → `service/order.go`,授权 grant/revoke/列表 → `service/license.go`),dto 聚合在 `biz/model/dto/admin.go`。套餐已配置化(`common/plans.go` 读取 config.yml,仅客户端登录组 `GET /plans` 使用),无独立分层文件,管理端不管理套餐。
@@ -202,7 +203,8 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 tokeniOS 不
- **公开接口**:更新检查挂在公开组(无需登录态)——旧版本登录态可能已失效,且登录前的用户也要能收到强制更新
- **iOS 不检查**:客户端 `UpdateChecker.fetch()` 在非 Android 平台直接返回空(iOS 用户从 App Store 更新,应用内无法安装 APK,提示无意义)
- **版本号语义化比较**`x.y.z` 三段按数字比较(`1.10.0 > 1.9.9`),禁止字符串比较("1.9.9" > "1.10.0" 会漏判);版本号格式由 DTO `regex` 校验,DB 层 `UNIQUE` 兜底防重复
- **仅新增不删除**:记录为不可变下发历史(防管理端误删后客户端无法感知曾下发的版本);不提供修改接口。历史记录不存下载地址(固定文件路径,`app_version` 表不设 url 列)
- **删除联动文件**:APK 固定文件永远对应「最新一条记录」,故删除只对最新版本联动删文件(`记录存在 ⟺ 文件存在` 的删除方向:记录没了文件即失效,客户端不再提示、下载 404);删历史版本不动文件。执行顺序「先删记录、再删文件」——记录删除是核心操作,文件删除失败仅记日志不阻断(残留旧文件无害,客户端不会误提示更新)。不存在删除/修改接口(下发后版本号不可变),删除按 `id` 定位
- **写入串行**:新增/删除记录均走 `common.Serial()` 单写者(与授权链路一致,SQLite 无 WAL);列表/最新查询读走普通读,表极小不设查询缓存
- **写入串行**:新增记录走 `common.Serial()` 单写者(与授权链路一致,SQLite 无 WAL);列表/最新查询读走普通读,表极小不设查询缓存
## 待办/风险