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
+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` 占位