1
This commit is contained in:
@@ -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 相同)时,已更新完成的手机重启也不会再次弹更新
|
||||
- 网络异常/响应异常时静默跳过检查,不阻塞启动
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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 转元。
|
||||
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
+17
-17
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-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>
|
||||
|
||||
@@ -5,6 +5,7 @@ package consts
|
||||
const (
|
||||
TablePaymentOrder = "payment_order"
|
||||
TableLicense = "license"
|
||||
TableAppVersion = "app_version"
|
||||
|
||||
// 订单状态机 created → paid(closed 仅超时/失败关闭)
|
||||
OrderStatusCreated = "created"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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-data:version + 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{}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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:"更新时间"`
|
||||
}
|
||||
@@ -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 节点(配置驱动,无数据库表)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package service
|
||||
|
||||
// App 版本管理白盒测试:下发(APK 上传 + 记录)、最新版本查询、重复版本拒绝、
|
||||
// APK 固定文件覆盖(目录永远只有一个文件)。
|
||||
// 运行方式同 admin_test.go:cd 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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# 测试运行时产物(APK 上传目录),不入库
|
||||
workspace/
|
||||
+7
@@ -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"
|
||||
|
||||
BIN
Binary file not shown.
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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.
@@ -1,4 +1,4 @@
|
||||
# 视野后端单机部署:前后端一体单端口,./data 挂载持久化(容器重建不丢授权/订单数据)
|
||||
# 视野后端单机部署:前后端一体单端口,./data(数据库)与 ./workspace(APK 上传)挂载持久化(容器重建不丢数据)
|
||||
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
@@ -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)
|
||||
|
||||
@@ -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-versions(multipart:version + notes + APK 文件)
|
||||
├─► app_version 表新增记录(version UNIQUE 防重复下发)
|
||||
└─► APK 保存为 app.apkDir/observer-latest.apk(上传即覆盖,目录永远只有一个文件)
|
||||
Android 客户端启动 GET /api/v1/app/update(公开,无需 token;iOS 不调用)
|
||||
└─► 返回最新一条记录(无记录返回空对象)
|
||||
└─► 客户端语义化比较 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` 占位
|
||||
|
||||
@@ -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,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: '版本管理' } },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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.0(x.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>
|
||||
Reference in New Issue
Block a user