多平台账号批量注册系统,支持 OpenAI / Grok / Kiro / Gemini 平台的全自动注册。 Features: - 用户名/密码注册登录(首个用户自动管理员) - JWT 认证 + bcrypt 密码加密 - 积分系统与多用户管理 - HuggingFace Space 弹性 Worker 节点管理 - Cloudflare Worker 负载均衡 - 实时日志推送(SSE/WebSocket) - 管理后台(用户管理、系统设置、代理池、卡池) Tech Stack: Go 1.25 / Gin / GORM / PostgreSQL / Vue 3 / Vite / TailwindCSS
573 lines
17 KiB
Go
573 lines
17 KiB
Go
package handler
|
||
|
||
import (
|
||
"bufio"
|
||
"fmt"
|
||
"io"
|
||
"net"
|
||
"net/http"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/rs/zerolog/log"
|
||
"github.com/xiaolajiaoyyds/regplatformm/internal/model"
|
||
"github.com/xiaolajiaoyyds/regplatformm/internal/pkg/netutil"
|
||
"github.com/xiaolajiaoyyds/regplatformm/internal/service"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
// ProxyPoolHandler 全局代理池管理(管理员)
|
||
type ProxyPoolHandler struct {
|
||
db *gorm.DB
|
||
proxyPool *service.ProxyPool
|
||
}
|
||
|
||
// NewProxyPoolHandler 创建代理池管理处理器
|
||
func NewProxyPoolHandler(db *gorm.DB, proxyPool *service.ProxyPool) *ProxyPoolHandler {
|
||
return &ProxyPoolHandler{db: db, proxyPool: proxyPool}
|
||
}
|
||
|
||
// ── 请求结构体 ──────────────────────────────────────────────────────
|
||
|
||
type createPoolProxyReq struct {
|
||
Name string `json:"name" binding:"max=100"`
|
||
Protocol string `json:"protocol" binding:"required,oneof=http https socks5"`
|
||
Host string `json:"host" binding:"required,max=200"`
|
||
Port int `json:"port" binding:"required,min=1,max=65535"`
|
||
Username string `json:"username" binding:"max=100"`
|
||
Password string `json:"password" binding:"max=200"`
|
||
Country string `json:"country" binding:"max=10"`
|
||
}
|
||
|
||
// ── CRUD ────────────────────────────────────────────────────────────
|
||
|
||
// List 获取代理池列表(GET /api/admin/proxy-pool,支持分页)
|
||
func (h *ProxyPoolHandler) List(c *gin.Context) {
|
||
query := h.db.Where("protocol IN ?", []string{"http", "https", "socks5"}).Order("created_at DESC")
|
||
|
||
// 可选过滤:healthy / unhealthy
|
||
if filter := c.Query("filter"); filter == "healthy" {
|
||
query = query.Where("is_healthy = ?", true)
|
||
} else if filter == "unhealthy" {
|
||
query = query.Where("is_healthy = ?", false)
|
||
}
|
||
|
||
// 分页参数
|
||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "50"))
|
||
if page < 1 {
|
||
page = 1
|
||
}
|
||
if pageSize < 1 || pageSize > 500 {
|
||
pageSize = 50
|
||
}
|
||
|
||
// 总数(Session 克隆防止 Count 污染后续 Find 查询)
|
||
var total int64
|
||
query.Session(&gorm.Session{}).Model(&model.ProxyPoolEntry{}).Count(&total)
|
||
|
||
// 分页查询
|
||
var entries []model.ProxyPoolEntry
|
||
if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&entries).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "查询失败"})
|
||
return
|
||
}
|
||
|
||
// 脱敏密码
|
||
for i := range entries {
|
||
if entries[i].Password != "" {
|
||
entries[i].Password = "******"
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"items": entries,
|
||
"total": total,
|
||
"page": page,
|
||
"page_size": pageSize,
|
||
})
|
||
}
|
||
|
||
// Create 添加代理到池(POST /api/admin/proxy-pool)
|
||
func (h *ProxyPoolHandler) Create(c *gin.Context) {
|
||
var req createPoolProxyReq
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||
return
|
||
}
|
||
|
||
// SSRF 防护:拒绝私网/保留地址
|
||
if netutil.IsPrivateHost(req.Host) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": "不允许添加内网地址的代理"})
|
||
return
|
||
}
|
||
|
||
// 去重检查
|
||
var count int64
|
||
if err := h.db.Model(&model.ProxyPoolEntry{}).Where("host = ? AND port = ?", req.Host, req.Port).Count(&count).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "查询失败"})
|
||
return
|
||
}
|
||
if count > 0 {
|
||
c.JSON(http.StatusConflict, gin.H{"detail": fmt.Sprintf("代理 %s:%d 已存在", req.Host, req.Port)})
|
||
return
|
||
}
|
||
|
||
entry := model.ProxyPoolEntry{
|
||
Name: req.Name,
|
||
Protocol: req.Protocol,
|
||
Host: req.Host,
|
||
Port: req.Port,
|
||
Username: req.Username,
|
||
Password: req.Password,
|
||
Country: req.Country,
|
||
Source: "manual",
|
||
}
|
||
if err := h.db.Create(&entry).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "创建失败"})
|
||
return
|
||
}
|
||
|
||
h.proxyPool.Reload()
|
||
|
||
if entry.Password != "" {
|
||
entry.Password = "******"
|
||
}
|
||
c.JSON(http.StatusOK, entry)
|
||
}
|
||
|
||
// Delete 删除代理(DELETE /api/admin/proxy-pool/:id)
|
||
func (h *ProxyPoolHandler) Delete(c *gin.Context) {
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 ID"})
|
||
return
|
||
}
|
||
result := h.db.Where("id = ?", id).Delete(&model.ProxyPoolEntry{})
|
||
if result.Error != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "删除失败"})
|
||
return
|
||
}
|
||
if result.RowsAffected == 0 {
|
||
c.JSON(http.StatusNotFound, gin.H{"detail": "代理不存在"})
|
||
return
|
||
}
|
||
h.proxyPool.Reload()
|
||
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
|
||
}
|
||
|
||
// BatchDelete 批量删除(POST /api/admin/proxy-pool/batch-delete)
|
||
func (h *ProxyPoolHandler) BatchDelete(c *gin.Context) {
|
||
var req struct {
|
||
IDs []uint `json:"ids" binding:"required"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||
return
|
||
}
|
||
if len(req.IDs) > 500 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": "单次最多删除 500 条"})
|
||
return
|
||
}
|
||
result := h.db.Where("id IN ?", req.IDs).Delete(&model.ProxyPoolEntry{})
|
||
if result.Error != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "删除失败"})
|
||
return
|
||
}
|
||
h.proxyPool.Reload()
|
||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("已删除 %d 条", result.RowsAffected)})
|
||
}
|
||
|
||
// ── 批量导入 ────────────────────────────────────────────────────────
|
||
|
||
// Import 批量导入代理(POST /api/admin/proxy-pool/import)
|
||
// 支持格式:每行一个,格式 protocol://[user:pass@]host:port 或 host:port(默认 socks5)
|
||
func (h *ProxyPoolHandler) Import(c *gin.Context) {
|
||
var req struct {
|
||
Proxies string `json:"proxies" binding:"required"` // 换行分隔的代理列表
|
||
Protocol string `json:"protocol"` // 默认协议(未指定时使用)
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||
return
|
||
}
|
||
|
||
defaultProto := req.Protocol
|
||
if defaultProto == "" {
|
||
defaultProto = "socks5"
|
||
}
|
||
|
||
// 协议白名单校验
|
||
allowedProtos := map[string]bool{"http": true, "https": true, "socks5": true}
|
||
if !allowedProtos[defaultProto] {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("不支持的协议: %s,仅支持 http/https/socks5", defaultProto)})
|
||
return
|
||
}
|
||
|
||
const maxImportLines = 1000
|
||
scanner := bufio.NewScanner(strings.NewReader(req.Proxies))
|
||
var imported, skipped, failed int
|
||
|
||
for scanner.Scan() {
|
||
line := strings.TrimSpace(scanner.Text())
|
||
if line == "" || strings.HasPrefix(line, "#") {
|
||
continue
|
||
}
|
||
|
||
if imported+skipped+failed >= maxImportLines {
|
||
failed++
|
||
continue
|
||
}
|
||
|
||
entry, err := parseProxyLine(line, defaultProto)
|
||
if err != nil {
|
||
log.Debug().Str("line", line).Err(err).Msg("代理导入解析失败")
|
||
failed++
|
||
continue
|
||
}
|
||
|
||
// 协议白名单(解析后二次校验)
|
||
if !allowedProtos[entry.Protocol] {
|
||
failed++
|
||
continue
|
||
}
|
||
|
||
// SSRF 防护:拒绝私网/保留地址
|
||
if netutil.IsPrivateHost(entry.Host) {
|
||
log.Warn().Str("host", entry.Host).Msg("代理导入拒绝内网地址")
|
||
failed++
|
||
continue
|
||
}
|
||
|
||
// 去重检查
|
||
var count int64
|
||
h.db.Model(&model.ProxyPoolEntry{}).Where("host = ? AND port = ?", entry.Host, entry.Port).Count(&count)
|
||
if count > 0 {
|
||
skipped++
|
||
continue
|
||
}
|
||
|
||
entry.Source = "imported"
|
||
if err := h.db.Create(entry).Error; err != nil {
|
||
failed++
|
||
continue
|
||
}
|
||
imported++
|
||
}
|
||
|
||
h.proxyPool.Reload()
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"imported": imported,
|
||
"skipped": skipped,
|
||
"failed": failed,
|
||
"message": fmt.Sprintf("导入完成:成功 %d,重复跳过 %d,失败 %d", imported, skipped, failed),
|
||
})
|
||
}
|
||
|
||
// parseProxyLine 解析单行代理地址
|
||
// 支持格式:
|
||
// - socks5://user:pass@host:port
|
||
// - host:port(使用默认协议)
|
||
// - host:port:user:pass(使用默认协议)
|
||
func parseProxyLine(line, defaultProto string) (*model.ProxyPoolEntry, error) {
|
||
entry := &model.ProxyPoolEntry{}
|
||
|
||
// 带协议前缀
|
||
if strings.Contains(line, "://") {
|
||
parts := strings.SplitN(line, "://", 2)
|
||
entry.Protocol = parts[0]
|
||
rest := parts[1]
|
||
|
||
// 提取认证信息
|
||
if atIdx := strings.LastIndex(rest, "@"); atIdx > 0 {
|
||
authPart := rest[:atIdx]
|
||
rest = rest[atIdx+1:]
|
||
if colonIdx := strings.Index(authPart, ":"); colonIdx > 0 {
|
||
entry.Username = authPart[:colonIdx]
|
||
entry.Password = authPart[colonIdx+1:]
|
||
}
|
||
}
|
||
|
||
host, port, err := parseHostPort(rest)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if port < 1 || port > 65535 {
|
||
return nil, fmt.Errorf("端口超出范围: %d", port)
|
||
}
|
||
entry.Host = host
|
||
entry.Port = port
|
||
} else {
|
||
// 无协议前缀
|
||
entry.Protocol = defaultProto
|
||
parts := strings.Split(line, ":")
|
||
switch len(parts) {
|
||
case 2:
|
||
// host:port
|
||
port, err := strconv.Atoi(parts[1])
|
||
if err != nil {
|
||
return nil, fmt.Errorf("端口格式错误: %s", parts[1])
|
||
}
|
||
if port < 1 || port > 65535 {
|
||
return nil, fmt.Errorf("端口超出范围: %d", port)
|
||
}
|
||
entry.Host = parts[0]
|
||
entry.Port = port
|
||
case 4:
|
||
// host:port:user:pass
|
||
port, err := strconv.Atoi(parts[1])
|
||
if err != nil {
|
||
return nil, fmt.Errorf("端口格式错误: %s", parts[1])
|
||
}
|
||
if port < 1 || port > 65535 {
|
||
return nil, fmt.Errorf("端口超出范围: %d", port)
|
||
}
|
||
entry.Host = parts[0]
|
||
entry.Port = port
|
||
entry.Username = parts[2]
|
||
entry.Password = parts[3]
|
||
default:
|
||
return nil, fmt.Errorf("无法解析: %s", line)
|
||
}
|
||
}
|
||
|
||
return entry, nil
|
||
}
|
||
|
||
// parseHostPort 解析 host:port(支持 IPv6 如 [::1]:8080)
|
||
func parseHostPort(s string) (string, int, error) {
|
||
host, portStr, err := net.SplitHostPort(s)
|
||
if err != nil {
|
||
return "", 0, fmt.Errorf("无效 host:port: %s", s)
|
||
}
|
||
port, err := strconv.Atoi(portStr)
|
||
if err != nil {
|
||
return "", 0, fmt.Errorf("端口格式错误: %s", portStr)
|
||
}
|
||
return host, port, nil
|
||
}
|
||
|
||
// ── 健康检查 ────────────────────────────────────────────────────────
|
||
|
||
// 健康检查冷却:防止频繁触发导致大量出站连接
|
||
var (
|
||
healthCheckMu sync.Mutex
|
||
healthCheckLastTime time.Time
|
||
)
|
||
|
||
const healthCheckCooldown = 30 * time.Second // 最短间隔 30 秒
|
||
|
||
// HealthCheck 触发代理池健康检查(POST /api/admin/proxy-pool/health-check)
|
||
// 支持选择性检查:请求体含 ids 字段时仅检查指定代理(跳过冷却),为空时检查全部
|
||
func (h *ProxyPoolHandler) HealthCheck(c *gin.Context) {
|
||
var req struct {
|
||
IDs []uint `json:"ids"`
|
||
}
|
||
// 忽略绑定错误,允许空 body(兼容旧调用)
|
||
_ = c.ShouldBindJSON(&req)
|
||
|
||
// 指定 ID 列表:小批量检查,跳过全局冷却
|
||
if len(req.IDs) > 0 {
|
||
if len(req.IDs) > 500 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": "单次最多检查 500 个代理"})
|
||
return
|
||
}
|
||
total, healthy, unhealthy := h.proxyPool.HealthCheckByIDs(req.IDs)
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"total": total,
|
||
"healthy": healthy,
|
||
"unhealthy": unhealthy,
|
||
"message": fmt.Sprintf("检查完成:共 %d 个,健康 %d,不健康 %d", total, healthy, unhealthy),
|
||
})
|
||
return
|
||
}
|
||
|
||
// 全量检查:受 30 秒冷却限制
|
||
healthCheckMu.Lock()
|
||
if time.Since(healthCheckLastTime) < healthCheckCooldown {
|
||
remaining := healthCheckCooldown - time.Since(healthCheckLastTime)
|
||
healthCheckMu.Unlock()
|
||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||
"detail": fmt.Sprintf("健康检查冷却中,请 %d 秒后再试", int(remaining.Seconds())+1),
|
||
"retry_after": int(remaining.Seconds()) + 1,
|
||
})
|
||
return
|
||
}
|
||
healthCheckLastTime = time.Now()
|
||
healthCheckMu.Unlock()
|
||
|
||
total, healthy, unhealthy := h.proxyPool.HealthCheckAll()
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"total": total,
|
||
"healthy": healthy,
|
||
"unhealthy": unhealthy,
|
||
"message": fmt.Sprintf("检查完成:共 %d 个,健康 %d,不健康 %d", total, healthy, unhealthy),
|
||
})
|
||
}
|
||
|
||
// Stats 代理池统计(GET /api/admin/proxy-pool/stats)
|
||
func (h *ProxyPoolHandler) Stats(c *gin.Context) {
|
||
stats := h.proxyPool.PoolStats()
|
||
c.JSON(http.StatusOK, stats)
|
||
}
|
||
|
||
// PurgeUnhealthy 清除所有不健康代理(POST /api/admin/proxy-pool/purge)
|
||
func (h *ProxyPoolHandler) PurgeUnhealthy(c *gin.Context) {
|
||
result := h.db.Where("is_healthy = ?", false).Delete(&model.ProxyPoolEntry{})
|
||
if result.Error != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"detail": "清除失败"})
|
||
return
|
||
}
|
||
h.proxyPool.Reload()
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"deleted": result.RowsAffected,
|
||
"message": fmt.Sprintf("已清除 %d 个不健康代理", result.RowsAffected),
|
||
})
|
||
}
|
||
|
||
// ResetHealth 重置指定代理的健康状态(POST /api/admin/proxy-pool/:id/reset)
|
||
func (h *ProxyPoolHandler) ResetHealth(c *gin.Context) {
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 32)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": "无效的 ID"})
|
||
return
|
||
}
|
||
now := time.Now()
|
||
result := h.db.Model(&model.ProxyPoolEntry{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||
"is_healthy": true,
|
||
"fail_count": 0,
|
||
"last_checked_at": now,
|
||
})
|
||
if result.RowsAffected == 0 {
|
||
c.JSON(http.StatusNotFound, gin.H{"detail": "代理不存在"})
|
||
return
|
||
}
|
||
h.proxyPool.Reload()
|
||
c.JSON(http.StatusOK, gin.H{"message": "已重置健康状态"})
|
||
}
|
||
|
||
// ── 从 URL 抓取 ────────────────────────────────────────────────────
|
||
|
||
// FetchURL 从 URL 抓取代理列表并导入(POST /api/admin/proxy-pool/fetch-url)
|
||
func (h *ProxyPoolHandler) FetchURL(c *gin.Context) {
|
||
var req struct {
|
||
URL string `json:"url" binding:"required"`
|
||
Protocol string `json:"protocol"`
|
||
}
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||
return
|
||
}
|
||
|
||
defaultProto := req.Protocol
|
||
if defaultProto == "" {
|
||
defaultProto = "socks5"
|
||
}
|
||
|
||
allowedProtos := map[string]bool{"http": true, "https": true, "socks5": true}
|
||
if !allowedProtos[defaultProto] {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("不支持的协议: %s,仅支持 http/https/socks5", defaultProto)})
|
||
return
|
||
}
|
||
|
||
// SSRF 防护:校验 URL 协议白名单
|
||
if err := netutil.ValidateURLScheme(req.URL); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||
return
|
||
}
|
||
// SSRF 防护:校验 URL 主机不指向内网
|
||
if err := netutil.ValidateURLHost(req.URL); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": err.Error()})
|
||
return
|
||
}
|
||
|
||
// 使用 SSRF 安全客户端下载
|
||
client := netutil.NewSSRFSafeClient(30 * time.Second)
|
||
httpReq, err := http.NewRequest("GET", req.URL, nil)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"detail": fmt.Sprintf("无效的 URL: %s", err.Error())})
|
||
return
|
||
}
|
||
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36")
|
||
httpReq.Header.Set("Accept", "text/plain, */*")
|
||
|
||
resp, err := client.Do(httpReq)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadGateway, gin.H{"detail": fmt.Sprintf("抓取失败: %s", err.Error())})
|
||
return
|
||
}
|
||
defer resp.Body.Close()
|
||
|
||
if resp.StatusCode != http.StatusOK {
|
||
c.JSON(http.StatusBadGateway, gin.H{"detail": fmt.Sprintf("远程返回 HTTP %d", resp.StatusCode)})
|
||
return
|
||
}
|
||
|
||
// 限制读取大小(5MB)
|
||
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
|
||
if err != nil {
|
||
c.JSON(http.StatusBadGateway, gin.H{"detail": fmt.Sprintf("读取内容失败: %s", err.Error())})
|
||
return
|
||
}
|
||
|
||
// 逐行解析并导入
|
||
const maxLines = 2000
|
||
scanner := bufio.NewScanner(strings.NewReader(string(body)))
|
||
var imported, skipped, failed int
|
||
|
||
for scanner.Scan() {
|
||
line := strings.TrimSpace(scanner.Text())
|
||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "//") {
|
||
continue
|
||
}
|
||
|
||
if imported+skipped+failed >= maxLines {
|
||
failed++
|
||
continue
|
||
}
|
||
|
||
entry, err := parseProxyLine(line, defaultProto)
|
||
if err != nil {
|
||
failed++
|
||
continue
|
||
}
|
||
|
||
// 协议白名单
|
||
if !allowedProtos[entry.Protocol] {
|
||
failed++
|
||
continue
|
||
}
|
||
|
||
// SSRF 防护
|
||
if netutil.IsPrivateHost(entry.Host) {
|
||
failed++
|
||
continue
|
||
}
|
||
|
||
// 去重
|
||
var count int64
|
||
h.db.Model(&model.ProxyPoolEntry{}).Where("host = ? AND port = ?", entry.Host, entry.Port).Count(&count)
|
||
if count > 0 {
|
||
skipped++
|
||
continue
|
||
}
|
||
|
||
entry.Source = "fetched"
|
||
if err := h.db.Create(entry).Error; err != nil {
|
||
failed++
|
||
continue
|
||
}
|
||
imported++
|
||
}
|
||
|
||
h.proxyPool.Reload()
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"imported": imported,
|
||
"skipped": skipped,
|
||
"failed": failed,
|
||
"message": fmt.Sprintf("抓取完成:成功 %d,重复跳过 %d,失败 %d", imported, skipped, failed),
|
||
})
|
||
}
|