feat: implement core backend for team owner management with SQLite, auto-add APIs, and a frontend owner list component.

This commit is contained in:
2026-01-30 20:20:35 +08:00
parent 3c5bb04d82
commit 10cda012af
6 changed files with 125 additions and 14 deletions

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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
}
@@ -538,7 +550,7 @@ func processSingleTeam(idx int, req TeamProcessRequest) TeamProcessResult {
result.Errors = append(result.Errors, fmt.Sprintf("Owner S2A: %v", err))
} else {
result.AddedToS2A++
logger.Success(fmt.Sprintf("%s [母号] ✓ 入库成功", logPrefix), owner.Email, "team")
logger.Success(fmt.Sprintf("%s [母号 ] ✓ 入库成功", logPrefix), owner.Email, "team")
}
}
}

View File

@@ -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(`

View File

@@ -119,19 +119,27 @@ func log(level, message, email, module string) {
color := ""
switch level {
case "info":
prefix = "INFO"
prefix = "INFO "
color = colorCyan
case "success":
prefix = "SUCCESS"
color = colorGreen
case "error":
prefix = "ERROR"
prefix = "ERROR "
color = colorRed
case "warning":
prefix = "WARN"
prefix = "WARN "
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)
}
}

View File

@@ -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"