Files

378 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 引导向导(USB 真机集成自动化)
//
// 目标:把「插 USB → 开 USB 调试 → 绑设备」全流程从手动操作变成 web 前端逐步引导。
// 2026-08-30 重构:监听端 APP 弃用,向导收敛为「绑定设备即完成」;
// 设备上线/账户解冻由 DeviceWatcher 按 adb 可达性驱动(不再依赖心跳/完成度门槛)。
//
// 三步接口(全部在 authMiddleware 组内):
// - GET /personal/usb-detect 前端轮询 USB 设备插入状态
// - POST /personal/devices/wizard-start 绑定设备(幂等,断点续传)
// - POST /personal/devices/:id/wizard-wifi 配置 WiFi 备用通道(USB 断线自动切换)
// - POST /personal/devices/:id/wizard-verify 验证 adb 连通 + 返回保活引导(wizard 收尾)
//
// 完成度推进:
//
// wizard-start → setup_progress=["usb_connected","bound"]
package admin
import (
"context"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"paygateway/internal/account"
"paygateway/internal/logger"
"paygateway/internal/personal"
)
// handleUSBCheck 列出当前已连 USB 真机中「未绑定本平台」的设备。
// GET /api/admin/personal/usb-detect
//
// 前端 2-3s 轮询,识别新插入的设备(state 从 unauthorized → device 表示手机端已授权)。
// 响应:{"data":{"devices":[{"serial":"4f2bdc30","state":"device","model":"MI 8","bound":false},...]}}
func (h *PersonalHandler) handleUSBCheck(c *gin.Context) {
if h.adbMgr == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "adb 管理器未配置,USB 检测不可用"})
return
}
devs, err := h.adbMgr.ListDevices(c.Request.Context())
if err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "设备检测失败: " + err.Error()})
return
}
// 已绑定 serial 集合(匹配 adb_host,USB 纯 serial 与 WiFi host:port 皆算)
bound := h.boundSerials(c.Request.Context())
out := make([]gin.H, 0, len(devs))
for _, d := range devs {
_, isBound := bound[d.Serial]
out = append(out, gin.H{
"serial": d.Serial, "state": d.State, "model": d.Model, "kind": d.Kind, "bound": isBound,
})
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{"devices": out}})
}
// boundSerials 返回当前已绑定设备的 adb serial 集合(累加 USB 主通道 + WiFi 备用通道)
func (h *PersonalHandler) boundSerials(ctx context.Context) map[string]int64 {
out := make(map[string]int64)
devs, err := h.pdb.ListDevices(ctx)
if err != nil {
return out
}
for _, d := range devs {
if d.AdbHost != "" {
out[d.AdbHost] = d.ID
}
if d.AdbWifiHost != "" && d.AdbWifiPort > 0 {
out[fmt.Sprintf("%s:%d", d.AdbWifiHost, d.AdbWifiPort)] = d.ID
}
}
return out
}
func containsColon(s string) bool {
for i := 0; i < len(s); i++ {
if s[i] == ':' {
return true
}
}
return false
}
// wizardStartReq wizard-start 请求体
type wizardStartReq struct {
Serial string `json:"serial"` // USB serial(必填)
Name string `json:"name"` // 设备备注名(必填)
AccountID int64 `json:"account_id"` // 收款账户 ID(必填)
}
// handleWizardStart 引导第 3 步:绑定设备(创建设备记录 + 写 setup 进度)。
// POST /api/admin/personal/devices/wizard-start
//
// 幂等 + 断点续传:
// - serial 未绑定 → 创建设备,setup_progress=["usb_connected","bound"]
// - serial 已绑定 → 返回已有设备 + 当前进度(前端从断点继续,不重复创建)
//
// 响应:{"data":{"ok":true,"id":7,"device_id":"...","setup_progress":["usb_connected","bound"]}}
func (h *PersonalHandler) handleWizardStart(c *gin.Context) {
var req wizardStartReq
if err := c.ShouldBindJSON(&req); err != nil || req.Serial == "" || req.Name == "" || req.AccountID <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误:需要 serial + name + account_id"})
return
}
// serial 可为 USB 直连设备号(不含 ":")或 WiFi host:port(含 ":",须已 adb connect 过)
if req.Serial == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误:需要 serial"})
return
}
acct := h.pool.GetByID(req.AccountID)
if acct == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "账户不存在"})
return
}
if !isPersonalChannelName(acct.Channel) {
c.JSON(http.StatusBadRequest, gin.H{"error": "账户不是个人通道账户(channel=" + acct.Channel + ")"})
return
}
if acct.QRContent == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "账户未配置收款码(qr_content),请先上传收款码"})
return
}
// 幂等:serial 已绑定 → 断点续传
devs, err := h.pdb.ListDevices(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for _, d := range devs {
if d.AdbHost == req.Serial {
steps := personal.ParseSetupProgress(d.SetupProgress)
// 已绑定即视为已接入(上线判定由 DeviceWatcher 按 adb 可达性驱动)
if personal.HasSetupStep(steps, personal.SetupStepBound) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{
"ok": true, "id": d.ID, "device_id": d.DeviceID,
"setup_progress": steps, "already_complete": true,
}})
return
}
c.JSON(http.StatusOK, gin.H{"data": gin.H{
"ok": true, "id": d.ID, "device_id": d.DeviceID,
"setup_progress": steps, "resumed": true,
}})
return
}
}
// 新建:生成设备密钥 + 加密 + 写入(绑定逻辑内联,密钥仅此处返回一次)
secret, err := personal.NewDeviceSecret()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "生成设备密钥失败: " + err.Error()})
return
}
secretEnc, err := account.Encrypt([]byte(secret))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "加密设备密钥失败: " + err.Error()})
return
}
provider := providerFromChannelName(acct.Channel)
deviceID := personal.NewDeviceID()
steps := []string{personal.SetupStepUSBConnected, personal.SetupStepBound}
d := &personal.DeviceEncrypted{
DeviceID: deviceID, Name: req.Name, AccountID: acct.ID,
Provider: provider, SecretEnc: secretEnc, Status: string(personal.DeviceOffline),
AdbHost: req.Serial,
// 完成度门槛:新建即标记 usb_connected + bound,账户后续由心跳/删除 handler 守恒冻结
SetupProgress: personal.FormatSetupProgress(steps),
}
id, err := h.pdb.CreateDevice(c.Request.Context(), d)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "创建设备失败: " + err.Error()})
return
}
// 账户冻结守恒:新建半配置设备 → 该账户若无其他在线设备则冻结
// (设备 adb 可达后由 DeviceWatcher 自动解冻)
if !h.accountHasConnectedDevice(c.Request.Context(), acct.ID) {
if a := h.pool.GetByID(acct.ID); a != nil && a.Status == account.StatusNormal {
h.pool.FreezeByOffline(acct.ID)
logger.Info("admin", "账户已冻结(wizard 新建设备未完成 setup)", "account_id", acct.ID, "name", a.Name)
}
}
logger.Info("admin", "向导设备已绑定", "device_id", deviceID, "name", req.Name, "serial", req.Serial, "account", acct.ID)
c.JSON(http.StatusOK, gin.H{"data": gin.H{
"ok": true, "id": id, "device_id": deviceID,
"setup_progress": steps,
}})
}
// handleWizardWifiBackup 自动配置 WiFi 备用通道(引导向导第 4 步前)。
// POST /api/admin/personal/devices/:id/wizard-wifi
//
// 全自动,无需用户填写任何 WiFi 信息:
// 1. 设备必须 USB 直连(AdbHost 为纯 serial)——WiFi 备用通道的开启依赖 USB 先授权
// 2. adb tcpip 5555:开启设备无线 adb(进入 TCP/IP 监听模式)
// 3. 自动获取设备 WiFi 局域网 IP(wlan0/eth0/getprop 多路尝试)
// 4. 组合 <ip>:5555 写入 adb_wifi_host/adb_wifi_port(设备 USB 断开后自动切换保活)
//
// 幂等:备用通道已配置且设备已在 TCP 监听 → 直接返回现有配置不重复执行。
// 失败不阻塞主流程(返回 ok=false + warning),USB 直连本身仍可用。
func (h *PersonalHandler) handleWizardWifiBackup(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
}
// 仅 USB 直连设备可自动配置(依赖 USB 通道的 adb 权限)
if dev.AdbHost == "" || containsColon(dev.AdbHost) {
c.JSON(http.StatusBadRequest, gin.H{"error": "仅 USB 直连设备可自动配置 WiFi 备用通道"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second)
defer cancel()
serial := dev.AdbHost
// 幂等:已有备用通道且设备在线(TCP 监听已开)→ 直接返回
if dev.AdbWifiHost != "" && dev.AdbWifiPort > 0 {
wifiSerial := fmt.Sprintf("%s:%d", dev.AdbWifiHost, dev.AdbWifiPort)
if h.adbMgr.Online(ctx, wifiSerial) {
logger.Info("admin", "WiFi 备用通道已生效,跳过配置", "device", dev.DeviceID, "wifi", wifiSerial)
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true, "wifi_host": dev.AdbWifiHost, "wifi_port": dev.AdbWifiPort}})
return
}
}
// 1. 开启无线 adb
if err := h.adbMgr.TCPIP(ctx, serial, 5555); err != nil {
logger.Warn("admin", "开启无线 adb 失败", "device", dev.DeviceID, "err", err)
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": false, "warning": "开启无线 adb 失败:" + err.Error()}})
return
}
// adb tcpip 会重启设备端 adbd(切到 TCP 监听模式),USB adb 通道短暂中断。
// 立即查 IP 会失败(adbd 未就绪),等待 2s 让其恢复后再获取 IP。
time.Sleep(2 * time.Second)
// 2. 获取 WiFi IP(重试 2 次,覆盖 adbd 重启窗口)
var ip string
for attempt := 0; attempt < 3; attempt++ {
ip = h.adbMgr.WifiIP(ctx, serial)
if ip != "" {
break
}
if attempt < 2 {
time.Sleep(time.Second)
}
}
if ip == "" {
logger.Warn("admin", "获取设备 WiFi IP 失败(设备可能未连 WiFi)", "device", dev.DeviceID)
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": false, "warning": "未获取到设备 WiFi IP(请确认手机已连接 WiFi)"}})
return
}
// 3. 写入备用通道
wifiPort := 5555
if err := h.pdb.SetDeviceAdb(c.Request.Context(), id, dev.AdbHost, dev.AdbPort, ip, wifiPort); err != nil {
logger.Warn("admin", "写入 WiFi 备用通道失败", "err", err, "id", id)
c.JSON(http.StatusInternalServerError, gin.H{"error": "写入 WiFi 备用通道失败: " + err.Error()})
return
}
logger.Info("admin", "WiFi 备用通道已自动配置", "device", dev.DeviceID, "serial", serial, "wifi", fmt.Sprintf("%s:%d", ip, wifiPort))
c.JSON(http.StatusOK, gin.H{"data": gin.H{"ok": true, "wifi_host": ip, "wifi_port": wifiPort}})
}
// handleWizardVerify 引导最后一步:验证 adb 连通性 + 返回保活引导提示。
// POST /api/admin/personal/devices/:id/wizard-verify
//
// 2026-08-30 重构:监听端 APP 弃用,向导不再下发配置/拉起 App;
// 设备上线由 DeviceWatcher 按 adb 可达性驱动。本步骤只做 adb 连通验证,
// 并返回 MIUI 省电策略"无限制"的保活引导(墓碑冻结无 adb 入口,需 UI 设置)。
func (h *PersonalHandler) handleWizardVerify(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
}
serial, ok := h.pickSerialForWizard(c.Request.Context(), dev)
if !ok {
c.JSON(http.StatusBadGateway, gin.H{"error": "设备离线:USB 未连接或未授权(请确认手机已开 USB 调试并允许)"})
return
}
resp := gin.H{"ok": true, "serial": serial}
// 保活引导提示(MIUI 墓碑冻结/杀后台需 UI 层"省电策略→无限制"才能豁免,
// 无 root 无 adb 入口——实测 2026-08-30 红米k60/MIUI14)。
// 微信/支付宝需无限制,否则后台被系统冻结会掉线。
resp["keepalive_hint"] = fmt.Sprintf(
"请在手机上完成保活设置(共 %d 个应用):设置→应用设置→应用管理→%s→省电策略→选择「无限制」。"+
"不设置的话系统可能冻结后台进程导致掉线。", keepaliveHintAppsCount(dev.Provider), keepaliveHintApps(dev.Provider))
c.JSON(http.StatusOK, gin.H{"data": resp})
}
// keepaliveHintApps 保活引导的应用显示名(支付通道 APP,按 provider 区分)。
func keepaliveHintApps(provider string) string {
return "「" + keepaliveChannelApp(provider) + "」"
}
// keepaliveHintAppsCount 保活引导的应用数量(当前恒为 1:支付通道 APP)。
func keepaliveHintAppsCount(provider string) int {
return 1
}
// keepaliveChannelApp 支付通道 APP 显示名(与包名映射,用于引导文案)。
func keepaliveChannelApp(provider string) string {
switch provider {
case "wechat":
return "微信"
case "alipay":
return "支付宝"
default:
return ""
}
}
// pickSerialForWizard 取设备可用的 adb serial 并校验在线(向导专用)。
// AdbHost 可为 USB 纯 serial 或 WiFi host:port;没有或不可用时回退 WiFi 备用通道。
func (h *PersonalHandler) pickSerialForWizard(ctx context.Context, dev *personal.DeviceEncrypted) (string, bool) {
candidates := make([]string, 0, 2)
if dev.AdbHost != "" {
candidates = append(candidates, dev.AdbHost)
}
if dev.AdbWifiHost != "" && dev.AdbWifiPort > 0 {
candidates = append(candidates, fmt.Sprintf("%s:%d", dev.AdbWifiHost, dev.AdbWifiPort))
}
for _, s := range candidates {
if h.adbMgr.Online(ctx, s) {
return s, true
}
}
return "", false
}
// handleWifiReconnect 设备断网/断 USB 后重连 WiFi 备用通道(adb connect 重建链路)。
// 心跳走公网隧道可能已恢复(status=online),但 scrcpy 画面流的 adb 连接会在断网时失效,
// 需要重新 adb connect 才能恢复实时画面。成功返回可用的 serial 供前端重连,
// USB 主通道若在线则优先返回 USB serial,否则尝试重连 WiFi。
func (h *PersonalHandler) handleWifiReconnect(c *gin.Context) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "设备 id 无效"})
return
}
ctx := c.Request.Context()
dev, err := h.pdb.GetDeviceByID(ctx, id)
if err != nil || dev == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "设备不存在"})
return
}
if dev.AdbHost != "" && h.adbMgr.Online(ctx, dev.AdbHost) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"serial": dev.AdbHost, "channel": "usb"}})
return
}
if dev.AdbWifiHost != "" && dev.AdbWifiPort > 0 {
serial := fmt.Sprintf("%s:%d", dev.AdbWifiHost, dev.AdbWifiPort)
if err := h.adbMgr.Connect(ctx, serial); err == nil && h.adbMgr.Online(ctx, serial) {
c.JSON(http.StatusOK, gin.H{"data": gin.H{"serial": serial, "channel": "wifi"}})
return
}
}
c.JSON(http.StatusBadGateway, gin.H{"error": "设备离线:USB 未连接且 WiFi 重连失败,请确认设备在线并已开 USB 调试"})
}