416 lines
14 KiB
Go
416 lines
14 KiB
Go
package admin
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"paygateway/internal/logger"
|
||
"paygateway/internal/personal"
|
||
"paygateway/internal/store"
|
||
)
|
||
|
||
// 设备控制台:web 前端可视化操作 adb 触达的安卓设备(USB 真机 / adb over WiFi;
|
||
// 微信 / 支付宝)
|
||
//
|
||
// 能力(按需渲染,非完整云手机):
|
||
// - 截图:查看设备当前屏幕(渲染微信 / 支付宝界面)
|
||
// - 操作:点击 / 滑动 / 输入文本 / 按键(完成 app 登录等)
|
||
// - 启动 app:拉起微信 / 支付宝
|
||
// - 手动预热:导航到账本页并锁定任务(DeviceActor 命令,账本监听专用)
|
||
//
|
||
// 前置条件:设备已配置 adb_host / adb_wifi_host(USB 真机留空 adb_port 即视作 USB 直连)。
|
||
// 本控制台以 adb 为协议基线,USB 直连(纯 serial)与 adb over WiFi(TCP adb)均适用。
|
||
|
||
// deviceSerial 从设备记录构造 adb serial(USB 优先,WiFi 备用)
|
||
// - adb_host 无端口 → USB 直连 serial(如 "4f2bdc30",真机直连)
|
||
// - adb_host 有端口 → TCP 地址(如 "192.168.1.119:5555",adb over WiFi)
|
||
// - adb_wifi_host 配置且 USB 通道不可用 → WiFi 备用
|
||
func (h *PersonalHandler) deviceSerial(d *personal.DeviceEncrypted) (string, error) {
|
||
if d.AdbHost != "" {
|
||
if d.AdbPort > 0 {
|
||
return fmt.Sprintf("%s:%d", d.AdbHost, d.AdbPort), nil
|
||
}
|
||
return d.AdbHost, nil
|
||
}
|
||
if d.AdbWifiHost != "" && d.AdbWifiPort > 0 {
|
||
return fmt.Sprintf("%s:%d", d.AdbWifiHost, d.AdbWifiPort), nil
|
||
}
|
||
return "", fmt.Errorf("设备未配置 adb 连接(adb_host / adb_wifi_host 为空)")
|
||
}
|
||
|
||
// getDeviceWithAdb 按 id 取设备并校验 adb 可用(USB 优先,失败自动切 WiFi 备用)
|
||
func (h *PersonalHandler) getDeviceWithAdb(c *gin.Context) (*personal.DeviceEncrypted, string, bool) {
|
||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
if id <= 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||
return nil, "", false
|
||
}
|
||
if h.adbMgr == nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "设备控制台未启用(adb 管理器未配置)"})
|
||
return nil, "", false
|
||
}
|
||
dev, err := h.pdb.GetDeviceByID(c.Request.Context(), id)
|
||
if err != nil || dev == nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "设备不存在"})
|
||
return nil, "", false
|
||
}
|
||
serial, all, ok := h.pickOnlineSerial(c.Request.Context(), dev)
|
||
if !ok {
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": "设备离线:所有 adb 通道不可用(" + strings.Join(all, " / ") + ")"})
|
||
return nil, "", false
|
||
}
|
||
return dev, serial, true
|
||
}
|
||
|
||
// pickOnlineSerial 取设备所有候选 serial(USB 主通道优先、WiFi 备用次之)并返回首个 online 的。
|
||
func (h *PersonalHandler) pickOnlineSerial(ctx context.Context, dev *personal.DeviceEncrypted) (serial string, allSerials []string, ok bool) {
|
||
all := make([]string, 0, 2)
|
||
if dev.AdbHost != "" {
|
||
if dev.AdbPort > 0 {
|
||
all = append(all, fmt.Sprintf("%s:%d", dev.AdbHost, dev.AdbPort))
|
||
} else {
|
||
all = append(all, dev.AdbHost)
|
||
}
|
||
}
|
||
if dev.AdbWifiHost != "" && dev.AdbWifiPort > 0 {
|
||
all = append(all, fmt.Sprintf("%s:%d", dev.AdbWifiHost, dev.AdbWifiPort))
|
||
}
|
||
if len(all) == 0 {
|
||
return "", all, false
|
||
}
|
||
for _, s := range all {
|
||
if h.adbMgr.Online(ctx, s) {
|
||
return s, all, true
|
||
}
|
||
}
|
||
return "", all, false
|
||
}
|
||
|
||
// handleDeviceScreenshot 设备截图(按需渲染:前端点开设备才拉取)
|
||
// GET /api/admin/personal/devices/:id/screenshot → PNG
|
||
func (h *PersonalHandler) handleDeviceScreenshot(c *gin.Context) {
|
||
dev, serial, ok := h.getDeviceWithAdb(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 20*time.Second)
|
||
defer cancel()
|
||
|
||
img, err := h.adbMgr.Screenshot(ctx, serial)
|
||
if err != nil {
|
||
logger.Warn("admin", "设备截图失败", "device", dev.DeviceID, "serial", serial, "err", err)
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": "设备截图失败: " + err.Error()})
|
||
return
|
||
}
|
||
c.Header("Content-Type", "image/png")
|
||
c.Header("Cache-Control", "no-store")
|
||
c.Data(http.StatusOK, "image/png", img)
|
||
}
|
||
|
||
// handleDeviceAction 设备操作(点击/滑动/输入/按键/启动 app/刷新前台)
|
||
// POST /api/admin/personal/devices/:id/action {"action":"tap|swipe|input|key|start|front", ...}
|
||
func (h *PersonalHandler) handleDeviceAction(c *gin.Context) {
|
||
dev, serial, ok := h.getDeviceWithAdb(c)
|
||
if !ok {
|
||
return
|
||
}
|
||
var req struct {
|
||
Action string `json:"action"`
|
||
X int `json:"x"`
|
||
Y int `json:"y"`
|
||
X2 int `json:"x2"`
|
||
Y2 int `json:"y2"`
|
||
Duration int `json:"duration"` // swipe 毫秒
|
||
Text string `json:"text"` // input 文本
|
||
Keycode int `json:"keycode"` // key 按键
|
||
Package string `json:"package"` // start 包名
|
||
Activity string `json:"activity"` // start activity(可选)
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误: " + err.Error()})
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||
defer cancel()
|
||
|
||
var err error
|
||
switch req.Action {
|
||
case "tap":
|
||
err = h.adbMgr.Tap(ctx, serial, req.X, req.Y)
|
||
case "swipe":
|
||
err = h.adbMgr.Swipe(ctx, serial, req.X, req.Y, req.X2, req.Y2, req.Duration)
|
||
case "input":
|
||
if req.Text == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "input 需要 text"})
|
||
return
|
||
}
|
||
err = h.adbMgr.InputText(ctx, serial, req.Text)
|
||
case "key":
|
||
if req.Keycode == 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "key 需要 keycode(4=返回 3=主页 66=回车 28=删除)"})
|
||
return
|
||
}
|
||
err = h.adbMgr.KeyEvent(ctx, serial, req.Keycode)
|
||
case "start":
|
||
if req.Package == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "start 需要 package"})
|
||
return
|
||
}
|
||
err = h.adbMgr.StartApp(ctx, serial, req.Package, req.Activity)
|
||
case "front":
|
||
var pkg string
|
||
pkg, err = h.adbMgr.ForegroundApp(ctx, serial)
|
||
if err == nil {
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true, "foreground": pkg}})
|
||
return
|
||
}
|
||
case "warmup":
|
||
// 手动预热:向设备 DeviceActor 投递 ManualWarmupCmd(冷启动导航到账本页 + 锁定任务)。
|
||
// provider 参数指定通道:alipay(默认,设备全部支付宝账户主空间+双开)
|
||
// 或 wechat(设备全部微信账户);provider 为空 = 全部通道全部账户。
|
||
// DeviceActor 模型:手动预热是队列中的一个命令,与自动预热/轮询天然互斥。
|
||
// 冷启动导航等待波动(网络/UI 渲染非线性),微信小账本为小程序加载更慢——
|
||
// 每步 3s + 重试,多账户累计可能超 120s,给足 240s 余量。
|
||
ctx, cancel2 := context.WithTimeout(c.Request.Context(), 240*time.Second)
|
||
defer cancel2()
|
||
providers := map[string]bool{}
|
||
switch req.Package {
|
||
case "wechat":
|
||
providers["wechat"] = true
|
||
case "alipay":
|
||
providers["alipay"] = true
|
||
default:
|
||
providers["alipay"] = true
|
||
providers["wechat"] = true
|
||
}
|
||
if h.ledgerWarmup == nil {
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": "账本监听未启用(服务端未配置 adb)"})
|
||
return
|
||
}
|
||
if werr := h.ledgerWarmup(ctx, dev.DeviceID, providers); werr != nil {
|
||
logger.Warn("admin", "手动预热失败", "device", dev.DeviceID, "err", werr)
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": "手动预热失败: " + werr.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{
|
||
"ok": true, "note": "账本页已就绪,任务卡片已锁定",
|
||
}})
|
||
return
|
||
default:
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "未知 action: " + req.Action})
|
||
return
|
||
}
|
||
if err != nil {
|
||
logger.Warn("admin", "设备操作失败", "device", dev.DeviceID, "action", req.Action, "err", err)
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": "设备操作失败: " + err.Error()})
|
||
return
|
||
}
|
||
logger.Info("admin", "设备操作成功", "device", dev.DeviceID, "serial", serial, "action", req.Action)
|
||
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true}})
|
||
}
|
||
|
||
// deviceAlipayUserIDs 解析设备全部支付宝账户的用户空间(0=主空间,999=双开):
|
||
// 遍历设备绑定账户中的 alipay 账户,取其收款码与该账户 QRContent 比对(与
|
||
// cmd/gateway resolveLedgerDevices 的 ledgerUserSpaceOf 同逻辑),去重返回 userID 列表。
|
||
func (h *PersonalHandler) deviceProviderUserIDs(c *gin.Context, dev *personal.DeviceEncrypted, channel string) ([]int, error) {
|
||
ctx := c.Request.Context()
|
||
ids := store.DeviceAccountIDs(ctx, h.pdb, dev.ID, dev.AccountID)
|
||
platform := "alipay"
|
||
if channel == "personal.wechat" {
|
||
platform = "wechat"
|
||
}
|
||
var out []int
|
||
seen := map[int]bool{}
|
||
for _, aid := range ids {
|
||
acct := h.pool.GetByID(aid)
|
||
if acct == nil || acct.Channel != channel {
|
||
continue
|
||
}
|
||
if acct.QRContent == "" {
|
||
return nil, fmt.Errorf("账户 %d 未配置收款码(无法判定用户空间)", aid)
|
||
}
|
||
for _, space := range []int{999, 0} {
|
||
qr, err := h.pdb.GetLatestQrcodeByPlatformUser(ctx, dev.DeviceID, platform, space)
|
||
if err == nil && qr != nil && qr.QRContent == acct.QRContent && !seen[space] {
|
||
seen[space] = true
|
||
out = append(out, space)
|
||
}
|
||
}
|
||
}
|
||
if len(out) == 0 {
|
||
return nil, fmt.Errorf("设备未绑定 %s 账户", platform)
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
// handleRestartDevice 重启设备(设备离线自愈 / 用户手动触发)
|
||
//
|
||
// POST /api/admin/personal/devices/:id/restart
|
||
//
|
||
// 2026-08-30 重构:监听端 APP 弃用,恢复链路收敛为纯 adb 可达性自愈——
|
||
// 轮询 adb.Online 直到任一 serial 可用(每 5s×9 = 45s),45s 内恢复即成功;
|
||
// 始终不可达返回 ok=false + warning。不会直接修改设备 status,
|
||
// 设备恢复后由 DeviceWatcher 按 adb 可达性自动判定上线。
|
||
func (h *PersonalHandler) handleRestartDevice(c *gin.Context) {
|
||
id, _ := strconv.ParseInt(c.Param("id"), 10, 64)
|
||
if id <= 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
if h.adbMgr == nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "adb 管理器未配置,重启功能不可用"})
|
||
return
|
||
}
|
||
dev, err := h.pdb.GetDeviceByID(c.Request.Context(), id)
|
||
if err != nil || dev == nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "设备不存在"})
|
||
return
|
||
}
|
||
|
||
result, rerr := h.Restart(c.Request.Context(), dev)
|
||
if rerr != nil && result == nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": rerr.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"data": result})
|
||
}
|
||
|
||
// AttemptRestart 按 id 重启设备(实现 reconciler.Recoverer 接口)。
|
||
// 用于 DeviceWatcher 自动自愈;限频在内部已处理。
|
||
func (h *PersonalHandler) AttemptRestart(ctx context.Context, id int64) error {
|
||
if h.adbMgr == nil {
|
||
return fmt.Errorf("adb 管理器未配置")
|
||
}
|
||
dev, err := h.pdb.GetDeviceByID(ctx, id)
|
||
if err != nil {
|
||
return fmt.Errorf("查询设备失败: %w", err)
|
||
}
|
||
if dev == nil {
|
||
return fmt.Errorf("设备 %d 不存在", id)
|
||
}
|
||
if _, rerr := h.Restart(ctx, dev); rerr != nil {
|
||
return rerr
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Restart 公开重启入口,供 admin HTTP handler 和 DeviceWatcher 自动自愈复用。
|
||
// 限频由本方法负责(in-memory map + TTL),调用方不必关心防抖。
|
||
// 返回的 map 结构稳定可序列化为 JSON 给 HTTP 响应;err 仅在「无法开始重启流程」时返回,
|
||
// 例:adb 管理器未配置 / serial 候选空(HTTP 侧据此返回 400)。
|
||
//
|
||
// 2026-08-30 重构:监听端 APP 弃用,重启链路收敛为纯 adb 可达性自愈——
|
||
// 轮询 adb.Online 直到任一 serial 可用(每 5s×9 = 45s);45s 内恢复即成功,
|
||
// 始终不可达则返回 ok=false + warning(由调用方/DeviceWatcher 按告警策略处理)。
|
||
func (h *PersonalHandler) Restart(ctx context.Context, dev *personal.DeviceEncrypted) (map[string]any, error) {
|
||
if h.adbMgr == nil {
|
||
return nil, fmt.Errorf("adb 管理器未配置")
|
||
}
|
||
serials := h.deviceSerials(dev)
|
||
if len(serials) == 0 {
|
||
return nil, fmt.Errorf("设备未配置 adb 连接(adb_host / adb_wifi_host 为空)")
|
||
}
|
||
|
||
// 限频:5 分钟内同设备只允许一次重启尝试;recoverer 可由 watcher 频繁触发。
|
||
if !h.restartLimiter.allow(dev.ID, 5*time.Minute) {
|
||
return map[string]any{
|
||
"ok": true,
|
||
"serials": serials,
|
||
"throttled": true,
|
||
"hint": "距上次重启尝试不足 5 分钟,已忽略本次请求",
|
||
}, nil
|
||
}
|
||
|
||
cctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||
defer cancel()
|
||
|
||
resp := map[string]any{
|
||
"ok": true,
|
||
"serials": serials,
|
||
"stages": []map[string]any{},
|
||
"warnings": []string{},
|
||
}
|
||
stages := []map[string]any{}
|
||
warnings := []string{}
|
||
|
||
// 阶段 1:轮询 adb.Online(每 5s/次,共 9 次)
|
||
serial := ""
|
||
var online bool
|
||
var polls int
|
||
for polls = 0; polls < 9; polls++ {
|
||
for _, s := range serials {
|
||
if h.adbMgr.Online(cctx, s) {
|
||
serial = s
|
||
online = true
|
||
break
|
||
}
|
||
}
|
||
if online {
|
||
break
|
||
}
|
||
time.Sleep(5 * time.Second)
|
||
}
|
||
if online {
|
||
resp["online_recovered"] = true
|
||
resp["waited_seconds"] = (polls + 1) * 5
|
||
stages = append(stages, map[string]any{"stage": "adb探测", "online": true, "serial": serial})
|
||
} else {
|
||
resp["ok"] = false
|
||
resp["waited_seconds"] = 45
|
||
warnings = append(warnings, "45s 内 adb 未恢复在线(USB/WiFi 通道均不可达)")
|
||
}
|
||
|
||
resp["stages"] = stages
|
||
if len(warnings) > 0 {
|
||
resp["warnings"] = warnings
|
||
}
|
||
logger.Info("admin", "设备重启指令执行完成", "device", dev.DeviceID, "ok", resp["ok"],
|
||
"online_recovered", resp["online_recovered"], "stages", len(stages))
|
||
return resp, nil
|
||
}
|
||
|
||
// restartLimiter 限频器:每设备 5min/次的重启尝试上限;进程重启即清空,可接受。
|
||
type restartLimiter struct {
|
||
mu sync.Mutex
|
||
last map[int64]time.Time
|
||
}
|
||
|
||
func newRestartLimiter() *restartLimiter {
|
||
return &restartLimiter{last: make(map[int64]time.Time)}
|
||
}
|
||
|
||
// allow 返回 true 表示放行,false 表示被限频。
|
||
func (r *restartLimiter) allow(id int64, window time.Duration) bool {
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
if t, ok := r.last[id]; ok && time.Since(t) < window {
|
||
return false
|
||
}
|
||
r.last[id] = time.Now()
|
||
return true
|
||
}
|
||
|
||
// deviceSerials 返回该设备的所有 adb serial 候选(USB 主通道 + WiFi 备用通道)。
|
||
// 用于本机指令在通道间依次尝试;与 getDeviceWithAdb 一致。
|
||
func (h *PersonalHandler) deviceSerials(d *personal.DeviceEncrypted) []string {
|
||
out := make([]string, 0, 2)
|
||
if d.AdbHost != "" {
|
||
if d.AdbPort > 0 {
|
||
out = append(out, fmt.Sprintf("%s:%d", d.AdbHost, d.AdbPort))
|
||
} else {
|
||
out = append(out, d.AdbHost)
|
||
}
|
||
}
|
||
if d.AdbWifiHost != "" && d.AdbWifiPort > 0 {
|
||
out = append(out, fmt.Sprintf("%s:%d", d.AdbWifiHost, d.AdbWifiPort))
|
||
}
|
||
return out
|
||
}
|