Commit 65538280 authored by ianshaw's avatar ianshaw
Browse files

feat(account): 添加从 CRS 同步账户功能

- 添加账户同步 API 接口 (account_handler.go)
- 实现 CRS 同步服务 (crs_sync_service.go)
- 添加前端同步对话框组件 (SyncFromCrsModal.vue)
- 更新账户管理界面支持同步操作
- 添加账户仓库批量创建方法
- 添加中英文国际化翻译
- 更新依赖注入配置
parent adcb7bf0
...@@ -86,7 +86,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) { ...@@ -86,7 +86,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream) accountTestService := service.NewAccountTestService(accountRepository, oAuthService, openAIOAuthService, httpUpstream)
concurrencyCache := repository.NewConcurrencyCache(client) concurrencyCache := repository.NewConcurrencyCache(client)
concurrencyService := service.NewConcurrencyService(concurrencyCache) concurrencyService := service.NewConcurrencyService(concurrencyCache)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService) crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository)
accountHandler := admin.NewAccountHandler(adminService, oAuthService, openAIOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService)
oAuthHandler := admin.NewOAuthHandler(oAuthService) oAuthHandler := admin.NewOAuthHandler(oAuthService)
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService) openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService)
proxyHandler := admin.NewProxyHandler(adminService) proxyHandler := admin.NewProxyHandler(adminService)
......
...@@ -34,10 +34,20 @@ type AccountHandler struct { ...@@ -34,10 +34,20 @@ type AccountHandler struct {
accountUsageService *service.AccountUsageService accountUsageService *service.AccountUsageService
accountTestService *service.AccountTestService accountTestService *service.AccountTestService
concurrencyService *service.ConcurrencyService concurrencyService *service.ConcurrencyService
crsSyncService *service.CRSSyncService
} }
// NewAccountHandler creates a new admin account handler // NewAccountHandler creates a new admin account handler
func NewAccountHandler(adminService service.AdminService, oauthService *service.OAuthService, openaiOAuthService *service.OpenAIOAuthService, rateLimitService *service.RateLimitService, accountUsageService *service.AccountUsageService, accountTestService *service.AccountTestService, concurrencyService *service.ConcurrencyService) *AccountHandler { func NewAccountHandler(
adminService service.AdminService,
oauthService *service.OAuthService,
openaiOAuthService *service.OpenAIOAuthService,
rateLimitService *service.RateLimitService,
accountUsageService *service.AccountUsageService,
accountTestService *service.AccountTestService,
concurrencyService *service.ConcurrencyService,
crsSyncService *service.CRSSyncService,
) *AccountHandler {
return &AccountHandler{ return &AccountHandler{
adminService: adminService, adminService: adminService,
oauthService: oauthService, oauthService: oauthService,
...@@ -46,6 +56,7 @@ func NewAccountHandler(adminService service.AdminService, oauthService *service. ...@@ -46,6 +56,7 @@ func NewAccountHandler(adminService service.AdminService, oauthService *service.
accountUsageService: accountUsageService, accountUsageService: accountUsageService,
accountTestService: accountTestService, accountTestService: accountTestService,
concurrencyService: concurrencyService, concurrencyService: concurrencyService,
crsSyncService: crsSyncService,
} }
} }
...@@ -224,6 +235,13 @@ type TestAccountRequest struct { ...@@ -224,6 +235,13 @@ type TestAccountRequest struct {
ModelID string `json:"model_id"` ModelID string `json:"model_id"`
} }
type SyncFromCRSRequest struct {
BaseURL string `json:"base_url" binding:"required"`
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
SyncProxies *bool `json:"sync_proxies"`
}
// Test handles testing account connectivity with SSE streaming // Test handles testing account connectivity with SSE streaming
// POST /api/v1/admin/accounts/:id/test // POST /api/v1/admin/accounts/:id/test
func (h *AccountHandler) Test(c *gin.Context) { func (h *AccountHandler) Test(c *gin.Context) {
...@@ -244,6 +262,35 @@ func (h *AccountHandler) Test(c *gin.Context) { ...@@ -244,6 +262,35 @@ func (h *AccountHandler) Test(c *gin.Context) {
} }
} }
// SyncFromCRS handles syncing accounts from claude-relay-service (CRS)
// POST /api/v1/admin/accounts/sync/crs
func (h *AccountHandler) SyncFromCRS(c *gin.Context) {
var req SyncFromCRSRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
// Default to syncing proxies (can be disabled by explicitly setting false)
syncProxies := true
if req.SyncProxies != nil {
syncProxies = *req.SyncProxies
}
result, err := h.crsSyncService.SyncFromCRS(c.Request.Context(), service.SyncFromCRSInput{
BaseURL: req.BaseURL,
Username: req.Username,
Password: req.Password,
SyncProxies: syncProxies,
})
if err != nil {
response.BadRequest(c, "Sync failed: "+err.Error())
return
}
response.Success(c, result)
}
// Refresh handles refreshing account credentials // Refresh handles refreshing account credentials
// POST /api/v1/admin/accounts/:id/refresh // POST /api/v1/admin/accounts/:id/refresh
func (h *AccountHandler) Refresh(c *gin.Context) { func (h *AccountHandler) Refresh(c *gin.Context) {
......
...@@ -2,6 +2,7 @@ package repository ...@@ -2,6 +2,7 @@ package repository
import ( import (
"context" "context"
"errors"
"github.com/Wei-Shaw/sub2api/internal/model" "github.com/Wei-Shaw/sub2api/internal/model"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination" "github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"time" "time"
...@@ -39,6 +40,22 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int64) (*model.Accou ...@@ -39,6 +40,22 @@ func (r *AccountRepository) GetByID(ctx context.Context, id int64) (*model.Accou
return &account, nil return &account, nil
} }
func (r *AccountRepository) GetByCRSAccountID(ctx context.Context, crsAccountID string) (*model.Account, error) {
if crsAccountID == "" {
return nil, nil
}
var account model.Account
err := r.db.WithContext(ctx).Where("extra->>'crs_account_id' = ?", crsAccountID).First(&account).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &account, nil
}
func (r *AccountRepository) Update(ctx context.Context, account *model.Account) error { func (r *AccountRepository) Update(ctx context.Context, account *model.Account) error {
return r.db.WithContext(ctx).Save(account).Error return r.db.WithContext(ctx).Save(account).Error
} }
......
...@@ -180,6 +180,7 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep ...@@ -180,6 +180,7 @@ func registerRoutes(r *gin.Engine, h *handler.Handlers, s *service.Services, rep
accounts.GET("", h.Admin.Account.List) accounts.GET("", h.Admin.Account.List)
accounts.GET("/:id", h.Admin.Account.GetByID) accounts.GET("/:id", h.Admin.Account.GetByID)
accounts.POST("", h.Admin.Account.Create) accounts.POST("", h.Admin.Account.Create)
accounts.POST("/sync/crs", h.Admin.Account.SyncFromCRS)
accounts.PUT("/:id", h.Admin.Account.Update) accounts.PUT("/:id", h.Admin.Account.Update)
accounts.DELETE("/:id", h.Admin.Account.Delete) accounts.DELETE("/:id", h.Admin.Account.Delete)
accounts.POST("/:id/test", h.Admin.Account.Test) accounts.POST("/:id/test", h.Admin.Account.Test)
......
This diff is collapsed.
...@@ -11,6 +11,9 @@ import ( ...@@ -11,6 +11,9 @@ import (
type AccountRepository interface { type AccountRepository interface {
Create(ctx context.Context, account *model.Account) error Create(ctx context.Context, account *model.Account) error
GetByID(ctx context.Context, id int64) (*model.Account, error) GetByID(ctx context.Context, id int64) (*model.Account, error)
// GetByCRSAccountID finds an account previously synced from CRS.
// Returns (nil, nil) if not found.
GetByCRSAccountID(ctx context.Context, crsAccountID string) (*model.Account, error)
Update(ctx context.Context, account *model.Account) error Update(ctx context.Context, account *model.Account) error
Delete(ctx context.Context, id int64) error Delete(ctx context.Context, id int64) error
......
...@@ -75,6 +75,7 @@ var ProviderSet = wire.NewSet( ...@@ -75,6 +75,7 @@ var ProviderSet = wire.NewSet(
NewSubscriptionService, NewSubscriptionService,
NewConcurrencyService, NewConcurrencyService,
NewIdentityService, NewIdentityService,
NewCRSSyncService,
ProvideUpdateService, ProvideUpdateService,
ProvideTokenRefreshService, ProvideTokenRefreshService,
......
...@@ -244,6 +244,28 @@ export async function getAvailableModels(id: number): Promise<ClaudeModel[]> { ...@@ -244,6 +244,28 @@ export async function getAvailableModels(id: number): Promise<ClaudeModel[]> {
return data; return data;
} }
export async function syncFromCrs(params: {
base_url: string;
username: string;
password: string;
sync_proxies?: boolean;
}): Promise<{
created: number;
updated: number;
skipped: number;
failed: number;
items: Array<{
crs_account_id: string;
kind: string;
name: string;
action: string;
error?: string;
}>;
}> {
const { data } = await apiClient.post('/admin/accounts/sync/crs', params);
return data;
}
export const accountsAPI = { export const accountsAPI = {
list, list,
getById, getById,
...@@ -263,6 +285,7 @@ export const accountsAPI = { ...@@ -263,6 +285,7 @@ export const accountsAPI = {
generateAuthUrl, generateAuthUrl,
exchangeCode, exchangeCode,
batchCreate, batchCreate,
syncFromCrs,
}; };
export default accountsAPI; export default accountsAPI;
<template>
<Modal
:show="show"
:title="t('admin.accounts.syncFromCrsTitle')"
size="lg"
close-on-click-outside
@close="handleClose"
>
<div class="space-y-4">
<div class="text-sm text-gray-600 dark:text-dark-300">
{{ t('admin.accounts.syncFromCrsDesc') }}
</div>
<div class="grid grid-cols-1 gap-4">
<div>
<label class="input-label">{{ t('admin.accounts.crsBaseUrl') }}</label>
<input
v-model="form.base_url"
type="text"
class="input"
:placeholder="t('admin.accounts.crsBaseUrlPlaceholder')"
/>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="input-label">{{ t('admin.accounts.crsUsername') }}</label>
<input
v-model="form.username"
type="text"
class="input"
autocomplete="username"
/>
</div>
<div>
<label class="input-label">{{ t('admin.accounts.crsPassword') }}</label>
<input
v-model="form.password"
type="password"
class="input"
autocomplete="current-password"
/>
</div>
</div>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-dark-300">
<input v-model="form.sync_proxies" type="checkbox" class="rounded border-gray-300 dark:border-dark-600" />
{{ t('admin.accounts.syncProxies') }}
</label>
</div>
<div v-if="result" class="rounded-xl border border-gray-200 dark:border-dark-700 p-4 space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ t('admin.accounts.syncResult') }}
</div>
<div class="text-sm text-gray-700 dark:text-dark-300">
{{ t('admin.accounts.syncResultSummary', result) }}
</div>
<div v-if="errorItems.length" class="mt-2">
<div class="text-sm font-medium text-red-600 dark:text-red-400">
{{ t('admin.accounts.syncErrors') }}
</div>
<div class="mt-2 max-h-48 overflow-auto rounded-lg bg-gray-50 dark:bg-dark-800 p-3 text-xs font-mono">
<div v-for="(item, idx) in errorItems" :key="idx" class="whitespace-pre-wrap">
{{ item.kind }} {{ item.crs_account_id }}{{ item.action }}{{ item.error ? `: ${item.error}` : '' }}
</div>
</div>
</div>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<button class="btn btn-secondary" :disabled="syncing" @click="handleClose">
{{ t('common.cancel') }}
</button>
<button class="btn btn-primary" :disabled="syncing" @click="handleSync">
{{ syncing ? t('admin.accounts.syncing') : t('admin.accounts.syncNow') }}
</button>
</div>
</template>
</Modal>
</template>
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import Modal from '@/components/common/Modal.vue'
import { useAppStore } from '@/stores/app'
import { adminAPI } from '@/api/admin'
interface Props {
show: boolean
}
interface Emits {
(e: 'close'): void
(e: 'synced'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const appStore = useAppStore()
const syncing = ref(false)
const result = ref<Awaited<ReturnType<typeof adminAPI.accounts.syncFromCrs>> | null>(null)
const form = reactive({
base_url: '',
username: '',
password: '',
sync_proxies: true
})
const errorItems = computed(() => {
if (!result.value?.items) return []
return result.value.items.filter((i) => i.action === 'failed' || i.action === 'skipped')
})
watch(
() => props.show,
(open) => {
if (open) {
result.value = null
}
}
)
const handleClose = () => {
emit('close')
}
const handleSync = async () => {
if (!form.base_url.trim() || !form.username.trim() || !form.password.trim()) {
appStore.showError(t('admin.accounts.syncMissingFields'))
return
}
syncing.value = true
try {
const res = await adminAPI.accounts.syncFromCrs({
base_url: form.base_url.trim(),
username: form.username.trim(),
password: form.password,
sync_proxies: form.sync_proxies
})
result.value = res
if (res.failed > 0) {
appStore.showError(t('admin.accounts.syncCompletedWithErrors', res))
} else {
appStore.showSuccess(t('admin.accounts.syncCompleted', res))
emit('synced')
}
} catch (error: any) {
appStore.showError(error?.message || t('admin.accounts.syncFailed'))
} finally {
syncing.value = false
}
}
</script>
...@@ -8,3 +8,4 @@ export { default as UsageProgressBar } from './UsageProgressBar.vue' ...@@ -8,3 +8,4 @@ export { default as UsageProgressBar } from './UsageProgressBar.vue'
export { default as AccountStatsModal } from './AccountStatsModal.vue' export { default as AccountStatsModal } from './AccountStatsModal.vue'
export { default as AccountTestModal } from './AccountTestModal.vue' export { default as AccountTestModal } from './AccountTestModal.vue'
export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue' export { default as AccountTodayStatsCell } from './AccountTodayStatsCell.vue'
export { default as SyncFromCrsModal } from './SyncFromCrsModal.vue'
...@@ -682,6 +682,25 @@ export default { ...@@ -682,6 +682,25 @@ export default {
title: 'Account Management', title: 'Account Management',
description: 'Manage AI platform accounts and credentials', description: 'Manage AI platform accounts and credentials',
createAccount: 'Create Account', createAccount: 'Create Account',
syncFromCrs: 'Sync from CRS',
syncFromCrsTitle: 'Sync Accounts from CRS',
syncFromCrsDesc:
'Sync accounts from claude-relay-service (CRS) into this system (CRS is called server-to-server).',
crsBaseUrl: 'CRS Base URL',
crsBaseUrlPlaceholder: 'e.g. http://127.0.0.1:3000',
crsUsername: 'Username',
crsPassword: 'Password',
syncProxies: 'Also sync proxies (match by host/port/auth or create)',
syncNow: 'Sync Now',
syncing: 'Syncing...',
syncMissingFields: 'Please fill base URL, username and password',
syncResult: 'Sync Result',
syncResultSummary: 'Created {created}, updated {updated}, skipped {skipped}, failed {failed}',
syncErrors: 'Errors / Skipped Details',
syncCompleted: 'Sync completed: created {created}, updated {updated}',
syncCompletedWithErrors:
'Sync completed with errors: failed {failed} (created {created}, updated {updated})',
syncFailed: 'Sync failed',
editAccount: 'Edit Account', editAccount: 'Edit Account',
deleteAccount: 'Delete Account', deleteAccount: 'Delete Account',
searchAccounts: 'Search accounts...', searchAccounts: 'Search accounts...',
......
...@@ -780,6 +780,23 @@ export default { ...@@ -780,6 +780,23 @@ export default {
title: '账号管理', title: '账号管理',
description: '管理 AI 平台账号和 Cookie', description: '管理 AI 平台账号和 Cookie',
createAccount: '添加账号', createAccount: '添加账号',
syncFromCrs: '从 CRS 同步',
syncFromCrsTitle: '从 CRS 同步账号',
syncFromCrsDesc: '将 claude-relay-service(CRS)中的账号同步到当前系统(不会在浏览器侧直接请求 CRS)。',
crsBaseUrl: 'CRS 服务地址',
crsBaseUrlPlaceholder: '例如:http://127.0.0.1:3000',
crsUsername: '用户名',
crsPassword: '密码',
syncProxies: '同时同步代理(按 host/port/账号匹配或自动创建)',
syncNow: '开始同步',
syncing: '同步中...',
syncMissingFields: '请填写服务地址、用户名和密码',
syncResult: '同步结果',
syncResultSummary: '创建 {created},更新 {updated},跳过 {skipped},失败 {failed}',
syncErrors: '错误/跳过详情',
syncCompleted: '同步完成:创建 {created},更新 {updated}',
syncCompletedWithErrors: '同步完成但有错误:失败 {failed}(创建 {created},更新 {updated})',
syncFailed: '同步失败',
editAccount: '编辑账号', editAccount: '编辑账号',
deleteAccount: '删除账号', deleteAccount: '删除账号',
deleteConfirmMessage: "确定要删除账号 '{name}' 吗?", deleteConfirmMessage: "确定要删除账号 '{name}' 吗?",
......
...@@ -16,6 +16,15 @@ ...@@ -16,6 +16,15 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" /> <path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg> </svg>
</button> </button>
<button
@click="showCrsSyncModal = true"
class="btn btn-secondary"
>
<svg class="w-5 h-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
{{ t('admin.accounts.syncFromCrs') }}
</button>
<button <button
@click="showCreateModal = true" @click="showCreateModal = true"
class="btn btn-primary" class="btn btn-primary"
...@@ -315,6 +324,12 @@ ...@@ -315,6 +324,12 @@
@confirm="confirmDelete" @confirm="confirmDelete"
@cancel="showDeleteDialog = false" @cancel="showDeleteDialog = false"
/> />
<SyncFromCrsModal
:show="showCrsSyncModal"
@close="showCrsSyncModal = false"
@synced="handleCrsSynced"
/>
</AppLayout> </AppLayout>
</template> </template>
...@@ -331,7 +346,7 @@ import Pagination from '@/components/common/Pagination.vue' ...@@ -331,7 +346,7 @@ import Pagination from '@/components/common/Pagination.vue'
import ConfirmDialog from '@/components/common/ConfirmDialog.vue' import ConfirmDialog from '@/components/common/ConfirmDialog.vue'
import EmptyState from '@/components/common/EmptyState.vue' import EmptyState from '@/components/common/EmptyState.vue'
import Select from '@/components/common/Select.vue' import Select from '@/components/common/Select.vue'
import { CreateAccountModal, EditAccountModal, ReAuthAccountModal, AccountStatsModal } from '@/components/account' import { CreateAccountModal, EditAccountModal, ReAuthAccountModal, AccountStatsModal, SyncFromCrsModal } from '@/components/account'
import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue' import AccountStatusIndicator from '@/components/account/AccountStatusIndicator.vue'
import AccountUsageCell from '@/components/account/AccountUsageCell.vue' import AccountUsageCell from '@/components/account/AccountUsageCell.vue'
import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue' import AccountTodayStatsCell from '@/components/account/AccountTodayStatsCell.vue'
...@@ -404,6 +419,7 @@ const showReAuthModal = ref(false) ...@@ -404,6 +419,7 @@ const showReAuthModal = ref(false)
const showDeleteDialog = ref(false) const showDeleteDialog = ref(false)
const showTestModal = ref(false) const showTestModal = ref(false)
const showStatsModal = ref(false) const showStatsModal = ref(false)
const showCrsSyncModal = ref(false)
const editingAccount = ref<Account | null>(null) const editingAccount = ref<Account | null>(null)
const reAuthAccount = ref<Account | null>(null) const reAuthAccount = ref<Account | null>(null)
const deletingAccount = ref<Account | null>(null) const deletingAccount = ref<Account | null>(null)
...@@ -480,6 +496,11 @@ const handlePageChange = (page: number) => { ...@@ -480,6 +496,11 @@ const handlePageChange = (page: number) => {
loadAccounts() loadAccounts()
} }
const handleCrsSynced = () => {
showCrsSyncModal.value = false
loadAccounts()
}
// Edit modal // Edit modal
const handleEdit = (account: Account) => { const handleEdit = (account: Account) => {
editingAccount.value = account editingAccount.value = account
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment