1
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# observer
|
||||
|
||||
野生动物实时识别 App(Flutter 版)。Android / iOS 一套代码,后端接口与支付见
|
||||
动物实时识别 App(Flutter 版)。Android / iOS 一套代码,后端接口与支付见
|
||||
[`docs/PaymentApi.md`](docs/PaymentApi.md)。
|
||||
|
||||
## iOS 真机部署(iPhone)
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>需要使用相机进行野生动物实时识别</string>
|
||||
<string>需要使用相机进行动物实时识别</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>需要通过本地网络连接服务器进行账号验证和支付</string>
|
||||
<key>UIApplicationSceneManifest</key>
|
||||
|
||||
@@ -67,7 +67,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'野生动物实时识别',
|
||||
'动物实时识别',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
|
||||
@@ -6,7 +6,7 @@ import 'package:provider/provider.dart';
|
||||
import '../auth/session_store.dart';
|
||||
|
||||
/// 使用协议页:首次启动强制展示,同意后记录(secure storage),拒绝则退出应用。
|
||||
/// 用途声明:仅限野生动物观察,禁止近距离打扰与非法使用。
|
||||
/// 用途声明:仅限动物观察,禁止近距离打扰与非法使用。
|
||||
class TermsScreen extends StatefulWidget {
|
||||
const TermsScreen({super.key});
|
||||
|
||||
@@ -28,12 +28,12 @@ class _TermsScreenState extends State<TermsScreen> {
|
||||
感谢您使用「视野」动物观察应用。使用本应用前,请仔细阅读并同意以下条款:
|
||||
|
||||
一、用途限制
|
||||
本应用仅用于野生动物观察、记录与科普学习,严禁用于任何非法用途。
|
||||
本应用仅用于动物观察、记录与科普学习,严禁用于任何非法用途。
|
||||
|
||||
二、禁止行为
|
||||
1. 禁止近距离打扰、追逐、驱赶、恐吓野生动物;
|
||||
2. 禁止投喂、诱捕、猎捕、伤害野生动物;
|
||||
3. 禁止利用本应用从事盗猎、野生动物交易或其他违法犯罪活动;
|
||||
1. 禁止近距离打扰、追逐、驱赶、恐吓动物;
|
||||
2. 禁止投喂、诱捕、猎捕、伤害动物;
|
||||
3. 禁止利用本应用从事盗猎、动物交易或其他违法犯罪活动;
|
||||
4. 禁止进入自然保护区禁区、私人领地等未经许可的区域进行观察。
|
||||
|
||||
三、法律合规
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
name: observer
|
||||
description: "视野 - 野生动物实时识别 (野鸡/生境), YOLOv8 + 充值付费"
|
||||
description: "视野 - 动物实时识别 (野鸡/生境), YOLOv8 + 充值付费"
|
||||
publish_to: 'none'
|
||||
|
||||
version: 1.0.0+1
|
||||
version: 1.0.2+3
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.2
|
||||
|
||||
+2
-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 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 |
|
||||
| 版本管理 | 后台管理端上传 Android APK + 更新说明,APK 存服务器 `app.apkDir`(默认 `./workspace/`,与 `./data` 平级、挂载持久化)**固定文件名 `observer-latest.apk`,上传即覆盖,目录永远只保留最新一个文件**;**版本号从文件名识别**:文件须命名为 `observer-x.y.z.apk`(Flutter 打包产物即此命名,版本号取自 pubspec);客户端启动时 `GET /api/v1/app/update` 检查更新:服务器版本高于本地版本即弹更新提示(不可跳过)。**仅 Android 检查,iOS 不做版本下发**(iOS 走 App Store 自行更新)。版本记录可删除:删最新版本联动删除 APK 文件,删历史版本仅删记录 |
|
||||
|
||||
## 架构与数据流
|
||||
|
||||
@@ -161,7 +161,7 @@ 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):`notes` + `file`(APK 文件,仅接受 `.apk`);**版本号从文件名识别**,文件须命名为 `observer-x.y.z.apk`(如 `observer-1.0.1.apk`),格式不符拒绝;版本号不可重复,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-BS5pj_Bg.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-CesX8tXX.css">
|
||||
<script type="module" crossorigin src="/admin/assets/index-BhTaosMq.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/admin/assets/index-COh41tJN.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -113,14 +113,13 @@ type AdminAppVersionListRes struct {
|
||||
List []*AdminAppVersionItem `json:"list"`
|
||||
}
|
||||
|
||||
// AdminAppVersionAddReq 下发新版本(multipart/form-data:version + notes + APK 文件;
|
||||
// 仅新增不删除,version UNIQUE 防重复下发;APK 覆盖保存固定文件,目录永远只有一个文件;
|
||||
// 检测到新版本即强制更新,无普通/强制之分)
|
||||
// AdminAppVersionAddReq 下发新版本(multipart/form-data:notes + APK 文件;
|
||||
// 版本号从文件名识别,文件须命名为 observer-x.y.z.apk;version UNIQUE 防重复下发;
|
||||
// APK 覆盖保存固定文件,目录永远只有一个文件;检测到新版本即强制更新,无普通/强制之分)
|
||||
type AdminAppVersionAddReq struct {
|
||||
g.Meta `path:"/app-versions" method:"post" summary:"下发新版本" tags:"管理端" mime:"multipart/form-data"`
|
||||
Version string `json:"version" v:"required|regex:^\\d+\\.\\d+\\.\\d+$" dc:"语义化版本号 x.y.z"`
|
||||
Notes string `json:"notes" v:"length:0,500" dc:"更新说明"`
|
||||
File *ghttp.UploadFile `json:"file" dc:"APK 文件"`
|
||||
g.Meta `path:"/app-versions" method:"post" summary:"下发新版本" tags:"管理端" mime:"multipart/form-data"`
|
||||
Notes string `json:"notes" v:"length:0,500" dc:"更新说明"`
|
||||
File *ghttp.UploadFile `json:"file" dc:"APK 文件(文件名须为 observer-x.y.z.apk)"`
|
||||
}
|
||||
|
||||
type AdminAppVersionAddRes struct{}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
|
||||
"github.com/gogf/gf/v2/errors/gerror"
|
||||
"github.com/gogf/gf/v2/frame/g"
|
||||
@@ -20,6 +21,9 @@ import (
|
||||
// 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 调用):
|
||||
@@ -57,18 +61,20 @@ func (s *appVersionService) AdminListVersions(ctx context.Context, req *dto.Admi
|
||||
return &dto.AdminAppVersionListRes{Total: total, List: items}, nil
|
||||
}
|
||||
|
||||
// AdminAddVersion 下发新版本:先单写者串行落库(查重 + UNIQUE 兜底),再保存 APK
|
||||
// 覆盖固定文件;文件保存失败时删除记录补偿,保证「记录存在 ⟺ 文件存在」。
|
||||
// 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)
|
||||
}
|
||||
if filepath.Ext(req.File.Filename) != ".apk" {
|
||||
return nil, gerror.NewCode(common.CodeApkInvalid, "仅支持 .apk 文件")
|
||||
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, req.Version)
|
||||
err = common.Serial().Submit(ctx, func() error {
|
||||
exists, err := dao.AppVersion.GetByVersion(ctx, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -76,7 +82,7 @@ func (s *appVersionService) AdminAddVersion(ctx context.Context, req *dto.AdminA
|
||||
return gerror.NewCode(common.CodeVersionDuplicate)
|
||||
}
|
||||
return dao.AppVersion.Insert(ctx, &entity.AppVersion{
|
||||
Version: req.Version,
|
||||
Version: version,
|
||||
Notes: req.Notes,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -86,7 +92,7 @@ func (s *appVersionService) AdminAddVersion(ctx context.Context, req *dto.AdminA
|
||||
return nil, err
|
||||
}
|
||||
if err := saveApk(ctx, req.File); err != nil {
|
||||
if delErr := dao.AppVersion.DeleteByVersion(ctx, req.Version); delErr != nil {
|
||||
if delErr := dao.AppVersion.DeleteByVersion(ctx, version); delErr != nil {
|
||||
g.Log().Errorf(ctx, "APK 保存失败后补偿删除记录失败: %+v", delErr)
|
||||
}
|
||||
return nil, err
|
||||
@@ -94,6 +100,17 @@ func (s *appVersionService) AdminAddVersion(ctx context.Context, req *dto.AdminA
|
||||
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);
|
||||
// 删历史版本仅删记录、不动文件(固定文件永远对应最新版本)。
|
||||
|
||||
@@ -65,7 +65,7 @@ func countApk(t *testing.T) int {
|
||||
func TestAppVersionUpdate(t *testing.T) {
|
||||
ver := uniqueVersion()
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
|
||||
Version: ver, Notes: "修复识别准确率", File: newApkUpload(t, "v1.apk", "apk-v1"),
|
||||
Notes: "修复识别准确率", File: newApkUpload(t, "observer-"+ver+".apk", "apk-v1"),
|
||||
}); err != nil {
|
||||
t.Fatalf("add version: %v", err)
|
||||
}
|
||||
@@ -90,8 +90,8 @@ func TestAppVersionUpdate(t *testing.T) {
|
||||
func TestAppVersionOverwrite(t *testing.T) {
|
||||
ver1 := uniqueVersion()
|
||||
ver2 := uniqueVersion() + "1"
|
||||
req1 := &dto.AdminAppVersionAddReq{Version: ver1, File: newApkUpload(t, "a.apk", "apk-old")}
|
||||
req2 := &dto.AdminAppVersionAddReq{Version: ver2, File: newApkUpload(t, "b.apk", "apk-new")}
|
||||
req1 := &dto.AdminAppVersionAddReq{File: newApkUpload(t, "observer-"+ver1+".apk", "apk-old")}
|
||||
req2 := &dto.AdminAppVersionAddReq{File: newApkUpload(t, "observer-"+ver2+".apk", "apk-new")}
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), req1); err != nil {
|
||||
t.Fatalf("add v1: %v", err)
|
||||
}
|
||||
@@ -107,23 +107,24 @@ func TestAppVersionOverwrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppVersionInvalidFile 无文件 / 非 .apk 文件拒绝
|
||||
// TestAppVersionInvalidFile 无文件 / 文件名不含版本号(格式不符)拒绝
|
||||
func TestAppVersionInvalidFile(t *testing.T) {
|
||||
ver := uniqueVersion()
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{Version: ver}); err == nil {
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{}); err == nil {
|
||||
t.Fatal("missing file should fail")
|
||||
}
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
|
||||
Version: ver, File: newApkUpload(t, "app.txt", "not an apk"),
|
||||
}); err == nil {
|
||||
t.Fatal("non-apk file should fail")
|
||||
for _, name := range []string{"app.txt", "myapp-1.0.apk", "observer-1.0.apk", "observer-abc.apk"} {
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
|
||||
File: newApkUpload(t, name, "not an apk"),
|
||||
}); err == nil {
|
||||
t.Fatalf("file %s should fail (version not parseable)", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestAppVersionDuplicate 同版本号重复下发报错
|
||||
func TestAppVersionDuplicate(t *testing.T) {
|
||||
ver := uniqueVersion()
|
||||
req := &dto.AdminAppVersionAddReq{Version: ver, File: newApkUpload(t, "v.apk", "apk")}
|
||||
req := &dto.AdminAppVersionAddReq{File: newApkUpload(t, "observer-"+ver+".apk", "apk")}
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), req); err != nil {
|
||||
t.Fatalf("add version: %v", err)
|
||||
}
|
||||
@@ -137,7 +138,7 @@ func TestAppVersionList(t *testing.T) {
|
||||
base := uniqueVersion()
|
||||
for _, v := range []string{base, base + "1"} {
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
|
||||
Version: v, File: newApkUpload(t, "v.apk", "apk"),
|
||||
File: newApkUpload(t, "observer-"+v+".apk", "apk"),
|
||||
}); err != nil {
|
||||
t.Fatalf("add version %s: %v", v, err)
|
||||
}
|
||||
@@ -159,7 +160,7 @@ func TestAppVersionList(t *testing.T) {
|
||||
func TestAppVersionDeleteLatest(t *testing.T) {
|
||||
ver := uniqueVersion()
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
|
||||
Version: ver, File: newApkUpload(t, "v.apk", "apk-v1"),
|
||||
File: newApkUpload(t, "observer-"+ver+".apk", "apk-v1"),
|
||||
}); err != nil {
|
||||
t.Fatalf("add version: %v", err)
|
||||
}
|
||||
@@ -194,7 +195,7 @@ func TestAppVersionDeleteHistoric(t *testing.T) {
|
||||
ver2 := uniqueVersion() + "1"
|
||||
for _, v := range []string{ver1, ver2} {
|
||||
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
|
||||
Version: v, File: newApkUpload(t, "v.apk", "apk"),
|
||||
File: newApkUpload(t, "observer-"+v+".apk", "apk"),
|
||||
}); err != nil {
|
||||
t.Fatalf("add version %s: %v", v, err)
|
||||
}
|
||||
|
||||
BIN
Binary file not shown.
+5
-4
@@ -166,7 +166,7 @@ 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:`notes` + `file` APK):**版本号从文件名识别**,文件名须为 `observer-x.y.z.apk`(如 `observer-1.0.1.apk`,正则 `^observer-(\d+\.\d+\.\d+)\.apk$`,格式不符拒绝)、`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` 使用),无独立分层文件,管理端不管理套餐。
|
||||
@@ -182,8 +182,8 @@ CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
|
||||
**数据流**:
|
||||
|
||||
```
|
||||
管理端 POST /admin/app-versions(multipart:version + notes + APK 文件)
|
||||
├─► app_version 表新增记录(version UNIQUE 防重复下发)
|
||||
管理端 POST /admin/app-versions(multipart:notes + APK 文件,文件名 observer-x.y.z.apk 识别版本号)
|
||||
├─► app_version 表新增记录(version 从文件名解析,UNIQUE 防重复下发)
|
||||
└─► APK 保存为 app.apkDir/observer-latest.apk(上传即覆盖,目录永远只有一个文件)
|
||||
Android 客户端启动 GET /api/v1/app/update(公开,无需 token;iOS 不调用)
|
||||
└─► 返回最新一条记录(无记录返回空对象)
|
||||
@@ -202,7 +202,8 @@ Android 客户端启动 GET /api/v1/app/update(公开,无需 token;iOS 不
|
||||
- **检测到新版本即强制更新**(无普通/强制之分):`version > 本地版本` 即全屏阻塞弹窗,用户必须跳转下载安装才能继续使用。简化管理端操作(不需要判断「这次要不要强制」),客户端语义单一(「有新版本 = 必须更新」)
|
||||
- **公开接口**:更新检查挂在公开组(无需登录态)——旧版本登录态可能已失效,且登录前的用户也要能收到强制更新
|
||||
- **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` 兜底防重复
|
||||
- **版本号语义化比较**:`x.y.z` 三段按数字比较(`1.10.0 > 1.9.9`),禁止字符串比较("1.9.9" > "1.10.0" 会漏判);版本号格式由文件名解析正则校验(`observer-x.y.z.apk`),DB 层 `UNIQUE` 兜底防重复
|
||||
- **文件名识别版本号**:管理端上传不再手工填写版本号,版本号从文件名解析(打包产物即 `observer-<versionName>.apk` 命名)——消除「填的版本号与包内版本不一致」的人为错误,上传文件名即唯一事实来源
|
||||
- **删除联动文件**:APK 固定文件永远对应「最新一条记录」,故删除只对最新版本联动删文件(`记录存在 ⟺ 文件存在` 的删除方向:记录没了文件即失效,客户端不再提示、下载 404);删历史版本不动文件。执行顺序「先删记录、再删文件」——记录删除是核心操作,文件删除失败仅记日志不阻断(残留旧文件无害,客户端不会误提示更新)。不存在删除/修改接口(下发后版本号不可变),删除按 `id` 定位
|
||||
- **写入串行**:新增/删除记录均走 `common.Serial()` 单写者(与授权链路一致,SQLite 无 WAL);列表/最新查询读走普通读,表极小不设查询缓存
|
||||
- **写入串行**:新增记录走 `common.Serial()` 单写者(与授权链路一致,SQLite 无 WAL);列表/最新查询读走普通读,表极小不设查询缓存
|
||||
|
||||
@@ -13,7 +13,7 @@ const page = ref(1)
|
||||
const size = ref(20)
|
||||
|
||||
const addVisible = ref(false)
|
||||
const addForm = reactive({ version: '', notes: '', file: null })
|
||||
const addForm = reactive({ notes: '', file: null })
|
||||
const adding = ref(false)
|
||||
|
||||
function load() {
|
||||
@@ -30,7 +30,6 @@ function load() {
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
addForm.version = ''
|
||||
addForm.notes = ''
|
||||
addForm.file = null
|
||||
fileList.value = []
|
||||
@@ -48,13 +47,12 @@ function submitAdd() {
|
||||
}
|
||||
adding.value = true
|
||||
const fd = new FormData()
|
||||
fd.append('version', addForm.version.trim())
|
||||
fd.append('notes', addForm.notes.trim())
|
||||
fd.append('file', addForm.file)
|
||||
request
|
||||
.post('/app-versions', fd, { timeout: 300000 })
|
||||
.then(() => {
|
||||
ElMessage.success(`已下发版本 ${addForm.version},覆盖服务器最新 APK`)
|
||||
ElMessage.success('已下发,覆盖服务器最新 APK')
|
||||
addVisible.value = false
|
||||
page.value = 1
|
||||
load()
|
||||
@@ -90,7 +88,7 @@ onMounted(load)
|
||||
<template>
|
||||
<el-card shadow="never">
|
||||
<div class="toolbar">
|
||||
<span class="tip">仅 Android:检测到服务器版本高于手机已装版本即强制更新(不可跳过)。上传的 APK 覆盖保存为固定文件,服务器永远只保留最新一个;下载地址 {{ downloadUrl }}。</span>
|
||||
<span class="tip">仅 Android:检测到服务器版本高于手机已装版本即强制更新(不可跳过)。APK 文件名须为 observer-x.y.z.apk(如 observer-1.0.1.apk),版本号从文件名识别;上传覆盖保存为固定文件,服务器永远只保留最新一个;下载地址 {{ downloadUrl }}。</span>
|
||||
<el-button type="primary" @click="openAdd">下发新版本</el-button>
|
||||
</div>
|
||||
|
||||
@@ -121,9 +119,6 @@ onMounted(load)
|
||||
|
||||
<el-dialog v-model="addVisible" title="下发新版本(Android)" width="480px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="版本号">
|
||||
<el-input v-model="addForm.version" placeholder="如 1.1.0(x.y.z,语义化比较,不可重复)" />
|
||||
</el-form-item>
|
||||
<el-form-item label="更新说明">
|
||||
<el-input
|
||||
v-model="addForm.notes"
|
||||
@@ -148,6 +143,7 @@ onMounted(load)
|
||||
<div class="upload-hint">
|
||||
<el-icon class="upload-icon"><UploadFilled /></el-icon>
|
||||
<div>拖拽或点击选择 .apk 文件</div>
|
||||
<div class="upload-sub">文件名须为 observer-x.y.z.apk(如 observer-1.0.1.apk),版本号从文件名识别</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
</el-form-item>
|
||||
@@ -184,4 +180,8 @@ onMounted(load)
|
||||
color: #c0c4cc;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.upload-sub {
|
||||
font-size: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user