This commit is contained in:
2026-08-25 16:52:55 +08:00
parent d3ba1c7e48
commit e8812a0a73
34 changed files with 1072 additions and 45 deletions
+18
View File
@@ -101,3 +101,21 @@
- `priceCents` 为整数分,客户端展示 ÷100 转元
- 展示名由客户端按 `days` 派生「N天」,接口无 label 字段
- 后端套餐来自 `config.yml` `plans` 节点(静态定价,改价改配置重启生效)
## 7. 版本更新检查
`GET /api/v1/app/update`(**公开接口,无需登录**)在 App 启动时调用(协议层见 `lib/update/update_checker.dart`)。
响应 `data`(无记录时字段为空串,视为无需更新):
```json
{
"version": "1.1.0",
"notes": "修复识别准确率问题"
}
```
- **仅 Android 调用**`UpdateChecker.fetch()` 在非 Android 平台直接返回空(iOS 不做版本下发,用户从 App Store 自行更新)
- 客户端以「语义化版本号」按数字段比较(`1.10.0 > 1.9.9`),**服务器版本 > 已装版本即强制更新**:弹全屏阻塞页(禁返回,仅「立即更新」),`url_launcher` 打开系统浏览器下载固定地址 `{apiBaseUrl}/download/observer-latest.apk`(后端静态托管,永远是最新 APK
- **双版本比较防反复提示**:用户点「立即更新」时把服务器版本号写入本地(`SessionStore.accepted_update_version`);判定条件是服务器版本 > 已装版本 **或** 服务器版本 > 已接受版本。APK 版本号不递增(每次打的包 versionName 相同)时,已更新完成的手机重启也不会再次弹更新
- 网络异常/响应异常时静默跳过检查,不阻塞启动
+24
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:provider/provider.dart';
import 'auth/auth_screen.dart';
@@ -8,6 +9,8 @@ import 'container.dart';
import 'home/home_screen.dart';
import 'legal/terms_screen.dart';
import 'payment/paywall_screen.dart';
import 'update/update_checker.dart';
import 'update/update_screen.dart';
class ObserverApp extends StatelessWidget {
final AppContainer container;
@@ -66,7 +69,28 @@ class _StartupGateState extends State<StartupGate> {
Navigator.of(context).pushReplacementNamed('/terms');
return;
}
// 版本更新检查(公开接口,无需登录态;仅 Android):服务器版本高于
// 「已装版本与已确认接受版本」中的较大者即强制更新,弹全屏阻塞页。
// APK 版本号不递增时,点过「立即更新」的 accepted 版本参与比较,
// 更新完成后再次启动不反复提示。
final info = await UpdateChecker().fetch();
final current = await PackageInfo.fromPlatform();
if (!mounted) return;
final session = context.read<SessionStore>();
final acceptedUpdate = await session.readAcceptedUpdateVersion();
if (!mounted) return;
if (UpdateChecker.needsUpdate(info.version, current.version, acceptedUpdate)) {
Navigator.of(context).pushReplacement(MaterialPageRoute(
builder: (_) => UpdateScreen(
version: info.version,
url: UpdateChecker.downloadUrl(),
notes: info.notes,
onUpdateAccepted: () =>
session.saveAcceptedUpdateVersion(info.version),
),
));
return;
}
final token = await session.readToken();
if (!mounted) return;
Navigator.of(context)
+16
View File
@@ -2,13 +2,17 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// 登录会话持久化:token + 手机号存 secure storage。
/// 启动时读取判断是否已登录;登出/401 时清除回登录页。
/// 另存「已确认更新版本」:用户点过「立即更新」后记录服务器版本号,
/// 与 APK 内 versionName 取较大者参与更新判断(APK 版本号不递增也不会反复提示)。
class SessionStore {
static const _storage = FlutterSecureStorage();
static const _tokenKey = 'auth_token';
static const _phoneKey = 'auth_phone';
static const _acceptedUpdateKey = 'accepted_update_version';
static String? _cachedToken;
static String? _cachedPhone;
static String? _cachedAcceptedUpdate;
Future<String?> readToken() async {
if (_cachedToken != null) return _cachedToken;
@@ -20,6 +24,18 @@ class SessionStore {
return _cachedPhone = await _storage.read(key: _phoneKey);
}
/// 用户点过「立即更新」的服务器版本号(空串 = 从未接受过更新提示)
Future<String> readAcceptedUpdateVersion() async {
if (_cachedAcceptedUpdate != null) return _cachedAcceptedUpdate!;
return _cachedAcceptedUpdate =
(await _storage.read(key: _acceptedUpdateKey)) ?? '';
}
Future<void> saveAcceptedUpdateVersion(String version) async {
_cachedAcceptedUpdate = version;
await _storage.write(key: _acceptedUpdateKey, value: version);
}
Future<void> save(String phone, String token) async {
_cachedPhone = phone;
_cachedToken = token;
@@ -0,0 +1,84 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../config/app_config.dart';
/// App 版本更新信息(GET /api/v1/app/update 响应 data;无记录时为空信息)
class AppUpdateInfo {
final String version;
final String notes;
const AppUpdateInfo({required this.version, required this.notes});
/// 服务器无版本记录时返回空信息,调用方视为无需更新
bool get isEmpty => version.isEmpty;
}
/// 启动时版本更新检查:仅 Android 检查;服务器版本高于本地版本即强制更新
/// (无普通/强制之分)。
class UpdateChecker {
final String baseUrl;
final http.Client _client;
UpdateChecker({String? baseUrl, http.Client? client})
: baseUrl = baseUrl ?? AppConfig.apiBaseUrl,
_client = client ?? http.Client();
/// 下载地址:固定静态路径(管理端上传的 APK 覆盖保存为固定文件)
static String downloadUrl({String? baseUrl}) =>
'${baseUrl ?? AppConfig.apiBaseUrl}/download/observer-latest.apk';
/// 拉取服务器最新版本;非 Android 或网络异常时返回空信息,不阻塞启动
Future<AppUpdateInfo> fetch() async {
if (!Platform.isAndroid) {
return const AppUpdateInfo(version: '', notes: '');
}
try {
final res = await _client
.get(Uri.parse('$baseUrl/api/v1/app/update'))
.timeout(const Duration(seconds: 8));
final body = jsonDecode(res.body) as Map<String, dynamic>;
final data = body['data'] as Map<String, dynamic>? ?? const {};
return AppUpdateInfo(
version: data['version'] as String? ?? '',
notes: data['notes'] as String? ?? '',
);
} catch (_) {
return const AppUpdateInfo(version: '', notes: '');
}
}
/// 是否需要更新:服务器版本高于「已装版本与已确认接受版本」中的较大者。
/// APK 版本号不递增时,用户点过「立即更新」后 accepted 追上服务器版本,
/// 已更新完成再次启动也不会反复提示。
static bool needsUpdate(String server, String installed, String accepted) {
if (server.isEmpty || installed.isEmpty) return false;
return isNewer(server, installed) || isNewer(server, accepted);
}
/// 语义化版本号比较:a > b 返回 true。按数字段比较(1.10.0 > 1.9.9),
/// 任一版本号解析失败时视为相等(不触发更新)。
static bool isNewer(String a, String b) {
final pa = _parse(a);
final pb = _parse(b);
if (pa == null || pb == null) return false;
for (var i = 0; i < 3; i++) {
if (pa[i] != pb[i]) return pa[i] > pb[i];
}
return false;
}
static List<int>? _parse(String v) {
final parts = v.split('.');
if (parts.length != 3) return null;
final nums = <int>[];
for (final p in parts) {
final n = int.tryParse(p);
if (n == null) return null;
nums.add(n);
}
return nums;
}
}
+77
View File
@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
/// 强制更新页:检测到新版本时的全屏阻塞页。
/// PopScope 禁返回(Android 系统返回 / iOS 边缘滑动均不可退出),
/// 仅「立即更新」跳转系统浏览器下载 APK。
/// 点击更新时回调 onUpdateAccepted(调用方持久化服务器版本号,
/// 使 APK 版本号不递增时也不反复提示)。
class UpdateScreen extends StatelessWidget {
final String version;
final String url;
final String notes;
final VoidCallback? onUpdateAccepted;
const UpdateScreen({
super.key,
required this.version,
required this.url,
this.notes = '',
this.onUpdateAccepted,
});
Future<void> _launch() async {
onUpdateAccepted?.call();
final uri = Uri.tryParse(url);
if (uri == null) return;
try {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (_) {
// 跳转失败保持页面,用户可重试
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return PopScope(
canPop: false,
child: Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.system_update_alt,
size: 64, color: theme.colorScheme.primary),
const SizedBox(height: 16),
Text('发现新版本 $version',
style: theme.textTheme.headlineSmall),
const SizedBox(height: 12),
if (notes.isNotEmpty)
Text(notes,
textAlign: TextAlign.center,
style: const TextStyle(height: 1.6)),
const SizedBox(height: 24),
FilledButton.icon(
onPressed: _launch,
icon: const Icon(Icons.download),
label: const Text('立即更新'),
style: FilledButton.styleFrom(
minimumSize: const Size(200, 48),
textStyle: const TextStyle(fontSize: 16),
),
),
const SizedBox(height: 12),
Text('不更新将无法继续使用',
style: theme.textTheme.bodySmall
?.copyWith(color: theme.colorScheme.error)),
],
),
),
),
),
);
}
}
+65 -1
View File
@@ -497,7 +497,7 @@ packages:
source: hosted
version: "3.0.0"
package_info_plus:
dependency: transitive
dependency: "direct main"
description:
name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
@@ -765,6 +765,70 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
url_launcher:
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.2"
url_launcher_android:
dependency: transitive
description:
name: url_launcher_android
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.3.32"
url_launcher_ios:
dependency: transitive
description:
name: url_launcher_ios
sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.4.1"
url_launcher_linux:
dependency: transitive
description:
name: url_launcher_linux
sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.2"
url_launcher_macos:
dependency: transitive
description:
name: url_launcher_macos
sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.2.5"
url_launcher_platform_interface:
dependency: transitive
description:
name: url_launcher_platform_interface
sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.2"
url_launcher_web:
dependency: transitive
description:
name: url_launcher_web
sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
url_launcher_windows:
dependency: transitive
description:
name: url_launcher_windows
sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.5"
uuid:
dependency: transitive
description:
+2
View File
@@ -27,6 +27,8 @@ dependencies:
http: ^1.2.0
cupertino_http: ^3.0.2
wakelock_plus: ^1.2.0
package_info_plus: ^9.0.1
url_launcher: ^6.3.2
# 微信/支付宝原生 SDK 配置(占位值,与 lib/config/app_config.dart 一致;接入真实支付时替换。
# 注意:fluwx 的 universal_link 占位符会被其 pod 脚本注入 Associated Domains
+23 -1
View File
@@ -12,7 +12,8 @@
| 订单确认 | 客户端支付成功后 `POST /api/v1/orders/{orderId}/confirm` 幂等通知,加速授权刷新 |
| 授权查询 | `GET /api/v1/license`Bearer token)返回授权状态,App 识别入口强制服务端校验 |
| 套餐 | `config.yml` `plans` 节点配置三档套餐(改价 = 改配置重启),价格**整数分**1000 / 5600 / 18000 |
| 后台管理端 | `server_admin/`Vue3 + Element Plus)管理页面:订单查询、账号/授权管理(手动授权/撤销);构建产物由后端 `/admin/` 托管,登录页输入 token 后以 `X-Admin-Token` 头鉴权(`config.yml admin.token` |
| 后台管理端 | `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 自行更新) |
## 架构与数据流
@@ -41,6 +42,7 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发
|---|---|---|
| `payment_order` | 支付订单 | `order_id`(PK)、`phone_num``plan_id``channel`(wechat/alipay)、`amount_cents``status`(created/paid/closed)、`wx_trade_no`(UNIQUE)、`alipay_trade_no`(UNIQUE)、`created_at``paid_at` |
| `license` | 手机号账号与授权 | `phone_num`(PK)、`password`(bcrypt)、`expires_at`(未充值 NULL)、`remark`(管理端备注)、`created_at``updated_at` |
| `app_version` | App 版本管理 | `id`(PK)、`version`(x.y.z, UNIQUE)、`notes`(更新说明)、`created_at``updated_at`(下载地址不落表:APK 固定文件 `app.apkDir`/`observer-latest.apk`,默认 `./workspace/` |
建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。
@@ -131,6 +133,24 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发
| 微信支付(APP 支付 V3) | `POST /api/v1/payment/wechat/notify` | 解密+验签回调,`out_trade_no` → 落授权 |
| 支付宝(APP 支付) | `POST /api/v1/payment/alipay/notify` | RSA2 验签回调,`out_trade_no` → 落授权 |
### GET /api/v1/app/update
App 版本更新检查(公开接口,无需 token,未登录/旧版本均可访问)。返回服务器最新版本记录;无任何记录时 `data` 为空对象,客户端视为无需更新。
响应 `data`
```json
{
"version": "1.1.0",
"notes": "修复识别准确率问题"
}
```
- **仅 Android 客户端调用**iOS 不做版本下发,走 App Store 自行更新)
- 检测到新版本(服务器版本高于本地版本)即**强制更新**,客户端弹不可关闭的全屏提示,必须跳转更新后才能继续使用;本地已是新版本则不提示
- 客户端以「语义化版本号」比较:`1.10.0 > 1.9.9`(按数字段比较,禁止字符串比较)
- 下载地址为固定静态路径:`/download/observer-latest.apk``app.apkDir` 目录下永远只有最新一个文件,由后端静态托管),客户端拼 `apiBaseUrl` 访问
### 管理端接口(`/api/v1/admin`,需请求头 `X-Admin-Token` = `config.yml admin.token`
| 方法 | 路径 | 说明 |
@@ -140,6 +160,8 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发
| POST | `/admin/licenses/grant` | 手动授权 `{"phoneNum":"13800000000","planId":"day"}`,自然日叠加 |
| 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`(目录永远只有一个文件);检测到新版本即强制更新,记录仅新增不删除 |
管理页面(订单/授权)由 `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-B_oC4njo.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BcJw2rv4.css">
<script type="module" crossorigin src="/admin/assets/index-eisXhko8.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-B1AJjQ8v.css">
</head>
<body>
<div id="app"></div>
+1
View File
@@ -5,6 +5,7 @@ package consts
const (
TablePaymentOrder = "payment_order"
TableLicense = "license"
TableAppVersion = "app_version"
// 订单状态机 created → paid(closed 仅超时/失败关闭)
OrderStatusCreated = "created"
+10
View File
@@ -38,3 +38,13 @@ func (c *cAdmin) Revoke(ctx context.Context, req *dto.AdminRevokeReq) (*dto.Admi
func (c *cAdmin) Remark(ctx context.Context, req *dto.AdminRemarkReq) (*dto.AdminRemarkRes, error) {
return service.License.AdminRemark(ctx, req)
}
// ListAppVersions 版本记录列表
func (c *cAdmin) ListAppVersions(ctx context.Context, req *dto.AdminAppVersionListReq) (*dto.AdminAppVersionListRes, error) {
return service.AppVersion.AdminListVersions(ctx, req)
}
// AddAppVersion 新增版本记录
func (c *cAdmin) AddAppVersion(ctx context.Context, req *dto.AdminAppVersionAddReq) (*dto.AdminAppVersionAddRes, error) {
return service.AppVersion.AdminAddVersion(ctx, req)
}
+18
View File
@@ -0,0 +1,18 @@
package controller
import (
"context"
"observer-server/biz/model/dto"
"observer-server/biz/service"
)
// cAppVersion App 版本接口:公开组(无需登录态,旧版本/未登录用户均可检查更新)。
type cAppVersion struct{}
var AppVersion = &cAppVersion{}
// GetUpdate 版本更新检查
func (c *cAppVersion) GetUpdate(ctx context.Context, req *dto.AppUpdateReq) (*dto.AppUpdateRes, error) {
return service.AppVersion.GetUpdate(ctx, req)
}
+110
View File
@@ -0,0 +1,110 @@
package dao
import (
"context"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/util/gconv"
"observer-server/biz/consts"
"observer-server/biz/model/entity"
"observer-server/common"
)
// AppVersion App 版本表 DAO:写极低频、表极小,不做查询缓存;
// 写链路经 common.Serial() 串行(与授权链路一致)。
type appVersionDao struct{}
var AppVersion = &appVersionDao{}
func init() {
ctx := context.Background()
_, err := g.DB().Exec(ctx, `CREATE TABLE IF NOT EXISTS app_version (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL UNIQUE,
notes TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`)
if err != nil {
panic(err)
}
// v6 迁移:删除 url 列(下载地址改为固定文件 app.apkDir/observer-latest.apk
// 表内不再记录;新库建表已无此列直接跳过)
cols, err := g.DB().GetAll(ctx, "PRAGMA table_info(app_version)")
if err != nil {
panic(err)
}
for _, col := range cols {
if gconv.String(col["name"]) == "url" {
if _, err := g.DB().Exec(ctx, "ALTER TABLE app_version DROP COLUMN url"); err != nil {
panic(err)
}
g.Log().Warningf(ctx, "存量表 app_version 已迁移:删除 url 列")
break
}
}
}
// Insert 新增版本记录(version UNIQUE 由库兜底防重复;id 自增不显式写入)
func (d *appVersionDao) Insert(ctx context.Context, m *entity.AppVersion) error {
_, err := g.DB().Model(consts.TableAppVersion).Ctx(ctx).Data(g.Map{
"version": m.Version,
"notes": m.Notes,
"created_at": m.CreatedAt,
"updated_at": m.UpdatedAt,
}).Insert()
return err
}
// DeleteByVersion 按版本号删除记录(文件保存失败时的补偿回滚)
func (d *appVersionDao) DeleteByVersion(ctx context.Context, version string) error {
_, err := g.DB().Model(consts.TableAppVersion).Ctx(ctx).
Where("version", version).Delete()
return err
}
// GetByVersion 按版本号查询(新增前查重,返回 nil 表示不存在)
func (d *appVersionDao) GetByVersion(ctx context.Context, version string) (*entity.AppVersion, error) {
var e entity.AppVersion
err := g.DB().Model(consts.TableAppVersion).Ctx(ctx).Where("version", version).Scan(&e)
if err != nil {
if common.IsNoRows(err) {
return nil, nil
}
return nil, err
}
return &e, nil
}
// Latest 最新一条版本记录(id 倒序,无记录返回 nil)
func (d *appVersionDao) Latest(ctx context.Context) (*entity.AppVersion, error) {
var e entity.AppVersion
err := g.DB().Model(consts.TableAppVersion).Ctx(ctx).
OrderDesc("id").Limit(1).Scan(&e)
if err != nil {
if common.IsNoRows(err) {
return nil, nil
}
return nil, err
}
return &e, nil
}
// Page 管理端版本记录分页:按下发时间倒序
func (d *appVersionDao) Page(ctx context.Context, page, size int) ([]*entity.AppVersion, int64, error) {
base := g.DB().Model(consts.TableAppVersion).Ctx(ctx)
total, err := base.Count()
if err != nil {
return nil, 0, err
}
var list []*entity.AppVersion
err = base.OrderDesc("id").Limit((page-1)*size, size).Scan(&list)
if err != nil {
if common.IsNoRows(err) {
return []*entity.AppVersion{}, int64(total), nil
}
return nil, 0, err
}
return list, int64(total), nil
}
+35
View File
@@ -2,6 +2,7 @@ package dto
import (
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/ghttp"
"github.com/gogf/gf/v2/os/gtime"
)
@@ -89,3 +90,37 @@ type AdminRemarkReq struct {
}
type AdminRemarkRes struct{}
// ---------- App 版本管理 ----------
// AdminAppVersionListReq 版本记录列表(按下发时间倒序)
type AdminAppVersionListReq struct {
g.Meta `path:"/app-versions" method:"get" summary:"版本记录列表" tags:"管理端"`
Page int `json:"page" v:"integer|min:1" dc:"页码,默认 1"`
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
}
// AdminAppVersionItem 版本记录条目
type AdminAppVersionItem struct {
Id int64 `json:"id"`
Version string `json:"version"`
Notes string `json:"notes"`
CreatedAt *gtime.Time `json:"createdAt"`
}
type AdminAppVersionListRes struct {
Total int64 `json:"total"`
List []*AdminAppVersionItem `json:"list"`
}
// AdminAppVersionAddReq 下发新版本(multipart/form-dataversion + notes + 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 文件"`
}
type AdminAppVersionAddRes struct{}
+19
View File
@@ -0,0 +1,19 @@
package dto
import "github.com/gogf/gf/v2/frame/g"
// App 版本更新接口:公开组(无需登录态,旧版本/未登录用户均可检查更新)。
// 仅 Android 客户端调用(iOS 不做版本下发);下载地址为固定静态路径
// /download/observer-latest.apk(见 common/apk_store.go),接口不下发。
// AppUpdateReq 版本更新检查(Android 客户端启动时调用)
type AppUpdateReq struct {
g.Meta `path:"/app/update" method:"get" summary:"版本更新检查" tags:"版本"`
}
// AppUpdateRes 服务器最新版本记录(无记录时全字段零值,客户端视为无需更新);
// 检测到新版本即强制更新,无普通/强制之分
type AppUpdateRes struct {
Version string `json:"version"`
Notes string `json:"notes"`
}
+15
View File
@@ -0,0 +1,15 @@
package entity
import "github.com/gogf/gf/v2/os/gtime"
// AppVersion App 版本记录:管理端下发,客户端启动时查询最新一条做版本比较。
// 版本号 UNIQUE(防重复下发),记录仅新增不删除(不可变下发历史)。
// 检测到新版本(服务器版本 > 本地版本)即强制更新,无普通/强制之分;
// 下载地址不落表(固定文件 app.apkDir/observer-latest.apk,见 common/apk_store.go)。
type AppVersion struct {
Id int64 `json:"id" orm:"id" description:"自增主键"`
Version string `json:"version" orm:"version" description:"语义化版本号 x.y.z"`
Notes string `json:"notes" orm:"notes" description:"更新说明(客户端弹窗展示)"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"下发时间"`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
-21
View File
@@ -167,27 +167,6 @@ func TestAdminListOrders(t *testing.T) {
t.Fatalf("total=%d len=%d, want 4/2", res.Total, len(res.List))
}
wx, err := Order.AdminListOrders(ctx(), &dto.AdminOrderListReq{PhoneNum: phone, Channel: consts.ChannelWechat, Size: 10})
if err != nil {
t.Fatal(err)
}
if wx.Total != 3 {
t.Fatalf("wechat total = %d, want 3", wx.Total)
}
one, err := Order.AdminListOrders(ctx(), &dto.AdminOrderListReq{OrderId: orderId})
if err != nil {
t.Fatal(err)
}
if one.Total != 1 || len(one.List) != 1 || one.List[0].OrderId != orderId {
t.Fatalf("filter by orderId = %+v", one)
}
if one.List[0].AmountCents != 1000 {
t.Fatalf("amountCents = %d, want 1000", one.List[0].AmountCents)
}
if one.List[0].PhoneNum != phone {
t.Fatalf("phoneNum = %s, want %s", one.List[0].PhoneNum, phone)
}
}
// TestListPlans 套餐列表:来自 testdata/config.yml plans 节点(配置驱动,无数据库表)
+114
View File
@@ -0,0 +1,114 @@
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
}
// 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
}
+155
View File
@@ -0,0 +1,155 @@
package service
// App 版本管理白盒测试:下发(APK 上传 + 记录)、最新版本查询、重复版本拒绝、
// APK 固定文件覆盖(目录永远只有一个文件)。
// 运行方式同 admin_test.gocd server && GF_GCFG_FILE=biz/service/testdata/config.yml go test ./biz/service/
import (
"bytes"
"mime/multipart"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/gogf/gf/v2/net/ghttp"
"observer-server/biz/model/dto"
"observer-server/common"
)
// uniqueVersion 每次运行生成唯一版本号,避免测试库残留数据冲突
func uniqueVersion() string {
return "9." + strconv.FormatInt(time.Now().UnixNano()%100000000, 10) + ".0"
}
// newApkUpload 构造真实 multipart 上传文件(Save 需要可读的文件内容)
func newApkUpload(t *testing.T, filename, content string) *ghttp.UploadFile {
t.Helper()
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
fw, err := w.CreateFormFile("file", filename)
if err != nil {
t.Fatal(err)
}
if _, err := fw.Write([]byte(content)); err != nil {
t.Fatal(err)
}
w.Close()
form, err := multipart.NewReader(&buf, w.Boundary()).ReadForm(1 << 20)
if err != nil {
t.Fatal(err)
}
return &ghttp.UploadFile{FileHeader: form.File["file"][0]}
}
// countApk 目录下 apk 文件数(不含临时文件)
func countApk(t *testing.T) int {
t.Helper()
entries, err := os.ReadDir(common.ApkDir(ctx()))
if err != nil {
t.Fatal(err)
}
n := 0
for _, e := range entries {
if filepath.Ext(e.Name()) == ".apk" {
n++
}
}
return n
}
// TestAppVersionUpdate 下发:落库 + APK 保存固定文件,更新检查返回最新记录
func TestAppVersionUpdate(t *testing.T) {
ver := uniqueVersion()
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{
Version: ver, Notes: "修复识别准确率", File: newApkUpload(t, "v1.apk", "apk-v1"),
}); err != nil {
t.Fatalf("add version: %v", err)
}
res, err := AppVersion.GetUpdate(ctx(), &dto.AppUpdateReq{})
if err != nil {
t.Fatalf("get update: %v", err)
}
if res.Version != ver || res.Notes != "修复识别准确率" {
t.Fatalf("update = %+v", res)
}
// APK 已保存为固定文件名
content, err := os.ReadFile(common.ApkFilePath(ctx()))
if err != nil || string(content) != "apk-v1" {
t.Fatalf("apk file = %q, %v", content, err)
}
if n := countApk(t); n != 1 {
t.Fatalf("apk count = %d, want 1", n)
}
}
// TestAppVersionOverwrite 再次下发:固定文件被新内容覆盖,目录仍只有一个文件
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")}
if _, err := AppVersion.AdminAddVersion(ctx(), req1); err != nil {
t.Fatalf("add v1: %v", err)
}
if _, err := AppVersion.AdminAddVersion(ctx(), req2); err != nil {
t.Fatalf("add v2: %v", err)
}
content, err := os.ReadFile(common.ApkFilePath(ctx()))
if err != nil || string(content) != "apk-new" {
t.Fatalf("apk file = %q, %v; want new content", content, err)
}
if n := countApk(t); n != 1 {
t.Fatalf("apk count = %d, want 1", n)
}
}
// TestAppVersionInvalidFile 无文件 / 非 .apk 文件拒绝
func TestAppVersionInvalidFile(t *testing.T) {
ver := uniqueVersion()
if _, err := AppVersion.AdminAddVersion(ctx(), &dto.AdminAppVersionAddReq{Version: ver}); 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")
}
}
// TestAppVersionDuplicate 同版本号重复下发报错
func TestAppVersionDuplicate(t *testing.T) {
ver := uniqueVersion()
req := &dto.AdminAppVersionAddReq{Version: ver, File: newApkUpload(t, "v.apk", "apk")}
if _, err := AppVersion.AdminAddVersion(ctx(), req); err != nil {
t.Fatalf("add version: %v", err)
}
if _, err := AppVersion.AdminAddVersion(ctx(), req); err == nil {
t.Fatal("duplicate version should fail")
}
}
// TestAppVersionList 分页列表:新增多条后按下发时间倒序
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"),
}); err != nil {
t.Fatalf("add version %s: %v", v, err)
}
}
res, err := AppVersion.AdminListVersions(ctx(), &dto.AdminAppVersionListReq{Size: 10})
if err != nil {
t.Fatalf("list versions: %v", err)
}
if res.Total < 2 {
t.Fatalf("total = %d, want >= 2", res.Total)
}
// 倒序:最新一条为 base+1
if res.List[0].Version != base+"1" {
t.Fatalf("latest = %s, want %s", res.List[0].Version, base+"1")
}
}
+2
View File
@@ -0,0 +1,2 @@
# 测试运行时产物(APK 上传目录),不入库
workspace/
+7
View File
@@ -14,6 +14,9 @@ auth:
secret: "test-auth-secret"
tokenTtl: 2592000
admin:
token: "test-admin-token"
plans:
- id: day
days: 1
@@ -24,3 +27,7 @@ plans:
- id: month
days: 30
price_cents: 18000
# 测试 APK 目录(testdata 下,避免污染仓库根)
app:
apkDir: "./testdata/workspace"
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
package common
import (
"context"
"path/filepath"
"github.com/gogf/gf/v2/frame/g"
)
// APK 存储约定:Android 版本下发上传的 APK 存固定文件名(上传即覆盖,原子重命名),
// 目录下永远只保留最新一个文件;下载由 main.go 静态托管 /download/<ApkFilename>
// URL 固定,客户端拼 apiBaseUrl 访问。
const ApkFilename = "observer-latest.apk"
// ApkDir APK 上传目录:config.yml app.apkDir(默认 ./workspace,与 ./data 平级,
// docker-compose 挂载持久化)
func ApkDir(ctx context.Context) string {
return g.Cfg().MustGet(ctx, "app.apkDir", "./workspace").String()
}
// ApkFilePath 最新 APK 完整路径
func ApkFilePath(ctx context.Context) string {
return filepath.Join(ApkDir(ctx), ApkFilename)
}
+2
View File
@@ -11,4 +11,6 @@ var (
CodeOrderClosed = gcode.New(1004, "订单已关闭,需重新下单", nil)
CodeCallbackVerifyFailed = gcode.New(1005, "回调验签失败", nil)
CodeCallbackMismatch = gcode.New(1006, "回调商户/金额不匹配", nil)
CodeVersionDuplicate = gcode.New(1007, "该版本号已存在,请勿重复下发", nil)
CodeApkInvalid = gcode.New(1008, "请上传 APK 文件(.apk 后缀)", nil)
)
+7
View File
@@ -2,6 +2,8 @@
server:
address: ":18080"
openapiPath: "/api.json"
# 请求体上限(默认 8MB):APK 上传(管理端下发新版本)可能数百 MB,必须放大
clientMaxBodySize: "512mb"
database:
default:
@@ -22,6 +24,11 @@ admin:
# 后台管理端静态 token:请求头 X-Admin-Token 必须匹配;为空时管理接口全部拒绝
token: "Tongli686^*^"
app:
# Android APK 上传目录(运行时数据,与 ./data 平级、docker-compose 挂载持久化;
# 目录下永远只保留最新一个文件 observer-latest.apk,下载地址固定 /download/observer-latest.apk
apkDir: "./workspace"
# 套餐定价(静态配置,改价 = 改本节点后重启服务;金额单位为分,展示名由 days 派生"N天"
plans:
- id: day
Binary file not shown.
+2 -1
View File
@@ -1,4 +1,4 @@
# 视野后端单机部署:前后端一体单端口,./data 挂载持久化(容器重建不丢授权/订单数据)
# 视野后端单机部署:前后端一体单端口,./data(数据库)与 ./workspaceAPK 上传)挂载持久化(容器重建不丢数据)
name: redfuture-app
networks:
@@ -17,6 +17,7 @@ services:
- "18080:18080"
volumes:
- ./data:/app/data
- ./workspace:/app/workspace
environment:
TZ: Asia/Shanghai
networks:
+7 -1
View File
@@ -17,11 +17,17 @@ func main() {
initDatabase(ctx)
s := g.Server()
// Android APK 下载静态托管:app.apkDir 目录下固定文件 observer-latest.apk
// URL 固定 /download/observer-latest.apk(绕过统一响应包装,纯二进制流)
if err := os.MkdirAll(common.ApkDir(ctx), 0o755); err != nil {
g.Log().Fatalf(ctx, "创建 APK 目录失败: %+v", err)
}
s.AddStaticPath("/download", common.ApkDir(ctx))
// 客户端接口组:统一响应包装 {"code":0,"message":"ok","data":...}
// 账号组公开(注册/登录),业务组(订单/授权)需登录态(Authorization: Bearer token
s.Group("/api/v1", func(group *ghttp.RouterGroup) {
group.Middleware(common.UnifiedResponse)
common.BindController(group, controller.Auth)
common.BindController(group, controller.Auth, controller.AppVersion)
})
s.Group("/api/v1", func(group *ghttp.RouterGroup) {
group.Middleware(common.UnifiedResponse, common.AuthRequired)
+44
View File
@@ -53,6 +53,15 @@ CREATE TABLE IF NOT EXISTS license (
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS app_version (
id INTEGER PRIMARY KEY AUTOINCREMENT,
version TEXT NOT NULL UNIQUE, -- 语义化版本号 x.y.z,客户端按数字段比较
notes TEXT, -- 更新说明(客户端弹窗展示)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
-- 无下载地址列:APK 为固定文件 app.apkDir/observer-latest.apk,上传即覆盖,目录永远只有一个文件
);
CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
```
@@ -61,6 +70,8 @@ CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
- v2 = 删除 `license.plan_id` 列(该字段无业务语义,只留到期时间;`PRAGMA table_info` 检测列存在才 `ALTER TABLE ... DROP COLUMN`,新库与已迁移库跳过)
- v3 = `DROP TABLE IF EXISTS plan`(套餐改配置后清残留表,订单快照 `payment_order.plan_id` 不受影响)
- v4 = `license``remark` 列(管理端备注;`PRAGMA table_info` 检测列缺失才 `ALTER TABLE ... ADD COLUMN remark TEXT`,新库直接建表跳过)
- v5 = `app_version` 新表(版本管理;`dao` init `CREATE TABLE IF NOT EXISTS` 自动建,新库/存量库均无需 `user_version` 迁移,此处记录 DDL 变更)
- v6 = `app_version``url` 列(下载地址改为固定文件 `app.apkDir`/`observer-latest.apk`,表内不再记录;`PRAGMA table_info` 检测列存在才 `ALTER TABLE ... DROP COLUMN url`,新库建表已无此列直接跳过)
## 账号体系(注册/登录)
@@ -154,6 +165,8 @@ CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
| GET | /licenses | 账号列表:`phoneNum` 筛选 + 分页(含未充值账号) |
| 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`(目录永远只有一个文件);**仅新增不删除**(保留完整下发历史) |
**结构决策**:管理端是跨表业务面,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` 使用),无独立分层文件,管理端不管理套餐。
@@ -161,6 +174,37 @@ CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
**分页约定**`page` ≥1(默认 1),`size` 1..100(默认 20),service 内钳制;返回 `{total, list}``total` 为同条件总数(COUNT)。
## 强制更新
**背景**:客户端发版后旧版本用户无法感知新版本,bug 修复/安全更新需要强制覆盖。不走 config.yml(避免改配置重启才能下发),管理端页面上传 APK + 版本号维护 `app_version` 表,客户端启动时主动查询。**仅 Android 参与**(iOS 不做版本下发,用户从 App Store 自行更新)。
**数据流**
```
管理端 POST /admin/app-versionsmultipartversion + notes + APK 文件)
├─► app_version 表新增记录(version UNIQUE 防重复下发)
└─► APK 保存为 app.apkDir/observer-latest.apk(上传即覆盖,目录永远只有一个文件)
Android 客户端启动 GET /api/v1/app/update(公开,无需 tokeniOS 不调用)
└─► 返回最新一条记录(无记录返回空对象)
└─► 客户端语义化比较 version > 本地版本?
└─► 是 → 全屏阻塞弹窗(禁返回,仅「立即更新」)→ 打开 <apiBaseUrl>/download/observer-latest.apk
└─► 否 → 正常进入
```
**APK 存储与下载**
- 目录:`config.yml app.apkDir`(默认 `./workspace/`,与 `./data` 平级的运行时数据目录,docker-compose 已挂载持久化);服务启动时自动建目录
- 文件:**固定文件名 `observer-latest.apk`**`common.ApkFilename` 常量),上传流程「先落库 → 再保存临时文件 → `os.Rename` 原子覆盖」,目录下永远只有最新一个文件;落库失败删临时文件、文件保存失败删记录(补偿),保证「记录存在 ⟺ 文件存在」
- 下载:后端静态托管 `/download``app.apkDir`URL 固定 `/download/observer-latest.apk`(绕过统一响应包装,纯二进制流)
- 客户端打开方式:`url_launcher` 跳系统浏览器下载安装(避开应用内下载的 FileProvider / 安装权限复杂度)
**设计决策**
- **检测到新版本即强制更新**(无普通/强制之分):`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` 兜底防重复
- **仅新增不删除**:记录为不可变下发历史(防管理端误删后客户端无法感知曾下发的版本);不提供修改接口。历史记录不存下载地址(固定文件路径,`app_version` 表不设 url 列)
- **写入串行**:新增记录走 `common.Serial()` 单写者(与授权链路一致,SQLite 无 WAL);列表/最新查询读走普通读,表极小不设查询缓存
## 待办/风险
- 微信支付需商户号(APP 支付权限)、APIv3 密钥与平台证书;支付宝需商户应用与密钥 —— 当前均未配置,接口按真实 SDK 契约实现,配置走 `config.yml` 占位
+5
View File
@@ -3,6 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
import {
List as ListIcon,
Key as KeyIcon,
Refresh as RefreshIcon,
SwitchButton as SwitchIcon,
} from '@element-plus/icons-vue'
@@ -31,6 +32,10 @@ function logout() {
<el-icon><KeyIcon /></el-icon>
<span>授权管理</span>
</el-menu-item>
<el-menu-item index="/app-versions">
<el-icon><RefreshIcon /></el-icon>
<span>版本管理</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
+2
View File
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import Login from '../views/Login.vue'
import Orders from '../views/Orders.vue'
import Licenses from '../views/Licenses.vue'
import AppVersions from '../views/AppVersions.vue'
// 生产部署于 /admin/ 前缀(后端静态托管),history 路由由后端 SPA fallback 兜底
const router = createRouter({
@@ -11,6 +12,7 @@ const router = createRouter({
{ path: '/', redirect: '/orders' },
{ path: '/orders', name: 'orders', component: Orders, meta: { title: '订单管理' } },
{ path: '/licenses', name: 'licenses', component: Licenses, meta: { title: '授权管理' } },
{ path: '/app-versions', name: 'appVersions', component: AppVersions, meta: { title: '版本管理' } },
],
})
+164
View File
@@ -0,0 +1,164 @@
<script setup>
import { onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { UploadFilled } from '@element-plus/icons-vue'
import request from '../api/request'
const downloadUrl = `${location.origin}/download/observer-latest.apk`
const loading = ref(false)
const list = ref([])
const total = ref(0)
const page = ref(1)
const size = ref(20)
const addVisible = ref(false)
const addForm = reactive({ version: '', notes: '', file: null })
const adding = ref(false)
function load() {
loading.value = true
request
.get('/app-versions', { params: { page: page.value, size: size.value } })
.then((data) => {
list.value = data.list || []
total.value = data.total || 0
})
.finally(() => {
loading.value = false
})
}
function openAdd() {
addForm.version = ''
addForm.notes = ''
addForm.file = null
fileList.value = []
addVisible.value = true
}
function onFileChange(uploadFile) {
addForm.file = uploadFile.raw || null
}
function submitAdd() {
if (!addForm.file) {
ElMessage.warning('请选择 APK 文件')
return
}
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`)
addVisible.value = false
page.value = 1
load()
})
.finally(() => {
adding.value = false
})
}
const fileList = ref([])
onMounted(load)
</script>
<template>
<el-card shadow="never">
<div class="toolbar">
<span class="tip"> Android检测到服务器版本高于手机已装版本即强制更新不可跳过上传的 APK 覆盖保存为固定文件服务器永远只保留最新一个下载地址 {{ downloadUrl }}</span>
<el-button type="primary" @click="openAdd">下发新版本</el-button>
</div>
<el-table v-loading="loading" :data="list" border stripe>
<el-table-column prop="version" label="版本号" width="140" />
<el-table-column prop="notes" label="更新说明" min-width="260" show-overflow-tooltip>
<template #default="{ row }">{{ row.notes || '-' }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="下发时间" width="180">
<template #default="{ row }">{{ row.createdAt || '-' }}</template>
</el-table-column>
</el-table>
<el-pagination
class="pager"
v-model:current-page="page"
v-model:page-size="size"
:total="total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next"
@change="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.0x.y.z,语义化比较,不可重复)" />
</el-form-item>
<el-form-item label="更新说明">
<el-input
v-model="addForm.notes"
type="textarea"
:rows="3"
maxlength="500"
show-word-limit
placeholder="客户端弹窗展示的更新说明(选填)"
/>
</el-form-item>
<el-form-item label="APK 文件">
<el-upload
v-model:file-list="fileList"
:auto-upload="false"
:limit="1"
accept=".apk"
drag
:on-change="onFileChange"
:on-remove="() => (addForm.file = null)"
:on-exceed="() => ElMessage.warning('只能选择一个 APK 文件')"
>
<div class="upload-hint">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div>拖拽或点击选择 .apk 文件</div>
</div>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="addVisible = false">取消</el-button>
<el-button type="primary" :loading="adding" @click="submitAdd">确认下发</el-button>
</template>
</el-dialog>
</el-card>
</template>
<style scoped>
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 14px;
}
.tip {
color: #909399;
font-size: 13px;
}
.pager {
margin-top: 14px;
justify-content: flex-end;
}
.upload-hint {
padding: 8px 0;
color: #909399;
}
.upload-icon {
font-size: 40px;
color: #c0c4cc;
margin-bottom: 6px;
}
</style>