mirror of
https://github.com/black-ant/Ant-Browser.git
synced 2026-07-14 18:48:55 +08:00
publish: 1.0.0 snapshot (bad2ec1)
channel: master version: 1.0.0 source-ref: master published-at-utc: 2026-03-13T15:19:28Z
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
package launchcode
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LaunchCodeDAO Launch Code 持久化接口
|
||||
type LaunchCodeDAO interface {
|
||||
// FindProfileId 根据 code 查询 profileId
|
||||
FindProfileId(code string) (string, error)
|
||||
// FindCode 根据 profileId 查询 code
|
||||
FindCode(profileId string) (string, error)
|
||||
// Upsert 保存或更新映射
|
||||
Upsert(profileId, code string) error
|
||||
// Delete 删除映射(实例删除时调用)
|
||||
Delete(profileId string) error
|
||||
// LoadAll 加载所有映射(启动时用),返回 profileId -> code 的 map
|
||||
LoadAll() (map[string]string, error)
|
||||
}
|
||||
|
||||
// SQLiteLaunchCodeDAO 基于 SQLite 的 LaunchCodeDAO 实现
|
||||
type SQLiteLaunchCodeDAO struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewSQLiteLaunchCodeDAO 创建 SQLiteLaunchCodeDAO
|
||||
func NewSQLiteLaunchCodeDAO(db *sql.DB) *SQLiteLaunchCodeDAO {
|
||||
return &SQLiteLaunchCodeDAO{db: db}
|
||||
}
|
||||
|
||||
// FindProfileId 根据 code 查询 profileId
|
||||
func (d *SQLiteLaunchCodeDAO) FindProfileId(code string) (string, error) {
|
||||
var profileId string
|
||||
err := d.db.QueryRow(
|
||||
`SELECT profile_id FROM launch_codes WHERE code = ?`, code,
|
||||
).Scan(&profileId)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("launch code not found: %s", code)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("查询 launch code 失败: %w", err)
|
||||
}
|
||||
return profileId, nil
|
||||
}
|
||||
|
||||
// FindCode 根据 profileId 查询 code
|
||||
func (d *SQLiteLaunchCodeDAO) FindCode(profileId string) (string, error) {
|
||||
var code string
|
||||
err := d.db.QueryRow(
|
||||
`SELECT code FROM launch_codes WHERE profile_id = ?`, profileId,
|
||||
).Scan(&code)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", fmt.Errorf("profile not found: %s", profileId)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("查询 profile code 失败: %w", err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// Upsert 保存或更新 profileId <-> code 映射
|
||||
func (d *SQLiteLaunchCodeDAO) Upsert(profileId, code string) error {
|
||||
now := time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||
_, err := d.db.Exec(
|
||||
`INSERT INTO launch_codes (profile_id, code, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(profile_id) DO UPDATE SET
|
||||
code = excluded.code,
|
||||
updated_at = excluded.updated_at`,
|
||||
profileId, code, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存 launch code 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除 profileId 对应的映射
|
||||
func (d *SQLiteLaunchCodeDAO) Delete(profileId string) error {
|
||||
_, err := d.db.Exec(
|
||||
`DELETE FROM launch_codes WHERE profile_id = ?`, profileId,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除 launch code 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAll 加载所有映射,返回 profileId -> code 的 map
|
||||
func (d *SQLiteLaunchCodeDAO) LoadAll() (map[string]string, error) {
|
||||
rows, err := d.db.Query(`SELECT profile_id, code FROM launch_codes`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("加载 launch codes 失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]string)
|
||||
for rows.Next() {
|
||||
var profileId, code string
|
||||
if err := rows.Scan(&profileId, &code); err != nil {
|
||||
return nil, fmt.Errorf("读取 launch code 行失败: %w", err)
|
||||
}
|
||||
result[profileId] = code
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("遍历 launch codes 失败: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package launchcode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// MemoryLaunchCodeDAO 基于内存的 LaunchCodeDAO 实现,仅用于测试
|
||||
type MemoryLaunchCodeDAO struct {
|
||||
mu sync.RWMutex
|
||||
profileToCode map[string]string
|
||||
codeToProfile map[string]string
|
||||
}
|
||||
|
||||
// NewMemoryLaunchCodeDAO 创建内存 DAO
|
||||
func NewMemoryLaunchCodeDAO() *MemoryLaunchCodeDAO {
|
||||
return &MemoryLaunchCodeDAO{
|
||||
profileToCode: make(map[string]string),
|
||||
codeToProfile: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (d *MemoryLaunchCodeDAO) FindProfileId(code string) (string, error) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
profileId, ok := d.codeToProfile[code]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("launch code not found: %s", code)
|
||||
}
|
||||
return profileId, nil
|
||||
}
|
||||
|
||||
func (d *MemoryLaunchCodeDAO) FindCode(profileId string) (string, error) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
code, ok := d.profileToCode[profileId]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("profile not found: %s", profileId)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
|
||||
func (d *MemoryLaunchCodeDAO) Upsert(profileId, code string) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
// 清理旧 code 的反向映射
|
||||
if oldCode, ok := d.profileToCode[profileId]; ok {
|
||||
delete(d.codeToProfile, oldCode)
|
||||
}
|
||||
d.profileToCode[profileId] = code
|
||||
d.codeToProfile[code] = profileId
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *MemoryLaunchCodeDAO) Delete(profileId string) error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if code, ok := d.profileToCode[profileId]; ok {
|
||||
delete(d.codeToProfile, code)
|
||||
delete(d.profileToCode, profileId)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *MemoryLaunchCodeDAO) LoadAll() (map[string]string, error) {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
result := make(map[string]string, len(d.profileToCode))
|
||||
for k, v := range d.profileToCode {
|
||||
result[k] = v
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package launchcode
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"ant-chrome/backend/internal/browser"
|
||||
"ant-chrome/backend/internal/logger"
|
||||
)
|
||||
|
||||
// BrowserStarter 浏览器启动接口(由 App 层实现并注入)
|
||||
type BrowserStarter interface {
|
||||
StartInstance(profileId string) (*browser.Profile, error)
|
||||
}
|
||||
|
||||
// LaunchRequestParams 支持外部自动化透传的一次性启动参数
|
||||
type LaunchRequestParams struct {
|
||||
LaunchArgs []string `json:"launchArgs"`
|
||||
StartURLs []string `json:"startUrls"`
|
||||
SkipDefaultStartURLs bool `json:"skipDefaultStartUrls"`
|
||||
}
|
||||
|
||||
// LaunchRequest POST /api/launch 的请求体
|
||||
type LaunchRequest struct {
|
||||
Code string `json:"code"`
|
||||
LaunchRequestParams
|
||||
}
|
||||
|
||||
// BrowserStarterWithParams 可选接口:支持带参数启动实例
|
||||
type BrowserStarterWithParams interface {
|
||||
StartInstanceWithParams(profileId string, params LaunchRequestParams) (*browser.Profile, error)
|
||||
}
|
||||
|
||||
// LaunchCallRecord 接口调用记录
|
||||
type LaunchCallRecord struct {
|
||||
Timestamp string `json:"timestamp"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
ClientIP string `json:"clientIp"`
|
||||
Code string `json:"code"`
|
||||
ProfileID string `json:"profileId"`
|
||||
ProfileName string `json:"profileName"`
|
||||
Params LaunchRequestParams `json:"params"`
|
||||
OK bool `json:"ok"`
|
||||
Status int `json:"status"`
|
||||
Error string `json:"error"`
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
}
|
||||
|
||||
// LaunchServer 本地 HTTP 唤起服务
|
||||
type LaunchServer struct {
|
||||
service *LaunchCodeService
|
||||
starter BrowserStarter
|
||||
browserMgr *browser.Manager
|
||||
port int
|
||||
server *http.Server
|
||||
mu sync.Mutex
|
||||
logMu sync.Mutex
|
||||
callLogs []LaunchCallRecord
|
||||
}
|
||||
|
||||
// NewLaunchServer 创建 LaunchServer
|
||||
func NewLaunchServer(service *LaunchCodeService, starter BrowserStarter, mgr *browser.Manager, port int) *LaunchServer {
|
||||
return &LaunchServer{
|
||||
service: service,
|
||||
starter: starter,
|
||||
browserMgr: mgr,
|
||||
port: port,
|
||||
}
|
||||
}
|
||||
|
||||
// Start 非阻塞启动 HTTP 服务。
|
||||
// 规则:
|
||||
// - port <= 0:自动分配随机可用端口
|
||||
// - port > 0:优先使用指定端口;若被占用则回退到随机可用端口
|
||||
func (s *LaunchServer) Start() error {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/health", s.handleHealth)
|
||||
mux.HandleFunc("/api/launch", s.handleLaunchWithBody)
|
||||
mux.HandleFunc("/api/launch/logs", s.handleLaunchLogs)
|
||||
mux.HandleFunc("/api/launch/", s.handleLaunch)
|
||||
|
||||
handler := s.localhostMiddleware(mux)
|
||||
|
||||
preferredPort := s.port
|
||||
ln, port, usedFallbackRandom, err := bindLaunchListener(preferredPort)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.port = port
|
||||
s.server = &http.Server{Handler: handler}
|
||||
s.mu.Unlock()
|
||||
|
||||
log := logger.New("LaunchServer")
|
||||
if preferredPort <= 0 {
|
||||
log.Info("LaunchServer 使用随机端口", logger.F("port", port))
|
||||
} else if usedFallbackRandom {
|
||||
log.Warn("LaunchServer 首选端口不可用,已切换随机端口",
|
||||
logger.F("preferred_port", preferredPort),
|
||||
logger.F("port", port),
|
||||
)
|
||||
}
|
||||
log.Info("LaunchServer 已启动", logger.F("port", port))
|
||||
|
||||
go func() {
|
||||
if serveErr := s.server.Serve(ln); serveErr != nil && serveErr != http.ErrServerClosed {
|
||||
log.Error("LaunchServer 异常退出", logger.F("error", serveErr.Error()))
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func bindLaunchListener(preferredPort int) (net.Listener, int, bool, error) {
|
||||
if preferredPort <= 0 {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, 0, false, fmt.Errorf("自动分配端口失败: %w", err)
|
||||
}
|
||||
port, err := listenerPort(ln)
|
||||
if err != nil {
|
||||
_ = ln.Close()
|
||||
return nil, 0, false, err
|
||||
}
|
||||
return ln, port, false, nil
|
||||
}
|
||||
|
||||
addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(preferredPort))
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err == nil {
|
||||
return ln, preferredPort, false, nil
|
||||
}
|
||||
|
||||
fallbackLn, fallbackErr := net.Listen("tcp", "127.0.0.1:0")
|
||||
if fallbackErr != nil {
|
||||
return nil, 0, false, fmt.Errorf("端口 %d 不可用且自动分配失败: %w", preferredPort, err)
|
||||
}
|
||||
port, portErr := listenerPort(fallbackLn)
|
||||
if portErr != nil {
|
||||
_ = fallbackLn.Close()
|
||||
return nil, 0, false, portErr
|
||||
}
|
||||
return fallbackLn, port, true, nil
|
||||
}
|
||||
|
||||
func listenerPort(ln net.Listener) (int, error) {
|
||||
if ln == nil {
|
||||
return 0, fmt.Errorf("listener is nil")
|
||||
}
|
||||
if tcpAddr, ok := ln.Addr().(*net.TCPAddr); ok {
|
||||
return tcpAddr.Port, nil
|
||||
}
|
||||
|
||||
_, rawPort, err := net.SplitHostPort(ln.Addr().String())
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("解析监听地址失败: %w", err)
|
||||
}
|
||||
port, err := strconv.Atoi(rawPort)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("解析端口失败: %w", err)
|
||||
}
|
||||
return port, nil
|
||||
}
|
||||
|
||||
// Stop 优雅关闭(5 秒超时)
|
||||
func (s *LaunchServer) Stop() error {
|
||||
s.mu.Lock()
|
||||
srv := s.server
|
||||
s.mu.Unlock()
|
||||
|
||||
if srv == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
return srv.Shutdown(ctx)
|
||||
}
|
||||
|
||||
// Port 返回实际绑定的端口
|
||||
func (s *LaunchServer) Port() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.port
|
||||
}
|
||||
|
||||
// localhostMiddleware 只允许 127.0.0.1 访问
|
||||
func (s *LaunchServer) localhostMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil || host != "127.0.0.1" {
|
||||
writeJSON(w, http.StatusForbidden, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "forbidden: only localhost is allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// handleHealth GET /api/health
|
||||
func (s *LaunchServer) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
|
||||
}
|
||||
|
||||
// handleLaunch GET /api/launch/{code}
|
||||
func (s *LaunchServer) handleLaunch(w http.ResponseWriter, r *http.Request) {
|
||||
startAt := time.Now()
|
||||
clientIP := remoteIP(r.RemoteAddr)
|
||||
if r.Method != http.MethodGet {
|
||||
msg := "method not allowed"
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": msg,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusMethodNotAllowed, msg, "", "", startAt)
|
||||
return
|
||||
}
|
||||
|
||||
code := strings.TrimPrefix(r.URL.Path, "/api/launch/")
|
||||
if strings.TrimSpace(code) == "" {
|
||||
msg := "launch code not found"
|
||||
writeJSON(w, http.StatusNotFound, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": msg,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusNotFound, msg, "", "", startAt)
|
||||
return
|
||||
}
|
||||
|
||||
profile, status, errMsg := s.launchByCode(code, LaunchRequestParams{})
|
||||
if errMsg != "" {
|
||||
writeJSON(w, status, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": errMsg,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, code, LaunchRequestParams{}, false, status, errMsg, "", "", startAt)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"profileId": profile.ProfileId,
|
||||
"profileName": profile.ProfileName,
|
||||
"pid": profile.Pid,
|
||||
"debugPort": profile.DebugPort,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, code, LaunchRequestParams{}, true, http.StatusOK, "", profile.ProfileId, profile.ProfileName, startAt)
|
||||
}
|
||||
|
||||
// handleLaunchWithBody POST /api/launch
|
||||
func (s *LaunchServer) handleLaunchWithBody(w http.ResponseWriter, r *http.Request) {
|
||||
startAt := time.Now()
|
||||
clientIP := remoteIP(r.RemoteAddr)
|
||||
if r.Method != http.MethodPost {
|
||||
msg := "method not allowed"
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": msg,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusMethodNotAllowed, msg, "", "", startAt)
|
||||
return
|
||||
}
|
||||
|
||||
var req LaunchRequest
|
||||
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&req); err != nil {
|
||||
msg := "invalid request body"
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": msg,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", LaunchRequestParams{}, false, http.StatusBadRequest, msg, "", "", startAt)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Code) == "" {
|
||||
msg := "code is required"
|
||||
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": msg,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, "", req.LaunchRequestParams, false, http.StatusBadRequest, msg, "", "", startAt)
|
||||
return
|
||||
}
|
||||
|
||||
req.LaunchArgs = normalizeStringSlice(req.LaunchArgs)
|
||||
req.StartURLs = normalizeStringSlice(req.StartURLs)
|
||||
profile, status, errMsg := s.launchByCode(req.Code, req.LaunchRequestParams)
|
||||
if errMsg != "" {
|
||||
writeJSON(w, status, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": errMsg,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, req.Code, req.LaunchRequestParams, false, status, errMsg, "", "", startAt)
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"profileId": profile.ProfileId,
|
||||
"profileName": profile.ProfileName,
|
||||
"pid": profile.Pid,
|
||||
"debugPort": profile.DebugPort,
|
||||
})
|
||||
s.appendLaunchLog(r.Method, r.URL.Path, clientIP, req.Code, req.LaunchRequestParams, true, http.StatusOK, "", profile.ProfileId, profile.ProfileName, startAt)
|
||||
}
|
||||
|
||||
// handleLaunchLogs GET /api/launch/logs?limit=50
|
||||
func (s *LaunchServer) handleLaunchLogs(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeJSON(w, http.StatusMethodNotAllowed, map[string]interface{}{
|
||||
"ok": false,
|
||||
"error": "method not allowed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
limit := 50
|
||||
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil {
|
||||
if n < 1 {
|
||||
n = 1
|
||||
}
|
||||
if n > 200 {
|
||||
n = 200
|
||||
}
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
|
||||
items := s.listLaunchLogs(limit)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"ok": true,
|
||||
"items": items,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *LaunchServer) launchByCode(code string, params LaunchRequestParams) (*browser.Profile, int, string) {
|
||||
profileId, err := s.service.Resolve(strings.TrimSpace(code))
|
||||
if err != nil {
|
||||
return nil, http.StatusNotFound, "launch code not found"
|
||||
}
|
||||
|
||||
var profile *browser.Profile
|
||||
if starterWithParams, ok := s.starter.(BrowserStarterWithParams); ok {
|
||||
profile, err = starterWithParams.StartInstanceWithParams(profileId, params)
|
||||
} else {
|
||||
profile, err = s.starter.StartInstance(profileId)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, http.StatusInternalServerError, err.Error()
|
||||
}
|
||||
|
||||
return profile, http.StatusOK, ""
|
||||
}
|
||||
|
||||
// writeJSON 写入 JSON 响应
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
// NewTestHandler 返回不含 localhost 限制的 handler,仅供测试使用
|
||||
func NewTestHandler(s *LaunchServer) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/api/health", s.handleHealth)
|
||||
mux.HandleFunc("/api/launch", s.handleLaunchWithBody)
|
||||
mux.HandleFunc("/api/launch/logs", s.handleLaunchLogs)
|
||||
mux.HandleFunc("/api/launch/", s.handleLaunch)
|
||||
return mux
|
||||
}
|
||||
|
||||
func normalizeStringSlice(items []string) []string {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
v := strings.TrimSpace(item)
|
||||
if v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *LaunchServer) appendLaunchLog(method, path, clientIP, code string, params LaunchRequestParams, ok bool, status int, errMsg, profileID, profileName string, startAt time.Time) {
|
||||
entry := LaunchCallRecord{
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
Method: method,
|
||||
Path: path,
|
||||
ClientIP: clientIP,
|
||||
Code: strings.TrimSpace(code),
|
||||
ProfileID: profileID,
|
||||
ProfileName: profileName,
|
||||
Params: params,
|
||||
OK: ok,
|
||||
Status: status,
|
||||
Error: errMsg,
|
||||
DurationMs: time.Since(startAt).Milliseconds(),
|
||||
}
|
||||
|
||||
s.logMu.Lock()
|
||||
s.callLogs = append(s.callLogs, entry)
|
||||
if len(s.callLogs) > 500 {
|
||||
s.callLogs = append([]LaunchCallRecord(nil), s.callLogs[len(s.callLogs)-500:]...)
|
||||
}
|
||||
s.logMu.Unlock()
|
||||
|
||||
log := logger.New("LaunchServer")
|
||||
if ok {
|
||||
log.Info("Launch API 调用", logger.F("method", method), logger.F("path", path), logger.F("code", entry.Code), logger.F("profile_id", profileID), logger.F("status", status), logger.F("duration_ms", entry.DurationMs))
|
||||
return
|
||||
}
|
||||
log.Warn("Launch API 调用失败", logger.F("method", method), logger.F("path", path), logger.F("code", entry.Code), logger.F("status", status), logger.F("error", errMsg), logger.F("duration_ms", entry.DurationMs))
|
||||
}
|
||||
|
||||
func (s *LaunchServer) listLaunchLogs(limit int) []LaunchCallRecord {
|
||||
s.logMu.Lock()
|
||||
defer s.logMu.Unlock()
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > len(s.callLogs) {
|
||||
limit = len(s.callLogs)
|
||||
}
|
||||
if limit == 0 {
|
||||
return []LaunchCallRecord{}
|
||||
}
|
||||
|
||||
out := make([]LaunchCallRecord, 0, limit)
|
||||
for i := len(s.callLogs) - 1; i >= 0 && len(out) < limit; i-- {
|
||||
out = append(out, s.callLogs[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func remoteIP(remoteAddr string) string {
|
||||
host, _, err := net.SplitHostPort(remoteAddr)
|
||||
if err != nil {
|
||||
return remoteAddr
|
||||
}
|
||||
return host
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package launchcode_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"ant-chrome/backend/internal/launchcode"
|
||||
)
|
||||
|
||||
func TestLaunchServerStartWithAutoPort(t *testing.T) {
|
||||
svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO())
|
||||
srv := launchcode.NewLaunchServer(svc, nil, nil, 0)
|
||||
|
||||
if err := srv.Start(); err != nil {
|
||||
t.Fatalf("Start 失败: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = srv.Stop()
|
||||
}()
|
||||
|
||||
port := srv.Port()
|
||||
if port <= 0 {
|
||||
t.Fatalf("自动端口分配失败: got=%d", port)
|
||||
}
|
||||
|
||||
resp, err := http.Get(fmt.Sprintf("http://127.0.0.1:%d/api/health", port))
|
||||
if err != nil {
|
||||
t.Fatalf("健康检查请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("健康检查状态码错误: got=%d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaunchServerFallbackToRandomPortWhenPreferredIsBusy(t *testing.T) {
|
||||
occupied, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("占用端口失败: %v", err)
|
||||
}
|
||||
defer occupied.Close()
|
||||
|
||||
busyPort := occupied.Addr().(*net.TCPAddr).Port
|
||||
svc := launchcode.NewLaunchCodeService(launchcode.NewMemoryLaunchCodeDAO())
|
||||
srv := launchcode.NewLaunchServer(svc, nil, nil, busyPort)
|
||||
|
||||
if err := srv.Start(); err != nil {
|
||||
t.Fatalf("Start 失败: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = srv.Stop()
|
||||
}()
|
||||
|
||||
actualPort := srv.Port()
|
||||
if actualPort <= 0 {
|
||||
t.Fatalf("随机回退端口无效: got=%d", actualPort)
|
||||
}
|
||||
if actualPort == busyPort {
|
||||
t.Fatalf("期望回退到随机端口,但仍使用了被占用端口: %d", actualPort)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package launchcode
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
const codeLen = 6
|
||||
const maxRetries = 10
|
||||
const customCodeMinLen = 4
|
||||
const customCodeMaxLen = 32
|
||||
|
||||
var customCodePattern = regexp.MustCompile(`^[A-Z0-9_-]+$`)
|
||||
|
||||
// LaunchCodeService 负责 Launch Code 的生成、缓存与管理
|
||||
type LaunchCodeService struct {
|
||||
dao LaunchCodeDAO
|
||||
codeToProfile map[string]string
|
||||
profileToCode map[string]string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewLaunchCodeService 创建 LaunchCodeService
|
||||
func NewLaunchCodeService(dao LaunchCodeDAO) *LaunchCodeService {
|
||||
return &LaunchCodeService{
|
||||
dao: dao,
|
||||
codeToProfile: make(map[string]string),
|
||||
profileToCode: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// EnsureCode 为 profile 生成并持久化 code(幂等:已有则直接返回)
|
||||
func (s *LaunchCodeService) EnsureCode(profileId string) (string, error) {
|
||||
s.mu.RLock()
|
||||
if code, ok := s.profileToCode[profileId]; ok {
|
||||
s.mu.RUnlock()
|
||||
return code, nil
|
||||
}
|
||||
s.mu.RUnlock()
|
||||
|
||||
code, err := s.generateUniqueCode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := s.dao.Upsert(profileId, code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.profileToCode[profileId] = code
|
||||
s.codeToProfile[code] = profileId
|
||||
s.mu.Unlock()
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// SetCode 为指定 profile 设置自定义 launch code。
|
||||
// code 会自动 trim 并转为大写;格式限制为 4-32 位,字符集 [A-Z0-9_-]。
|
||||
func (s *LaunchCodeService) SetCode(profileId, code string) (string, error) {
|
||||
code = normalizeCode(code)
|
||||
if err := validateCustomCode(code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if old, ok := s.profileToCode[profileId]; ok && old == code {
|
||||
return code, nil
|
||||
}
|
||||
|
||||
if ownerProfile, exists := s.codeToProfile[code]; exists && ownerProfile != profileId {
|
||||
return "", fmt.Errorf("launch code already exists")
|
||||
}
|
||||
|
||||
if err := s.dao.Upsert(profileId, code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if old, ok := s.profileToCode[profileId]; ok {
|
||||
delete(s.codeToProfile, old)
|
||||
}
|
||||
s.profileToCode[profileId] = code
|
||||
s.codeToProfile[code] = profileId
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// RegenerateCode 重新生成 code(废弃旧 code)
|
||||
func (s *LaunchCodeService) RegenerateCode(profileId string) (string, error) {
|
||||
s.mu.Lock()
|
||||
if oldCode, ok := s.profileToCode[profileId]; ok {
|
||||
delete(s.codeToProfile, oldCode)
|
||||
delete(s.profileToCode, profileId)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
code, err := s.generateUniqueCode()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := s.dao.Upsert(profileId, code); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.profileToCode[profileId] = code
|
||||
s.codeToProfile[code] = profileId
|
||||
s.mu.Unlock()
|
||||
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// Resolve 根据 code 查找 profileId(仅查内存缓存)
|
||||
func (s *LaunchCodeService) Resolve(code string) (string, error) {
|
||||
code = normalizeCode(code)
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
profileId, ok := s.codeToProfile[code]
|
||||
if !ok {
|
||||
return "", fmt.Errorf("launch code not found: %s", code)
|
||||
}
|
||||
return profileId, nil
|
||||
}
|
||||
|
||||
// Remove 删除 profile 对应的 code(同时清理内存缓存和数据库)
|
||||
func (s *LaunchCodeService) Remove(profileId string) error {
|
||||
s.mu.Lock()
|
||||
if code, ok := s.profileToCode[profileId]; ok {
|
||||
delete(s.codeToProfile, code)
|
||||
delete(s.profileToCode, profileId)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
return s.dao.Delete(profileId)
|
||||
}
|
||||
|
||||
// LoadAll 启动时从数据库加载所有映射到内存
|
||||
func (s *LaunchCodeService) LoadAll() error {
|
||||
profileToCode, err := s.dao.LoadAll()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.profileToCode = make(map[string]string, len(profileToCode))
|
||||
s.codeToProfile = make(map[string]string, len(profileToCode))
|
||||
|
||||
for profileId, code := range profileToCode {
|
||||
s.profileToCode[profileId] = code
|
||||
s.codeToProfile[code] = profileId
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateUniqueCode 生成一个在内存缓存中唯一的 code
|
||||
func (s *LaunchCodeService) generateUniqueCode() (string, error) {
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
code, err := randomCode()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("生成 launch code 失败: %w", err)
|
||||
}
|
||||
|
||||
s.mu.RLock()
|
||||
_, exists := s.codeToProfile[code]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return code, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("无法在 %d 次重试内生成唯一 launch code", maxRetries)
|
||||
}
|
||||
|
||||
// randomCode 使用 crypto/rand 生成一个随机 6 位字符串
|
||||
func randomCode() (string, error) {
|
||||
buf := make([]byte, codeLen)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
result := make([]byte, codeLen)
|
||||
for i, b := range buf {
|
||||
result[i] = charset[int(b)%len(charset)]
|
||||
}
|
||||
return string(result), nil
|
||||
}
|
||||
|
||||
func normalizeCode(code string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(code))
|
||||
}
|
||||
|
||||
func validateCustomCode(code string) error {
|
||||
if len(code) < customCodeMinLen || len(code) > customCodeMaxLen {
|
||||
return fmt.Errorf("launch code must be %d-%d characters", customCodeMinLen, customCodeMaxLen)
|
||||
}
|
||||
if !customCodePattern.MatchString(code) {
|
||||
return fmt.Errorf("launch code format invalid: only A-Z, 0-9, _ and - are allowed")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user