1
This commit is contained in:
+3
-2
@@ -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 转元。
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+1
-1
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
@@ -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
@@ -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(公开,无需 token;iOS 不
|
||||
- **公开接口**:更新检查挂在公开组(无需登录态)——旧版本登录态可能已失效,且登录前的用户也要能收到强制更新
|
||||
- **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);列表/最新查询读走普通读,表极小不设查询缓存
|
||||
|
||||
## 待办/风险
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { UploadFilled } from '@element-plus/icons-vue'
|
||||
import request from '../api/request'
|
||||
|
||||
@@ -66,6 +66,24 @@ function submitAdd() {
|
||||
|
||||
const fileList = ref([])
|
||||
|
||||
function removeRow(row) {
|
||||
ElMessageBox.confirm(
|
||||
`确定删除版本 ${row.version}?删除最新版本会同时移除已下发的 APK(客户端将不再提示该更新)`,
|
||||
'删除版本记录',
|
||||
{
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning',
|
||||
},
|
||||
)
|
||||
.then(() => request.post('/app-versions/delete', { id: row.id }))
|
||||
.then(() => {
|
||||
ElMessage.success('已删除')
|
||||
load()
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -84,6 +102,11 @@ onMounted(load)
|
||||
<el-table-column prop="createdAt" label="下发时间" width="180">
|
||||
<template #default="{ row }">{{ row.createdAt || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="danger" @click="removeRow(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-pagination
|
||||
|
||||
Reference in New Issue
Block a user