feat: Implement core backend server with file upload, monitoring, S2A API proxy, and team processing features.
This commit is contained in:
@@ -104,6 +104,7 @@ func startServer(cfg *config.Config) {
|
|||||||
mux.HandleFunc("/api/db/owners", api.CORS(handleGetOwners))
|
mux.HandleFunc("/api/db/owners", api.CORS(handleGetOwners))
|
||||||
mux.HandleFunc("/api/db/owners/stats", api.CORS(handleGetOwnerStats))
|
mux.HandleFunc("/api/db/owners/stats", api.CORS(handleGetOwnerStats))
|
||||||
mux.HandleFunc("/api/db/owners/clear", api.CORS(handleClearOwners))
|
mux.HandleFunc("/api/db/owners/clear", api.CORS(handleClearOwners))
|
||||||
|
mux.HandleFunc("/api/upload/validate", api.CORS(handleUploadValidate))
|
||||||
|
|
||||||
// 注册测试 API
|
// 注册测试 API
|
||||||
mux.HandleFunc("/api/register/test", api.CORS(handleRegisterTest))
|
mux.HandleFunc("/api/register/test", api.CORS(handleRegisterTest))
|
||||||
|
|||||||
273
backend/cmd/upload.go
Normal file
273
backend/cmd/upload.go
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"codex-pool/internal/api"
|
||||||
|
"codex-pool/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
type uploadValidateRequest struct {
|
||||||
|
Content string `json:"content"`
|
||||||
|
Accounts []accountRecord `json:"accounts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type accountRecord struct {
|
||||||
|
Account string `json:"account"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
AccessTok string `json:"access_token"`
|
||||||
|
AccountID string `json:"account_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUploadValidate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
api.Error(w, http.StatusMethodNotAllowed, "仅支持 POST")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if database.Instance == nil {
|
||||||
|
api.Error(w, http.StatusInternalServerError, "数据库未初始化")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 10<<20))
|
||||||
|
if err != nil {
|
||||||
|
api.Error(w, http.StatusBadRequest, "读取请求失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req uploadValidateRequest
|
||||||
|
if err := json.Unmarshal(body, &req); err != nil {
|
||||||
|
// 如果不是 JSON,直接把 body 当作原始内容
|
||||||
|
req.Content = string(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []accountRecord
|
||||||
|
switch {
|
||||||
|
case len(req.Accounts) > 0:
|
||||||
|
records = req.Accounts
|
||||||
|
case strings.TrimSpace(req.Content) != "":
|
||||||
|
parsed, parseErr := parseAccountsFlexible(req.Content)
|
||||||
|
if parseErr != nil {
|
||||||
|
api.Error(w, http.StatusBadRequest, parseErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
records = parsed
|
||||||
|
default:
|
||||||
|
api.Error(w, http.StatusBadRequest, "未提供账号内容")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
owners := make([]database.TeamOwner, 0, len(records))
|
||||||
|
for i, rec := range records {
|
||||||
|
owner, err := normalizeOwner(rec, i+1)
|
||||||
|
if err != nil {
|
||||||
|
api.Error(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
owners = append(owners, owner)
|
||||||
|
}
|
||||||
|
if len(owners) == 0 {
|
||||||
|
api.Error(w, http.StatusBadRequest, "未解析到有效账号")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
inserted, err := database.Instance.AddTeamOwners(owners)
|
||||||
|
if err != nil {
|
||||||
|
api.Error(w, http.StatusInternalServerError, fmt.Sprintf("写入数据库失败: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := database.Instance.GetOwnerStats()
|
||||||
|
api.Success(w, map[string]interface{}{
|
||||||
|
"imported": inserted,
|
||||||
|
"total": len(owners),
|
||||||
|
"stats": stats,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeOwner(rec accountRecord, index int) (database.TeamOwner, error) {
|
||||||
|
email := strings.TrimSpace(rec.Email)
|
||||||
|
if email == "" {
|
||||||
|
email = strings.TrimSpace(rec.Account)
|
||||||
|
}
|
||||||
|
if email == "" {
|
||||||
|
return database.TeamOwner{}, fmt.Errorf("第 %d 条记录缺少 account/email 字段", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
password := strings.TrimSpace(rec.Password)
|
||||||
|
if password == "" {
|
||||||
|
return database.TeamOwner{}, fmt.Errorf("第 %d 条记录缺少 password 字段", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
token := strings.TrimSpace(rec.Token)
|
||||||
|
if token == "" {
|
||||||
|
token = strings.TrimSpace(rec.AccessTok)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return database.TeamOwner{}, fmt.Errorf("第 %d 条记录缺少 token 字段", index)
|
||||||
|
}
|
||||||
|
|
||||||
|
accountID := strings.TrimSpace(rec.AccountID)
|
||||||
|
|
||||||
|
return database.TeamOwner{
|
||||||
|
Email: email,
|
||||||
|
Password: password,
|
||||||
|
Token: token,
|
||||||
|
AccountID: accountID,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseAccountsFlexible(raw string) ([]accountRecord, error) {
|
||||||
|
raw = strings.TrimSpace(strings.TrimPrefix(raw, "\uFEFF"))
|
||||||
|
if raw == "" {
|
||||||
|
return nil, fmt.Errorf("内容为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
cleaned := stripJSONComments(raw)
|
||||||
|
cleaned = removeTrailingCommas(cleaned)
|
||||||
|
trimmed := strings.TrimSpace(cleaned)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil, fmt.Errorf("内容为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(trimmed, "[") {
|
||||||
|
var arr []accountRecord
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &arr); err == nil {
|
||||||
|
return arr, nil
|
||||||
|
}
|
||||||
|
} else if strings.HasPrefix(trimmed, "{") {
|
||||||
|
var single accountRecord
|
||||||
|
if err := json.Unmarshal([]byte(trimmed), &single); err == nil {
|
||||||
|
return []accountRecord{single}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSONL 回退
|
||||||
|
lines := strings.Split(raw, "\n")
|
||||||
|
records := make([]accountRecord, 0, len(lines))
|
||||||
|
for i, line := range lines {
|
||||||
|
line = strings.TrimSpace(stripJSONComments(line))
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(removeTrailingCommas(line))
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var rec accountRecord
|
||||||
|
if err := json.Unmarshal([]byte(line), &rec); err != nil {
|
||||||
|
return nil, fmt.Errorf("第 %d 行解析失败: %v", i+1, err)
|
||||||
|
}
|
||||||
|
records = append(records, rec)
|
||||||
|
}
|
||||||
|
if len(records) == 0 {
|
||||||
|
return nil, fmt.Errorf("未解析到有效账号")
|
||||||
|
}
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripJSONComments(input string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(input))
|
||||||
|
|
||||||
|
inString := false
|
||||||
|
escaped := false
|
||||||
|
for i := 0; i < len(input); i++ {
|
||||||
|
ch := input[i]
|
||||||
|
if inString {
|
||||||
|
b.WriteByte(ch)
|
||||||
|
if escaped {
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '\\' {
|
||||||
|
escaped = true
|
||||||
|
} else if ch == '"' {
|
||||||
|
inString = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '"' {
|
||||||
|
inString = true
|
||||||
|
b.WriteByte(ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '/' && i+1 < len(input) && input[i+1] == '/' {
|
||||||
|
for i+1 < len(input) && input[i+1] != '\n' {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '/' && i+1 < len(input) && input[i+1] == '*' {
|
||||||
|
i += 2
|
||||||
|
for i+1 < len(input) {
|
||||||
|
if input[i] == '*' && input[i+1] == '/' {
|
||||||
|
i++
|
||||||
|
break
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteByte(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeTrailingCommas(input string) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(input))
|
||||||
|
|
||||||
|
inString := false
|
||||||
|
escaped := false
|
||||||
|
for i := 0; i < len(input); i++ {
|
||||||
|
ch := input[i]
|
||||||
|
if inString {
|
||||||
|
b.WriteByte(ch)
|
||||||
|
if escaped {
|
||||||
|
escaped = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ch == '\\' {
|
||||||
|
escaped = true
|
||||||
|
} else if ch == '"' {
|
||||||
|
inString = false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == '"' {
|
||||||
|
inString = true
|
||||||
|
b.WriteByte(ch)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if ch == ',' {
|
||||||
|
j := i + 1
|
||||||
|
for j < len(input) && unicode.IsSpace(rune(input[j])) {
|
||||||
|
j++
|
||||||
|
}
|
||||||
|
if j < len(input) && (input[j] == ']' || input[j] == '}') {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
b.WriteByte(ch)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -98,7 +98,7 @@ export default function FileDropzone({ onFileSelect, disabled = false, error }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-xs text-slate-400 dark:text-slate-500">
|
<div className="text-xs text-slate-400 dark:text-slate-500">
|
||||||
支持格式: [{"account": "email", "password": "pwd", "token": "..."}]
|
支持格式: JSON 数组/单对象/JSONL(支持注释与末尾逗号)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,31 +65,29 @@ export default function Monitor() {
|
|||||||
const [pollingEnabled, setPollingEnabled] = useState(false)
|
const [pollingEnabled, setPollingEnabled] = useState(false)
|
||||||
const [pollingInterval, setPollingInterval] = useState(60)
|
const [pollingInterval, setPollingInterval] = useState(60)
|
||||||
|
|
||||||
// 使用后端代理路径
|
// 监控接口属于本地后端服务(通常在 8088 端口)
|
||||||
const proxyBase = '/api/s2a/proxy'
|
const localBase = window.location.protocol + '//' + window.location.hostname + ':8088'
|
||||||
|
|
||||||
// 辅助函数:解包 S2A 响应
|
// 辅助函数:处理本地请求
|
||||||
const requestS2A = async (url: string, options: RequestInit = {}) => {
|
const requestLocal = async (path: string, options: RequestInit = {}) => {
|
||||||
const res = await fetch(url, options)
|
const res = await fetch(`${localBase}${path}`, options)
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
const data = await res.json()
|
return res.json()
|
||||||
if (data && typeof data === 'object' && 'code' in data && 'data' in data) {
|
|
||||||
if (data.code !== 0) throw new Error(data.message || 'API error')
|
|
||||||
return data.data
|
|
||||||
}
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取号池状态
|
// 获取号池状态
|
||||||
const fetchPoolStatus = useCallback(async () => {
|
const fetchPoolStatus = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await requestS2A(`${proxyBase}/api/pool/status`)
|
const res = await requestLocal('/api/pool/status')
|
||||||
|
if (res.code === 0) {
|
||||||
|
const data = res.data
|
||||||
setPoolStatus(data)
|
setPoolStatus(data)
|
||||||
setTargetInput(data.target)
|
setTargetInput(data.target)
|
||||||
setAutoAdd(data.auto_add)
|
setAutoAdd(data.auto_add)
|
||||||
setMinInterval(data.min_interval)
|
setMinInterval(data.min_interval)
|
||||||
setPollingEnabled(data.polling_enabled)
|
setPollingEnabled(data.polling_enabled)
|
||||||
setPollingInterval(data.polling_interval)
|
setPollingInterval(data.polling_interval)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取号池状态失败:', e)
|
console.error('获取号池状态失败:', e)
|
||||||
}
|
}
|
||||||
@@ -99,8 +97,10 @@ export default function Monitor() {
|
|||||||
const refreshStats = useCallback(async () => {
|
const refreshStats = useCallback(async () => {
|
||||||
setRefreshing(true)
|
setRefreshing(true)
|
||||||
try {
|
try {
|
||||||
const data = await requestS2A(`${proxyBase}/api/pool/refresh`, { method: 'POST' })
|
const res = await requestLocal('/api/pool/refresh', { method: 'POST' })
|
||||||
setStats(data)
|
if (res.code === 0) {
|
||||||
|
setStats(res.data)
|
||||||
|
}
|
||||||
await fetchPoolStatus()
|
await fetchPoolStatus()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('刷新统计失败:', e)
|
console.error('刷新统计失败:', e)
|
||||||
@@ -112,7 +112,7 @@ export default function Monitor() {
|
|||||||
const handleSetTarget = async () => {
|
const handleSetTarget = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await requestS2A(`${proxyBase}/api/pool/target`, {
|
await requestLocal('/api/pool/target', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -132,7 +132,7 @@ export default function Monitor() {
|
|||||||
const handleTogglePolling = async () => {
|
const handleTogglePolling = async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
await requestS2A(`${proxyBase}/api/pool/polling`, {
|
await requestLocal('/api/pool/polling', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -152,7 +152,7 @@ export default function Monitor() {
|
|||||||
const handleHealthCheck = async (autoPause: boolean = false) => {
|
const handleHealthCheck = async (autoPause: boolean = false) => {
|
||||||
setCheckingHealth(true)
|
setCheckingHealth(true)
|
||||||
try {
|
try {
|
||||||
await requestS2A(`${proxyBase}/api/health-check/start`, {
|
await requestLocal('/api/health-check/start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ auto_pause: autoPause }),
|
body: JSON.stringify({ auto_pause: autoPause }),
|
||||||
@@ -160,8 +160,10 @@ export default function Monitor() {
|
|||||||
// 等待一会儿再获取结果
|
// 等待一会儿再获取结果
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const data = await requestS2A(`${proxyBase}/api/health-check/results`)
|
const res = await requestLocal('/api/health-check/results')
|
||||||
setHealthResults(data || [])
|
if (res.code === 0) {
|
||||||
|
setHealthResults(res.data || [])
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取健康检查结果失败:', e)
|
console.error('获取健康检查结果失败:', e)
|
||||||
}
|
}
|
||||||
@@ -176,8 +178,10 @@ export default function Monitor() {
|
|||||||
// 获取自动补号日志
|
// 获取自动补号日志
|
||||||
const fetchAutoAddLogs = async () => {
|
const fetchAutoAddLogs = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await requestS2A(`${proxyBase}/api/auto-add/logs`)
|
const res = await requestLocal('/api/auto-add/logs')
|
||||||
setAutoAddLogs(data || [])
|
if (res.code === 0) {
|
||||||
|
setAutoAddLogs(res.data || [])
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取日志失败:', e)
|
console.error('获取日志失败:', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,13 +120,10 @@ export default function Upload() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await file.text()
|
const text = await file.text()
|
||||||
const json = JSON.parse(text)
|
|
||||||
const accounts = Array.isArray(json) ? json : [json]
|
|
||||||
|
|
||||||
const res = await fetch('/api/upload/validate', {
|
const res = await fetch('/api/upload/validate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ accounts }),
|
body: JSON.stringify({ content: text, filename: file.name }),
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|||||||
Reference in New Issue
Block a user