1
This commit is contained in:
+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` 占位
|
||||
|
||||
Reference in New Issue
Block a user