feat: implement core backend for team owner management with SQLite, auto-add APIs, and a frontend owner list component.
This commit is contained in:
@@ -111,6 +111,7 @@ func startServer(cfg *config.Config) {
|
||||
mux.HandleFunc("/api/db/owners", api.CORS(handleGetOwners))
|
||||
mux.HandleFunc("/api/db/owners/stats", api.CORS(handleGetOwnerStats))
|
||||
mux.HandleFunc("/api/db/owners/clear", api.CORS(handleClearOwners))
|
||||
mux.HandleFunc("/api/db/owners/clear-used", api.CORS(handleClearUsedOwners)) // 清理已使用
|
||||
mux.HandleFunc("/api/db/owners/delete/", api.CORS(handleDeleteOwner)) // DELETE /api/db/owners/delete/{id}
|
||||
mux.HandleFunc("/api/db/owners/batch-delete", api.CORS(handleBatchDeleteOwners)) // POST 批量删除
|
||||
mux.HandleFunc("/api/db/owners/refetch-account-ids", api.CORS(api.HandleRefetchAccountIDs))
|
||||
@@ -609,7 +610,24 @@ func handleGetOwners(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
owners, total, err := database.Instance.GetTeamOwners("", 50, 0)
|
||||
// 读取分页参数
|
||||
query := r.URL.Query()
|
||||
limit := 20
|
||||
offset := 0
|
||||
status := query.Get("status")
|
||||
|
||||
if l := query.Get("limit"); l != "" {
|
||||
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||
limit = parsed
|
||||
}
|
||||
}
|
||||
if o := query.Get("offset"); o != "" {
|
||||
if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
|
||||
offset = parsed
|
||||
}
|
||||
}
|
||||
|
||||
owners, total, err := database.Instance.GetTeamOwners(status, limit, offset)
|
||||
if err != nil {
|
||||
api.Error(w, http.StatusInternalServerError, fmt.Sprintf("查询失败: %v", err))
|
||||
return
|
||||
@@ -645,6 +663,25 @@ func handleClearOwners(w http.ResponseWriter, r *http.Request) {
|
||||
api.Success(w, map[string]string{"message": "已清空"})
|
||||
}
|
||||
|
||||
// handleClearUsedOwners POST /api/db/owners/clear-used - 清理已使用的母号
|
||||
func handleClearUsedOwners(w http.ResponseWriter, r *http.Request) {
|
||||
if database.Instance == nil {
|
||||
api.Error(w, http.StatusInternalServerError, "数据库未初始化")
|
||||
return
|
||||
}
|
||||
|
||||
deleted, err := database.Instance.ClearUsedOwners()
|
||||
if err != nil {
|
||||
api.Error(w, http.StatusInternalServerError, fmt.Sprintf("清理失败: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
api.Success(w, map[string]interface{}{
|
||||
"message": fmt.Sprintf("已清理 %d 个已使用的母号", deleted),
|
||||
"deleted": deleted,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteOwner DELETE /api/db/owners/delete/{id} - 删除单个 owner
|
||||
func handleDeleteOwner(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodDelete && r.Method != http.MethodPost {
|
||||
|
||||
@@ -206,12 +206,21 @@ func getS2AAccountCount() (int, error) {
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data struct {
|
||||
NormalAccounts int `json:"normal_accounts"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
if err := decodeJSON(resp.Body, &result); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return result.NormalAccounts, nil
|
||||
// S2A 返回 code=0 表示成功
|
||||
if result.Code != 0 {
|
||||
return 0, fmt.Errorf("S2A 返回错误: %s", result.Message)
|
||||
}
|
||||
|
||||
return result.Data.NormalAccounts, nil
|
||||
}
|
||||
|
||||
@@ -292,7 +292,8 @@ func processSingleTeam(idx int, req TeamProcessRequest) TeamProcessResult {
|
||||
Errors: make([]string, 0),
|
||||
}
|
||||
|
||||
logPrefix := fmt.Sprintf("[Team %d]", idx+1)
|
||||
// 固定宽度的 Team 编号 (支持到 Team 99)
|
||||
logPrefix := fmt.Sprintf("[Team %2d]", idx+1)
|
||||
logger.Info(fmt.Sprintf("%s 开始处理 | 母号: %s", logPrefix, owner.Email), owner.Email, "team")
|
||||
|
||||
// 标记 owner 为处理中
|
||||
@@ -385,12 +386,23 @@ func processSingleTeam(idx int, req TeamProcessRequest) TeamProcessResult {
|
||||
// 重试时使用新邮箱
|
||||
currentEmail = mail.GenerateEmail()
|
||||
currentPassword = register.GeneratePassword()
|
||||
logger.Warning(fmt.Sprintf("%s [成员 %d] 重试,新邮箱: %s", logPrefix, memberIdx+1, currentEmail), currentEmail, "team")
|
||||
logger.Warning(fmt.Sprintf("%s [成员 %d] 重试, 新邮箱: %s", logPrefix, memberIdx+1, currentEmail), currentEmail, "team")
|
||||
}
|
||||
|
||||
// 发送邀请
|
||||
if err := inviter.SendInvites([]string{currentEmail}); err != nil {
|
||||
errStr := err.Error()
|
||||
logger.Error(fmt.Sprintf("%s [成员 %d] 邀请失败: %v", logPrefix, memberIdx+1, err), currentEmail, "team")
|
||||
|
||||
// 检测 Team 已达邀请上限
|
||||
if strings.Contains(errStr, "maximum number of seats") {
|
||||
logger.Warning(fmt.Sprintf("%s Team 邀请已满,标记母号为已使用", logPrefix), owner.Email, "team")
|
||||
if database.Instance != nil {
|
||||
database.Instance.MarkOwnerAsUsed(owner.Email)
|
||||
}
|
||||
// 跳出重试,该成员不再处理
|
||||
return false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
|
||||
@@ -189,7 +189,8 @@ func (d *DB) GetTeamOwners(status string, limit, offset int) ([]TeamOwner, int,
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
// 排序:已使用(used, pooled)排前面,其次按创建时间降序
|
||||
query += " ORDER BY CASE WHEN status IN ('used', 'pooled') THEN 0 ELSE 1 END, created_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := d.db.Query(query, args...)
|
||||
@@ -277,6 +278,15 @@ func (d *DB) ClearTeamOwners() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// ClearUsedOwners 清理已使用的母号(status = 'used' 或 'pooled')
|
||||
func (d *DB) ClearUsedOwners() (int64, error) {
|
||||
result, err := d.db.Exec("DELETE FROM team_owners WHERE status IN ('used', 'pooled')")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
// GetOwnersWithoutAccountID 获取缺少 account_id 的 owners
|
||||
func (d *DB) GetOwnersWithoutAccountID() ([]TeamOwner, error) {
|
||||
rows, err := d.db.Query(`
|
||||
|
||||
@@ -132,6 +132,14 @@ func log(level, message, email, module string) {
|
||||
color = colorYellow
|
||||
}
|
||||
|
||||
// 模块名固定宽度(8字符)
|
||||
moduleStr := module
|
||||
if len(moduleStr) < 8 {
|
||||
moduleStr = moduleStr + strings.Repeat(" ", 8-len(moduleStr))
|
||||
} else if len(moduleStr) > 8 {
|
||||
moduleStr = moduleStr[:8]
|
||||
}
|
||||
|
||||
// 如果是 Team 相关日志,消息使用 Team 颜色
|
||||
msgColor := colorReset
|
||||
if teamColor != "" {
|
||||
@@ -139,15 +147,20 @@ func log(level, message, email, module string) {
|
||||
}
|
||||
|
||||
if email != "" {
|
||||
fmt.Printf("%s%s%s %s[%s]%s [%s] %s - %s%s%s\n",
|
||||
// 截断长邮箱,保持对齐
|
||||
emailDisplay := email
|
||||
if len(emailDisplay) > 35 {
|
||||
emailDisplay = emailDisplay[:32] + "..."
|
||||
}
|
||||
fmt.Printf("%s%s%s %s[%s]%s [%s] %s%s%s\n",
|
||||
colorGray, timestamp, colorReset,
|
||||
color, prefix, colorReset,
|
||||
module, email, msgColor, message, colorReset)
|
||||
moduleStr, msgColor, message, colorReset)
|
||||
} else {
|
||||
fmt.Printf("%s%s%s %s[%s]%s [%s] %s%s%s\n",
|
||||
colorGray, timestamp, colorReset,
|
||||
color, prefix, colorReset,
|
||||
module, msgColor, message, colorReset)
|
||||
moduleStr, msgColor, message, colorReset)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,14 +16,16 @@ const statusColors: Record<string, string> = {
|
||||
pooled: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
||||
processing: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
|
||||
invalid: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
used: 'bg-gray-100 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
valid: '有效',
|
||||
registered: '已注册',
|
||||
pooled: '已入库',
|
||||
processing: 'processing',
|
||||
processing: '处理中',
|
||||
invalid: '无效',
|
||||
used: '已使用',
|
||||
}
|
||||
|
||||
interface OwnerListProps {
|
||||
@@ -131,6 +133,24 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
|
||||
}
|
||||
}
|
||||
|
||||
const handleClearUsed = async () => {
|
||||
if (!confirm('确认清理所有已使用的母号?')) return
|
||||
try {
|
||||
const res = await fetch('/api/db/owners/clear-used', { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.code === 0) {
|
||||
alert(data.data.message)
|
||||
loadOwners()
|
||||
onStatsChange?.()
|
||||
} else {
|
||||
alert(data.message || '清理失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to clear used:', e)
|
||||
alert('清理失败')
|
||||
}
|
||||
}
|
||||
|
||||
const [refetching, setRefetching] = useState(false)
|
||||
|
||||
const handleRefetchAccountIds = async () => {
|
||||
@@ -212,6 +232,7 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
|
||||
<option value="valid">有效</option>
|
||||
<option value="registered">已注册</option>
|
||||
<option value="pooled">已入库</option>
|
||||
<option value="used">已使用</option>
|
||||
<option value="invalid">无效</option>
|
||||
</select>
|
||||
<Button variant="ghost" size="sm" onClick={loadOwners} icon={<RefreshCw className="h-4 w-4" />}>
|
||||
@@ -239,6 +260,15 @@ export default function OwnerList({ onStatsChange }: OwnerListProps) {
|
||||
{deleting ? '删除中...' : `删除选中 (${selectedIds.size})`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearUsed}
|
||||
icon={<Trash2 className="h-4 w-4" />}
|
||||
className="text-yellow-600 hover:text-yellow-700"
|
||||
>
|
||||
清理已使用
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
Reference in New Issue
Block a user