This commit is contained in:
2026-08-25 16:01:07 +08:00
parent 63b169a033
commit d3ba1c7e48
26 changed files with 1542 additions and 148 deletions
+2 -1
View File
@@ -40,7 +40,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)、`created_at``updated_at` |
| `license` | 手机号账号与授权 | `phone_num`(PK)、`password`(bcrypt)、`expires_at`(未充值 NULL)、`remark`(管理端备注)、`created_at``updated_at` |
建表与迁移见 `技术设计.md`(新库直接建表;存量库以 `PRAGMA user_version` 版本化迁移)。
@@ -139,6 +139,7 @@ Flutter App ── POST /auth/register|login ─► 账号注册/登录,签发
| GET | `/admin/licenses` | 账号/授权列表,筛选 `phoneNum` + `page/size`(含未充值账号) |
| POST | `/admin/licenses/grant` | 手动授权 `{"phoneNum":"13800000000","planId":"day"}`,自然日叠加 |
| POST | `/admin/licenses/revoke` | 撤销授权 `{"phoneNum":"13800000000"}`(清授权保留账号) |
| POST | `/admin/licenses/remark` | 写账号备注 `{"phoneNum":"13800000000","remark":"..."}`(空串清空,≤200 字) |
管理页面(订单/授权)由 `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-gy5ysx_E.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-DsTNKZnX.css">
<script type="module" crossorigin src="/admin/assets/index-B_oC4njo.js"></script>
<link rel="stylesheet" crossorigin href="/admin/assets/index-BcJw2rv4.css">
</head>
<body>
<div id="app"></div>
+5
View File
@@ -33,3 +33,8 @@ func (c *cAdmin) Grant(ctx context.Context, req *dto.AdminGrantReq) (*dto.AdminG
func (c *cAdmin) Revoke(ctx context.Context, req *dto.AdminRevokeReq) (*dto.AdminRevokeRes, error) {
return service.License.AdminRevoke(ctx, req)
}
// Remark 写账号备注
func (c *cAdmin) Remark(ctx context.Context, req *dto.AdminRemarkReq) (*dto.AdminRemarkRes, error) {
return service.License.AdminRemark(ctx, req)
}
+11
View File
@@ -24,12 +24,14 @@ func init() {
phone_num TEXT PRIMARY KEY,
password TEXT NOT NULL,
expires_at TEXT,
remark TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)`)
if err != nil {
panic(err)
}
common.EnsureColumn(ctx, consts.TableLicense, "remark", "remark TEXT")
}
// GetByPhone 按手机号查询(走缓存,键含手机号)
@@ -108,6 +110,15 @@ func (d *licenseDao) ClearAuthInTx(ctx context.Context, tx gdb.TX, phone string)
return err
}
// SetRemarkInTx 事务内写备注(管理端 remark,空串即清空):不覆盖密码/授权字段
func (d *licenseDao) SetRemarkInTx(ctx context.Context, tx gdb.TX, phone, remark string) error {
_, err := g.DB().Model(consts.TableLicense).Ctx(ctx).TX(tx).
Where("phone_num", phone).
Data(gdb.Map{"remark": remark, "updated_at": gtime.Now()}).
Update()
return err
}
// PageByPhone 管理端账号分页查询:phone 模糊筛选,按更新时间倒序;
// 列表不缓存(单点 GetByPhone 仍走缓存,写后清缓存不变)。
func (d *licenseDao) PageByPhone(ctx context.Context, phone string, page, size int) ([]*entity.License, int64, error) {
+11 -1
View File
@@ -48,10 +48,11 @@ type AdminLicenseListReq struct {
Size int `json:"size" v:"integer|min:1|max:100" dc:"每页条数,默认 20"`
}
// AdminLicenseItem 账号条目(未充值时 expiresAt 为 null
// AdminLicenseItem 账号条目(未充值时 expiresAt 为 nullremark 为管理端备注
type AdminLicenseItem struct {
PhoneNum string `json:"phoneNum"`
ExpiresAt *gtime.Time `json:"expiresAt"`
Remark string `json:"remark"`
CreatedAt *gtime.Time `json:"createdAt"`
UpdatedAt *gtime.Time `json:"updatedAt"`
}
@@ -79,3 +80,12 @@ type AdminRevokeReq struct {
}
type AdminRevokeRes struct{}
// AdminRemarkReq 写账号备注(空串即清空;仅管理端可见,客户端接口不含该字段)
type AdminRemarkReq struct {
g.Meta `path:"/licenses/remark" method:"post" summary:"写账号备注" tags:"管理端"`
PhoneNum string `json:"phoneNum" v:"required|length:6,20" dc:"手机号"`
Remark string `json:"remark" v:"length:0,200" dc:"备注内容,空串清空"`
}
type AdminRemarkRes struct{}
+1
View File
@@ -8,6 +8,7 @@ type License struct {
PhoneNum string `json:"phoneNum" orm:"phone_num" description:"手机号账号(主键)"`
Password string `json:"-" orm:"password" description:"bcrypt 加盐哈希"`
ExpiresAt *gtime.Time `json:"expiresAt" orm:"expires_at" description:"到期时间(自然日,服务端时区),未充值 NULL"`
Remark string `json:"remark" orm:"remark" description:"管理端备注(客户端不可见)"`
CreatedAt *gtime.Time `json:"createdAt" orm:"created_at" description:"注册时间"`
UpdatedAt *gtime.Time `json:"updatedAt" orm:"updated_at" description:"更新时间"`
}
+17 -1
View File
@@ -50,7 +50,8 @@ func (s *licenseService) AdminListLicenses(ctx context.Context, req *dto.AdminLi
for _, lic := range list {
items = append(items, &dto.AdminLicenseItem{
PhoneNum: lic.PhoneNum,
ExpiresAt: lic.ExpiresAt, CreatedAt: lic.CreatedAt, UpdatedAt: lic.UpdatedAt,
ExpiresAt: lic.ExpiresAt, Remark: lic.Remark,
CreatedAt: lic.CreatedAt, UpdatedAt: lic.UpdatedAt,
})
}
return &dto.AdminLicenseListRes{Total: total, List: items}, nil
@@ -102,3 +103,18 @@ func (s *licenseService) AdminRevoke(ctx context.Context, req *dto.AdminRevokeRe
common.ClearCache(ctx, "license:"+req.PhoneNum)
return &dto.AdminRevokeRes{}, nil
}
// AdminRemark 写账号备注(管理端运营记录):空串即清空,不覆盖密码/授权;
// 单写者串行事务内更新,提交后清授权缓存(列表不缓存不受影响)。
func (s *licenseService) AdminRemark(ctx context.Context, req *dto.AdminRemarkReq) (*dto.AdminRemarkRes, error) {
err := common.Serial().Submit(ctx, func() error {
return g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
return dao.License.SetRemarkInTx(ctx, tx, req.PhoneNum, req.Remark)
})
})
if err != nil {
return nil, err
}
common.ClearCache(ctx, "license:"+req.PhoneNum)
return &dto.AdminRemarkRes{}, nil
}
+18
View File
@@ -52,6 +52,24 @@ func ClearCache(ctx context.Context, keys ...string) {
}
}
// EnsureColumn 存量库迁移:列缺失时 ALTER TABLE ADD COLUMNSQLite 支持表尾追加)。
// 新库由 CREATE TABLE 直接含列、已迁移库列已存在,均跳过;失败 panic(启动即暴露)。
func EnsureColumn(ctx context.Context, table, column, ddl string) {
res, err := g.DB().GetAll(ctx, "PRAGMA table_info("+table+")")
if err != nil {
panic("检查表结构失败 " + table + ": " + err.Error())
}
for _, r := range res {
if gconv.String(r["name"]) == column {
return
}
}
if _, err := g.DB().Exec(ctx, "ALTER TABLE "+table+" ADD COLUMN "+ddl); err != nil {
panic("迁移加列失败 " + table + "." + column + ": " + err.Error())
}
g.Log().Warningf(ctx, "存量表 %s 已迁移:新增列 %s", table, column)
}
// DropLegacyTableIfHasColumn 存量库迁移:表存在旧版本废弃列(账号体系上线前的 device_id)
// 时 DROP 整表(存量数据作废,用户决策),由 dao init 以新结构重建。
func DropLegacyTableIfHasColumn(ctx context.Context, table, column string) {
+2
View File
@@ -48,6 +48,7 @@ CREATE TABLE IF NOT EXISTS license (
phone_num TEXT PRIMARY KEY, -- 手机号账号,一账号一授权记录,续费更新
password TEXT NOT NULL, -- bcrypt 加盐哈希,不存明文
expires_at TEXT, -- 未充值 NULLNULL/过期即未授权)
remark TEXT, -- 管理端备注(运营发卡/人工记录,客户端不可见)
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -59,6 +60,7 @@ CREATE INDEX IF NOT EXISTS idx_payment_order_phone ON payment_order(phone_num);
- v1 = 建表(各 dao init)+ 种子套餐(v1 已移除)
- 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`,新库直接建表跳过)
## 账号体系(注册/登录)