实现用户对模型权限的管理,优化UI
This commit is contained in:
1 parent
d03b5466da
commit
1edc97072c
13 files changed
+2570
-373
No files matched your search
+18
-1
@@ -9,13 +9,30 @@ admin:
|
||||
providers:
|
||||
- name: volcengine
|
||||
enabled: true
|
||||
api: https://ark.cn-beijing.volces.com/api/coding/v3/chat/completions
|
||||
api: http://127.0.0.1:22026
|
||||
transport: http
|
||||
source_format: openai_chat
|
||||
target_format: openai_chat
|
||||
verify_ssl: false
|
||||
model_list:
|
||||
- kimi-k2.5
|
||||
- deepseek/deepseek-chat
|
||||
- deepseek/deepseek-reasoner
|
||||
- glm/glm-4.5
|
||||
- glm/glm-4.5-air
|
||||
- glm/glm-4.6
|
||||
- glm/glm-4.7
|
||||
- glm/glm-5
|
||||
- volc_chat/deepseek-v3-2-251201
|
||||
- volc_chat/doubao-seed-2-0-code-preview-260215
|
||||
- volc_chat/doubao-seed-2-0-pro-260215
|
||||
- volc_chat/doubao-seedance-2-0-260128
|
||||
- volc_code/DeepSeek-V3.2
|
||||
- volc_code/Doubao-Seed-2.0-Code
|
||||
- volc_code/Doubao-Seed-2.0-pro
|
||||
- volc_code/GLM-4.7
|
||||
- volc_code/MiniMax-M2.5
|
||||
- volc_code/kimi-k2.5
|
||||
auth_groups:
|
||||
- name: a
|
||||
strategy: least_inflight
|
||||
|
||||
@@ -168,6 +168,7 @@ class Application:
|
||||
auth_service = AuthenticationService(self._ctx)
|
||||
user_service = UserService(self._ctx, self._user_repository)
|
||||
self._user_service = user_service
|
||||
self._user_service.sync_model_permissions()
|
||||
proxy_service = ProxyService(self._ctx, self._auth_group_manager)
|
||||
log_service = LogService(self._ctx, self._log_repository)
|
||||
provider_service = ProviderService(self._ctx, self.reload_providers)
|
||||
@@ -216,6 +217,8 @@ class Application:
|
||||
)
|
||||
self._auth_group_manager.load_auth_groups(auth_group_schemas)
|
||||
self._provider_manager.load_providers(provider_schemas)
|
||||
if hasattr(self, "_user_service"):
|
||||
self._user_service.sync_model_permissions()
|
||||
|
||||
def run(self) -> None:
|
||||
"""启动 WSGI 服务。"""
|
||||
|
||||
@@ -50,6 +50,29 @@ class ProxyController:
|
||||
def _is_whitelist_required(self) -> bool:
|
||||
return self._config_manager.is_chat_whitelist_enabled()
|
||||
|
||||
def _get_authorized_user_for_request(
|
||||
self,
|
||||
client_ip: str,
|
||||
*,
|
||||
error_format: str,
|
||||
) -> tuple[Optional[Dict[str, Any]], Optional[tuple[Response, int]]]:
|
||||
"""在开启白名单时解析当前请求对应用户。"""
|
||||
if not self._is_whitelist_required():
|
||||
return None, None
|
||||
|
||||
user = self._get_user_by_ip(client_ip)
|
||||
if user:
|
||||
return user, None
|
||||
|
||||
self._logger.warning("Proxy denied: ip=%s is not in whitelist", client_ip)
|
||||
return None, self._error_response(
|
||||
f"IP address {client_ip} is not in whitelist",
|
||||
403,
|
||||
error_type="permission_error",
|
||||
code="ip_not_whitelisted",
|
||||
error_format=error_format,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_error_payload(
|
||||
message: str,
|
||||
@@ -137,17 +160,12 @@ class ProxyController:
|
||||
try:
|
||||
client_ip = normalize_ip(request.remote_addr)
|
||||
self._logger.info("Proxy request received: route=%s ip=%s", route_name, client_ip)
|
||||
whitelist_required = self._is_whitelist_required()
|
||||
user = self._get_user_by_ip(client_ip) if whitelist_required else None
|
||||
if whitelist_required and not user:
|
||||
self._logger.warning("Proxy denied: ip=%s is not in whitelist", client_ip)
|
||||
return self._error_response(
|
||||
f"IP address {client_ip} is not in whitelist",
|
||||
403,
|
||||
error_type="permission_error",
|
||||
code="ip_not_whitelisted",
|
||||
error_format=resolved_error_format,
|
||||
)
|
||||
user, denial_response = self._get_authorized_user_for_request(
|
||||
client_ip,
|
||||
error_format=resolved_error_format,
|
||||
)
|
||||
if denial_response is not None:
|
||||
return denial_response
|
||||
|
||||
request_data = request.get_json(silent=True)
|
||||
if request_data is None:
|
||||
@@ -185,6 +203,25 @@ class ProxyController:
|
||||
error_format=resolved_error_format,
|
||||
)
|
||||
|
||||
if self._is_whitelist_required() and not self._user_service.can_user_access_model(
|
||||
user,
|
||||
model_name,
|
||||
available_models=self._provider_manager.list_model_names(),
|
||||
):
|
||||
self._logger.warning(
|
||||
"Proxy denied: ip=%s is not allowed to access model=%s route=%s",
|
||||
client_ip,
|
||||
model_name,
|
||||
route_name,
|
||||
)
|
||||
return self._error_response(
|
||||
f"IP address {client_ip} is not allowed to access model {model_name}",
|
||||
403,
|
||||
error_type="permission_error",
|
||||
code="model_not_allowed",
|
||||
error_format=resolved_error_format,
|
||||
)
|
||||
|
||||
provider_target_format = str(getattr(provider, "target_format", "") or "").strip().lower()
|
||||
if provider_target_format not in normalized_expected_target_formats:
|
||||
self._logger.warning(
|
||||
@@ -302,8 +339,26 @@ class ProxyController:
|
||||
|
||||
def list_models(self) -> Response:
|
||||
try:
|
||||
client_ip = normalize_ip(request.remote_addr)
|
||||
user, denial_response = self._get_authorized_user_for_request(
|
||||
client_ip,
|
||||
error_format="openai_chat",
|
||||
)
|
||||
if denial_response is not None:
|
||||
return denial_response
|
||||
|
||||
model_names = list(self._provider_manager.list_model_names())
|
||||
if self._is_whitelist_required():
|
||||
allowed_models = set(
|
||||
self._user_service.get_accessible_models_for_user(
|
||||
user,
|
||||
available_models=model_names,
|
||||
)
|
||||
)
|
||||
model_names = [model_name for model_name in model_names if model_name in allowed_models]
|
||||
|
||||
data = []
|
||||
for model_key in self._provider_manager.list_model_names():
|
||||
for model_key in model_names:
|
||||
provider_name, _, _ = str(model_key).partition("/")
|
||||
provider_view = self._provider_manager.get_provider_view(provider_name)
|
||||
source_format = getattr(provider_view, "source_format", None)
|
||||
|
||||
@@ -181,6 +181,16 @@
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.providers-page .provider-batch-summary .toolbar-pill {
|
||||
border-color: rgba(58, 127, 237, 0.14);
|
||||
background: rgba(58, 127, 237, 0.06);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.providers-page .provider-batch-summary .toolbar-pill strong {
|
||||
color: var(--primary-active);
|
||||
}
|
||||
|
||||
.providers-page .providers-table-shell {
|
||||
overflow: auto;
|
||||
border: 1px solid var(--line);
|
||||
@@ -267,6 +277,16 @@
|
||||
color: #e6edf3;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .provider-batch-summary .toolbar-pill {
|
||||
border-color: rgba(88, 166, 255, 0.2);
|
||||
background: rgba(88, 166, 255, 0.1);
|
||||
color: #a8b3c2;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .provider-batch-summary .toolbar-pill strong {
|
||||
color: #cfe0ff;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .toolbar-pill-primary {
|
||||
border-color: #2f5f97;
|
||||
background: rgba(47, 129, 247, 0.12);
|
||||
@@ -576,6 +596,24 @@
|
||||
|
||||
.providers-page .model-tag-detail {
|
||||
min-height: 30px;
|
||||
border-color: rgba(100, 116, 139, 0.18);
|
||||
background: #f6f8fb;
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.providers-page .providers-table .model-tag {
|
||||
min-height: 26px;
|
||||
border-color: rgba(100, 116, 139, 0.18);
|
||||
background: #f6f8fb;
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .model-tag {
|
||||
@@ -585,6 +623,20 @@
|
||||
box-shadow: inset 0 1px 0 rgba(125, 180, 255, 0.08);
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .providers-table .model-tag {
|
||||
border-color: rgba(148, 163, 184, 0.22);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: #d5dde7;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .model-tag-detail {
|
||||
border-color: rgba(148, 163, 184, 0.22);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: #d5dde7;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .provider-chip {
|
||||
border-color: rgba(86, 150, 232, 0.34);
|
||||
background: rgba(56, 104, 168, 0.18);
|
||||
@@ -748,15 +800,15 @@
|
||||
}
|
||||
|
||||
.providers-page .model-count {
|
||||
border: 1px solid #d8e5f3;
|
||||
background: rgba(17, 24, 39, 0.04);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid rgba(100, 116, 139, 0.18);
|
||||
background: #f6f8fb;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.providers-page .model-more {
|
||||
border: 1px solid #bfd6ee;
|
||||
background: linear-gradient(180deg, #f4f9ff 0%, #eaf3ff 100%);
|
||||
color: #29507c;
|
||||
border: 1px solid rgba(100, 116, 139, 0.18);
|
||||
background: #f6f8fb;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.providers-page .btn-inline {
|
||||
@@ -1001,6 +1053,15 @@
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.providers-page .provider-help-popover-body code {
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: #c2185b;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .btn-action {
|
||||
background: #0d1117;
|
||||
}
|
||||
@@ -1054,10 +1115,6 @@
|
||||
max-width: min(1120px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.providers-page .provider-batch-delete-modal-dialog {
|
||||
max-width: min(420px, calc(100vw - 32px));
|
||||
}
|
||||
|
||||
.providers-page .auth-entry-yaml-modal-dialog {
|
||||
max-width: min(1180px, calc(100vw - 32px));
|
||||
}
|
||||
@@ -1620,6 +1677,11 @@
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .provider-help-popover-body code {
|
||||
background: rgba(108, 167, 255, 0.14);
|
||||
color: #ff8ac2;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .inline-confirm-action:disabled {
|
||||
opacity: 0.78;
|
||||
}
|
||||
@@ -1701,9 +1763,9 @@
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .model-count {
|
||||
border-color: #313b47;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: #a8b3c2;
|
||||
border-color: rgba(148, 163, 184, 0.22);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: #d5dde7;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .fetch-model-list {
|
||||
@@ -1741,9 +1803,9 @@
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] .providers-page .model-more {
|
||||
border-color: #2d5a8a;
|
||||
background: linear-gradient(180deg, rgba(19, 35, 56, 0.96) 0%, rgba(14, 28, 45, 0.96) 100%);
|
||||
color: #cfe5ff;
|
||||
border-color: rgba(148, 163, 184, 0.22);
|
||||
background: rgba(148, 163, 184, 0.08);
|
||||
color: #d5dde7;
|
||||
}
|
||||
|
||||
.providers-page .provider-models-textarea {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
{% extends "base_admin.html" %}
|
||||
|
||||
{% block page_title %}Provider 管理{% endblock %}
|
||||
{% block page_css %}<link rel="stylesheet" href="/static/css/providers.css?v=20260402-6">{% endblock %}
|
||||
{% block page_css %}<link rel="stylesheet" href="/static/css/providers.css?v=20260403-11">{% endblock %}
|
||||
{% block body_class %}providers-page{% endblock %}
|
||||
{% block header_title %}Provider 管理{% endblock %}
|
||||
{% block header_subtitle %}维护 providers 配置,修改后自动重载生效。{% endblock %}
|
||||
@@ -49,10 +49,8 @@
|
||||
</div>
|
||||
<div class="toolbar-summary provider-batch-summary">
|
||||
<span class="toolbar-pill">已选 <strong id="selectedProviderCount">0</strong> 个</span>
|
||||
<button type="button" class="btn btn-toolbar-secondary" id="enableSelectedProvidersBtn" onclick="runProviderBatchAction('enable')" disabled>批量启用</button>
|
||||
<button type="button" class="btn btn-toolbar-secondary" id="disableSelectedProvidersBtn" onclick="runProviderBatchAction('disable')" disabled>批量禁用</button>
|
||||
<button type="button" class="btn btn-toolbar-secondary" id="clearSelectedProvidersBtn" onclick="clearSelectedProviders()" disabled>清空选择</button>
|
||||
<button type="button" class="btn btn-toolbar-secondary" id="deleteSelectedProvidersBtn" onclick="runProviderBatchAction('delete')" disabled>批量删除</button>
|
||||
<button type="button" class="btn btn-toolbar-secondary" id="enableSelectedProvidersBtn" onclick="runProviderBatchAction('enable')" disabled>启用</button>
|
||||
<button type="button" class="btn btn-toolbar-secondary" id="disableSelectedProvidersBtn" onclick="runProviderBatchAction('disable')" disabled>禁用</button>
|
||||
</div>
|
||||
<div class="providers-table-shell" id="providersContainer">
|
||||
<div class="loading">正在加载 provider 配置...</div>
|
||||
@@ -444,24 +442,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal fade" id="providerBatchDeleteModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered provider-batch-delete-modal-dialog">
|
||||
<div class="modal-content border-0 shadow">
|
||||
<div class="modal-header border-bottom-0">
|
||||
<div>
|
||||
<h5 class="modal-title fw-semibold">确认批量删除</h5>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-body pt-0">
|
||||
<p class="mb-0 fw-semibold" id="providerBatchDeleteMessage">是否批量删除 0 个 Provider?</p>
|
||||
</div>
|
||||
<div class="modal-footer border-top-0">
|
||||
<button type="button" class="btn btn-secondary" id="cancelProviderBatchDeleteBtn" data-bs-dismiss="modal">取消</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmProviderBatchDeleteBtn" onclick="confirmProviderBatchDelete()">确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inline-confirm-popover floating-delete-popover" id="providerDeletePopover" data-placement="bottom" hidden>
|
||||
<div class="inline-confirm-text">确认删除?</div>
|
||||
@@ -498,7 +478,6 @@
|
||||
let editingProviderName = null;
|
||||
let editingAuthGroupName = null;
|
||||
let providerModalInstance = null;
|
||||
let providerBatchDeleteModalInstance = null;
|
||||
let authGroupModalInstance = null;
|
||||
let authEntryImportModalInstance = null;
|
||||
let authGroupRuntimeModalInstance = null;
|
||||
@@ -510,7 +489,6 @@
|
||||
let activeAuthGroupRuntime = null;
|
||||
let pendingDeleteProviderName = null;
|
||||
let deletingProviderName = null;
|
||||
let pendingProviderBatchDeleteNames = [];
|
||||
let selectedProviderNames = new Set();
|
||||
let togglingProviderNames = new Set();
|
||||
let providerBatchActionInFlight = false;
|
||||
@@ -901,8 +879,6 @@
|
||||
[
|
||||
'enableSelectedProvidersBtn',
|
||||
'disableSelectedProvidersBtn',
|
||||
'deleteSelectedProvidersBtn',
|
||||
'clearSelectedProvidersBtn',
|
||||
].forEach(id => {
|
||||
const button = document.getElementById(id);
|
||||
if (!button) return;
|
||||
@@ -939,52 +915,16 @@
|
||||
renderProviders();
|
||||
}
|
||||
|
||||
function syncProviderBatchDeleteModal() {
|
||||
const count = pendingProviderBatchDeleteNames.length;
|
||||
const messageElement = document.getElementById('providerBatchDeleteMessage');
|
||||
const cancelButton = document.getElementById('cancelProviderBatchDeleteBtn');
|
||||
const confirmButton = document.getElementById('confirmProviderBatchDeleteBtn');
|
||||
|
||||
if (messageElement) {
|
||||
messageElement.textContent = `是否批量删除 ${count} 个 Provider?`;
|
||||
}
|
||||
if (cancelButton) {
|
||||
cancelButton.disabled = providerBatchActionInFlight;
|
||||
}
|
||||
if (confirmButton) {
|
||||
confirmButton.disabled = providerBatchActionInFlight || count === 0;
|
||||
confirmButton.textContent = providerBatchActionInFlight ? '删除中...' : '确认删除';
|
||||
}
|
||||
}
|
||||
|
||||
function openProviderBatchDeleteModal(names) {
|
||||
const normalizedNames = Array.isArray(names)
|
||||
? names.map(name => String(name || '').trim()).filter(name => name !== '')
|
||||
: [];
|
||||
if (!normalizedNames.length) {
|
||||
showMessage('请先选择至少一个 Provider', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
pendingProviderBatchDeleteNames = normalizedNames;
|
||||
syncProviderBatchDeleteModal();
|
||||
providerBatchDeleteModalInstance?.show();
|
||||
}
|
||||
|
||||
function getProviderBatchActionMeta(action) {
|
||||
return {
|
||||
enable: { label: '启用', past: '启用', fallback: '批量启用 Provider 失败' },
|
||||
disable: { label: '禁用', past: '禁用', fallback: '批量禁用 Provider 失败' },
|
||||
delete: { label: '删除', past: '删除', fallback: '批量删除 Provider 失败' },
|
||||
}[action] || null;
|
||||
}
|
||||
|
||||
async function executeProviderBatchAction(action, selectedNames, meta, options = {}) {
|
||||
const { closeDeleteModal = false } = options || {};
|
||||
|
||||
async function executeProviderBatchAction(action, selectedNames, meta) {
|
||||
closeDeleteProviderConfirm(false);
|
||||
providerBatchActionInFlight = true;
|
||||
syncProviderBatchDeleteModal();
|
||||
renderProviders();
|
||||
|
||||
try {
|
||||
@@ -1001,13 +941,7 @@
|
||||
throw new Error(result.error || meta.fallback);
|
||||
}
|
||||
|
||||
selectedProviderNames = action === 'delete' ? new Set() : new Set(selectedNames);
|
||||
if (action === 'delete') {
|
||||
pendingProviderBatchDeleteNames = [];
|
||||
}
|
||||
if (closeDeleteModal) {
|
||||
providerBatchDeleteModalInstance?.hide();
|
||||
}
|
||||
selectedProviderNames = new Set(selectedNames);
|
||||
showMessage(`已批量${meta.past} ${result.count || selectedNames.length} 个 Provider,配置已重载`, 'success');
|
||||
await loadProviders();
|
||||
} catch (error) {
|
||||
@@ -1015,24 +949,10 @@
|
||||
showActionError(`批量${meta.label} Provider`, error, { fallback: meta.fallback });
|
||||
} finally {
|
||||
providerBatchActionInFlight = false;
|
||||
syncProviderBatchDeleteModal();
|
||||
renderProviders();
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmProviderBatchDelete() {
|
||||
const selectedNames = pendingProviderBatchDeleteNames.slice();
|
||||
if (!selectedNames.length || providerBatchActionInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const meta = getProviderBatchActionMeta('delete');
|
||||
if (!meta) {
|
||||
return;
|
||||
}
|
||||
await executeProviderBatchAction('delete', selectedNames, meta, { closeDeleteModal: true });
|
||||
}
|
||||
|
||||
function getAuthEntryDeletePopoverElement() {
|
||||
return document.getElementById('authEntryDeletePopover');
|
||||
}
|
||||
@@ -1184,7 +1104,7 @@
|
||||
switchProviderTab('providers');
|
||||
activeModelProvider = provider;
|
||||
document.getElementById('modelDetailTitle').textContent = `${provider.name} - 模型列表`;
|
||||
document.getElementById('modelDetailSubtitle').textContent = provider.api || '未配置 API 地址';
|
||||
document.getElementById('modelDetailSubtitle').textContent = `API 地址: ${provider.api}` || '未配置 API 地址';
|
||||
document.getElementById('modelSearchInput').value = '';
|
||||
renderModelDetailList();
|
||||
modelDetailModalInstance.show();
|
||||
@@ -3180,11 +3100,6 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
openProviderBatchDeleteModal(selectedNames);
|
||||
return;
|
||||
}
|
||||
|
||||
await executeProviderBatchAction(action, selectedNames, meta);
|
||||
}
|
||||
|
||||
@@ -3311,14 +3226,6 @@
|
||||
editingProviderName = null;
|
||||
fillForm(null);
|
||||
});
|
||||
const providerBatchDeleteModalElement = document.getElementById('providerBatchDeleteModal');
|
||||
providerBatchDeleteModalInstance = new bootstrap.Modal(providerBatchDeleteModalElement);
|
||||
providerBatchDeleteModalElement.addEventListener('hidden.bs.modal', function() {
|
||||
if (!providerBatchActionInFlight) {
|
||||
pendingProviderBatchDeleteNames = [];
|
||||
syncProviderBatchDeleteModal();
|
||||
}
|
||||
});
|
||||
const authGroupModalElement = document.getElementById('authGroupModal');
|
||||
authGroupModalInstance = new bootstrap.Modal(authGroupModalElement);
|
||||
authGroupModalElement.addEventListener('hidden.bs.modal', function() {
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -28,6 +28,7 @@ class UserController:
|
||||
|
||||
self._app.route('/api/users', methods=['GET'])(auth(self.get_users))
|
||||
self._app.route('/api/users', methods=['POST'])(auth(self.create_user))
|
||||
self._app.route('/api/users/batch', methods=['POST'])(auth(self.batch_users))
|
||||
self._app.route('/api/users/<int:user_id>', methods=['GET'])(auth(self.get_user))
|
||||
self._app.route('/api/users/<int:user_id>', methods=['PUT'])(auth(self.update_user))
|
||||
self._app.route('/api/users/<int:user_id>', methods=['DELETE'])(auth(self.delete_user))
|
||||
@@ -56,6 +57,7 @@ class UserController:
|
||||
'page_size': page_size,
|
||||
'total_pages': (total + page_size - 1) // page_size,
|
||||
'keyword': keyword,
|
||||
'available_models': self._user_service.get_available_models(),
|
||||
})
|
||||
except Exception as exc:
|
||||
self._logger.error(f'Error getting users: {exc}')
|
||||
@@ -114,6 +116,8 @@ class UserController:
|
||||
username = data.get('username')
|
||||
ip_address = data.get('ip_address')
|
||||
whitelist_access_enabled = data.get('whitelist_access_enabled')
|
||||
model_permissions_provided = 'model_permissions' in data
|
||||
model_permissions = data.get('model_permissions')
|
||||
|
||||
normalized_ip = None
|
||||
if ip_address is not None:
|
||||
@@ -127,6 +131,8 @@ class UserController:
|
||||
username,
|
||||
normalized_ip,
|
||||
whitelist_access_enabled,
|
||||
model_permissions_provided=model_permissions_provided,
|
||||
model_permissions=model_permissions,
|
||||
)
|
||||
if not success:
|
||||
self._logger.warning(f"Update user failed: user_id={user_id}")
|
||||
@@ -134,10 +140,38 @@ class UserController:
|
||||
|
||||
self._logger.info(f"Update user succeeded: user_id={user_id}")
|
||||
return jsonify({'message': 'User updated successfully'})
|
||||
except ValueError as exc:
|
||||
return jsonify({'error': str(exc)}), 400
|
||||
except Exception as exc:
|
||||
self._logger.error(f'Error updating user: {exc}')
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def batch_users(self) -> Response:
|
||||
"""执行用户批量操作。"""
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
action = str(payload.get('action') or '').strip().lower()
|
||||
if action != 'set_model_permissions':
|
||||
raise ValueError(f'Unsupported user batch action: {action or "<empty>"}')
|
||||
|
||||
result = self._user_service.batch_update_model_permissions(
|
||||
payload.get('user_ids'),
|
||||
payload.get('model_permissions'),
|
||||
)
|
||||
self._logger.info(
|
||||
'User batch action completed: action=%s count=%s',
|
||||
action,
|
||||
result.get('count', 0),
|
||||
)
|
||||
return jsonify(result)
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
status_code = 404 if 'not found' in message.lower() else 400
|
||||
return jsonify({'error': message}), status_code
|
||||
except Exception as exc:
|
||||
self._logger.error(f'Error applying user batch action: {exc}')
|
||||
return jsonify({'error': str(exc)}), 500
|
||||
|
||||
def delete_user(self, user_id: int) -> Response:
|
||||
"""删除用户。"""
|
||||
try:
|
||||
|
||||
@@ -11,6 +11,8 @@ from ..utils.local_time import now_local_datetime_text
|
||||
class UserRepository:
|
||||
"""负责 users 表的数据访问。"""
|
||||
|
||||
MODEL_PERMISSIONS_ALL = "*"
|
||||
|
||||
def __init__(self, get_connection: ConnectionFactory):
|
||||
self._get_connection = get_connection
|
||||
self._ensure_table()
|
||||
@@ -26,15 +28,29 @@ class UserRepository:
|
||||
username TEXT NOT NULL,
|
||||
ip_address TEXT NOT NULL UNIQUE,
|
||||
whitelist_access_enabled INTEGER DEFAULT 1,
|
||||
model_permissions TEXT NOT NULL DEFAULT '*',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
columns = {
|
||||
str(row["name"]).strip()
|
||||
for row in cursor.execute("PRAGMA table_info(users)").fetchall()
|
||||
}
|
||||
if "model_permissions" not in columns:
|
||||
cursor.execute(
|
||||
"ALTER TABLE users ADD COLUMN model_permissions TEXT NOT NULL DEFAULT '*'"
|
||||
)
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_ip ON users(ip_address)")
|
||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_users_username ON users(username)")
|
||||
|
||||
def create(self, username: str, ip_address: str) -> Optional[int]:
|
||||
def create(
|
||||
self,
|
||||
username: str,
|
||||
ip_address: str,
|
||||
model_permissions: str = MODEL_PERMISSIONS_ALL,
|
||||
) -> Optional[int]:
|
||||
"""创建用户记录。"""
|
||||
now_text = now_local_datetime_text()
|
||||
with self._get_connection() as conn:
|
||||
@@ -42,11 +58,11 @@ class UserRepository:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO users (
|
||||
username, ip_address, whitelist_access_enabled, created_at, updated_at
|
||||
username, ip_address, whitelist_access_enabled, model_permissions, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(username, ip_address, 1, now_text, now_text),
|
||||
(username, ip_address, 1, model_permissions, now_text, now_text),
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
@@ -56,7 +72,7 @@ class UserRepository:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, username, ip_address, whitelist_access_enabled, created_at, updated_at
|
||||
SELECT id, username, ip_address, whitelist_access_enabled, model_permissions, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
@@ -90,6 +106,7 @@ class UserRepository:
|
||||
u.username,
|
||||
u.ip_address,
|
||||
u.whitelist_access_enabled,
|
||||
u.model_permissions,
|
||||
u.created_at,
|
||||
u.updated_at,
|
||||
COALESCE(s.total_request_count, 0) AS total_request_count,
|
||||
@@ -136,6 +153,7 @@ class UserRepository:
|
||||
username: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
whitelist_access_enabled: Optional[bool] = None,
|
||||
model_permissions: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""更新用户记录。"""
|
||||
updates = []
|
||||
@@ -150,6 +168,9 @@ class UserRepository:
|
||||
if whitelist_access_enabled is not None:
|
||||
updates.append("whitelist_access_enabled = ?")
|
||||
params.append(1 if whitelist_access_enabled else 0)
|
||||
if model_permissions is not None:
|
||||
updates.append("model_permissions = ?")
|
||||
params.append(model_permissions)
|
||||
|
||||
if not updates:
|
||||
return False
|
||||
@@ -176,7 +197,7 @@ class UserRepository:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, username, ip_address, whitelist_access_enabled, created_at, updated_at
|
||||
SELECT id, username, ip_address, whitelist_access_enabled, model_permissions, created_at, updated_at
|
||||
FROM users
|
||||
WHERE ip_address = ?
|
||||
""",
|
||||
@@ -184,3 +205,54 @@ class UserRepository:
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_by_ids(self, user_ids: List[int]) -> List[Dict[str, Any]]:
|
||||
"""按 ID 列表查询用户。"""
|
||||
normalized_user_ids = [int(user_id) for user_id in user_ids]
|
||||
if not normalized_user_ids:
|
||||
return []
|
||||
|
||||
placeholders = ", ".join("?" for _ in normalized_user_ids)
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
f"""
|
||||
SELECT id, username, ip_address, whitelist_access_enabled, model_permissions, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id IN ({placeholders})
|
||||
""",
|
||||
normalized_user_ids,
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def list_all(self) -> List[Dict[str, Any]]:
|
||||
"""查询全部用户,用于权限同步与批量设置。"""
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, username, ip_address, whitelist_access_enabled, model_permissions, created_at, updated_at
|
||||
FROM users
|
||||
ORDER BY id ASC
|
||||
"""
|
||||
)
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def batch_update_model_permissions(self, user_ids: List[int], model_permissions: str) -> int:
|
||||
"""批量更新用户模型权限。"""
|
||||
normalized_user_ids = [int(user_id) for user_id in user_ids]
|
||||
if not normalized_user_ids:
|
||||
return 0
|
||||
|
||||
placeholders = ", ".join("?" for _ in normalized_user_ids)
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
f"""
|
||||
UPDATE users
|
||||
SET model_permissions = ?, updated_at = ?
|
||||
WHERE id IN ({placeholders})
|
||||
""",
|
||||
[model_permissions, now_local_datetime_text(), *normalized_user_ids],
|
||||
)
|
||||
return cursor.rowcount
|
||||
+327
-30
@@ -2,10 +2,12 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""用户服务。"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, Iterable, List, Optional, Sequence
|
||||
|
||||
from ..application.app_context import AppContext
|
||||
from ..config.provider_config import normalize_model_list
|
||||
from ..repositories import UserRepository
|
||||
from ..utils import is_valid_ip
|
||||
from ..utils.local_time import normalize_local_datetime_text
|
||||
@@ -14,9 +16,12 @@ from ..utils.local_time import normalize_local_datetime_text
|
||||
class UserService:
|
||||
"""封装用户管理业务逻辑。"""
|
||||
|
||||
MODEL_PERMISSIONS_ALL = UserRepository.MODEL_PERMISSIONS_ALL
|
||||
|
||||
def __init__(self, ctx: AppContext, repository: UserRepository):
|
||||
self._ctx = ctx
|
||||
self._logger = ctx.logger
|
||||
self._config_manager = getattr(ctx, "config_manager", None)
|
||||
self._repository = repository
|
||||
self._cache_lock = threading.RLock()
|
||||
self._user_by_ip_cache: Dict[str, Optional[Dict[str, Any]]] = {}
|
||||
@@ -51,49 +56,193 @@ class UserService:
|
||||
normalized["updated_at"] = normalize_local_datetime_text(normalized.get("updated_at"))
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
def _dedupe_models(model_names: Iterable[Any]) -> List[str]:
|
||||
seen_models: set[str] = set()
|
||||
normalized_models: List[str] = []
|
||||
for item in model_names:
|
||||
model_name = str(item or "").strip()
|
||||
if not model_name or model_name in seen_models:
|
||||
continue
|
||||
seen_models.add(model_name)
|
||||
normalized_models.append(model_name)
|
||||
return normalized_models
|
||||
|
||||
def _get_available_model_names(self) -> tuple[str, ...]:
|
||||
"""读取配置中声明的模型列表,包含已禁用 Provider 的模型。"""
|
||||
if self._config_manager is None:
|
||||
return ()
|
||||
|
||||
try:
|
||||
config = self._config_manager.get_raw_config()
|
||||
except Exception as exc:
|
||||
self._logger.error("Failed to load config model catalog: %s", exc)
|
||||
return ()
|
||||
|
||||
raw_providers = config.get("providers", [])
|
||||
if raw_providers is None or not isinstance(raw_providers, list):
|
||||
return ()
|
||||
|
||||
model_names: List[str] = []
|
||||
seen_models: set[str] = set()
|
||||
for raw_provider in raw_providers:
|
||||
if not isinstance(raw_provider, dict):
|
||||
continue
|
||||
|
||||
provider_name = str(raw_provider.get("name") or "").strip()
|
||||
if not provider_name:
|
||||
continue
|
||||
|
||||
try:
|
||||
provider_models = normalize_model_list(raw_provider.get("model_list"))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
for provider_model in provider_models:
|
||||
model_key = f"{provider_name}/{provider_model}"
|
||||
if model_key in seen_models:
|
||||
continue
|
||||
seen_models.add(model_key)
|
||||
model_names.append(model_key)
|
||||
|
||||
return tuple(sorted(model_names))
|
||||
|
||||
@classmethod
|
||||
def _deserialize_model_permissions(cls, raw_value: Any) -> Optional[tuple[str, ...]]:
|
||||
"""反序列化模型权限;返回 None 表示通配全模型。"""
|
||||
normalized_text = str(raw_value or "").strip()
|
||||
if not normalized_text or normalized_text == cls.MODEL_PERMISSIONS_ALL:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = json.loads(normalized_text)
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
payload = normalized_text.replace(",", "\n").splitlines()
|
||||
|
||||
if isinstance(payload, str):
|
||||
payload = [payload]
|
||||
if not isinstance(payload, list):
|
||||
return ()
|
||||
|
||||
normalized_models = cls._dedupe_models(payload)
|
||||
if cls.MODEL_PERMISSIONS_ALL in normalized_models:
|
||||
return None
|
||||
return tuple(normalized_models)
|
||||
|
||||
def _serialize_model_permissions(self, value: Any) -> str:
|
||||
"""标准化模型权限存储格式。"""
|
||||
if isinstance(value, str):
|
||||
normalized_text = value.strip()
|
||||
if normalized_text == self.MODEL_PERMISSIONS_ALL:
|
||||
return self.MODEL_PERMISSIONS_ALL
|
||||
raw_items: Sequence[Any] = normalized_text.replace(",", "\n").splitlines()
|
||||
elif isinstance(value, (list, tuple, set)):
|
||||
raw_items = list(value)
|
||||
else:
|
||||
raise ValueError('model_permissions must be "*" or a list of model names')
|
||||
|
||||
normalized_models = self._dedupe_models(raw_items)
|
||||
if self.MODEL_PERMISSIONS_ALL in normalized_models:
|
||||
return self.MODEL_PERMISSIONS_ALL
|
||||
|
||||
available_models = set(self._get_available_model_names())
|
||||
unknown_models = [model for model in normalized_models if model not in available_models]
|
||||
if unknown_models:
|
||||
raise ValueError(f"Unknown model permission(s): {', '.join(unknown_models)}")
|
||||
|
||||
return json.dumps(normalized_models, ensure_ascii=True)
|
||||
|
||||
def _decorate_user(
|
||||
self,
|
||||
user: Optional[Dict[str, Any]],
|
||||
*,
|
||||
available_models: Optional[Sequence[str]] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""补充模型权限字段并标准化时间格式。"""
|
||||
normalized = self._normalize_user_timestamps(user)
|
||||
if not normalized:
|
||||
return normalized
|
||||
|
||||
resolved_available_models = tuple(
|
||||
self._get_available_model_names()
|
||||
if available_models is None
|
||||
else available_models
|
||||
)
|
||||
available_model_set = set(resolved_available_models)
|
||||
parsed_permissions = self._deserialize_model_permissions(normalized.get("model_permissions"))
|
||||
|
||||
if parsed_permissions is None:
|
||||
normalized["model_permissions"] = self.MODEL_PERMISSIONS_ALL
|
||||
normalized["model_permissions_mode"] = "all"
|
||||
normalized["allowed_models_count"] = len(resolved_available_models)
|
||||
else:
|
||||
filtered_permissions = [
|
||||
model_name
|
||||
for model_name in parsed_permissions
|
||||
if not available_model_set or model_name in available_model_set
|
||||
]
|
||||
normalized["model_permissions"] = filtered_permissions
|
||||
normalized["model_permissions_mode"] = "selected"
|
||||
normalized["allowed_models_count"] = len(filtered_permissions)
|
||||
|
||||
normalized["available_models_count"] = len(resolved_available_models)
|
||||
return normalized
|
||||
|
||||
def get_available_models(self) -> List[str]:
|
||||
"""返回当前配置中可选的模型列表。"""
|
||||
return list(self._get_available_model_names())
|
||||
|
||||
def create_user(self, username: str, ip_address: str) -> Optional[int]:
|
||||
"""创建用户。"""
|
||||
try:
|
||||
if not is_valid_ip(ip_address):
|
||||
self._logger.error(f'Invalid IP address: {ip_address}')
|
||||
self._logger.error(f"Invalid IP address: {ip_address}")
|
||||
return None
|
||||
|
||||
existing_ip = self._repository.get_by_ip(ip_address)
|
||||
if existing_ip:
|
||||
self._logger.error(f'IP address already in use: {ip_address}')
|
||||
self._logger.error(f"IP address already in use: {ip_address}")
|
||||
return None
|
||||
|
||||
user_id = self._repository.create(username, ip_address)
|
||||
user_id = self._repository.create(
|
||||
username,
|
||||
ip_address,
|
||||
model_permissions=self.MODEL_PERMISSIONS_ALL,
|
||||
)
|
||||
self._invalidate_ip_cache(ip_address)
|
||||
self._logger.info(f"User created: user_id={user_id}, username={username!r}, ip={ip_address}")
|
||||
return user_id
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to create user: {e}')
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Failed to create user: {exc}")
|
||||
return None
|
||||
|
||||
def get_user_by_id(self, user_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""按 ID 查询用户。"""
|
||||
try:
|
||||
return self._normalize_user_timestamps(self._repository.get_by_id(user_id))
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to get user: {e}')
|
||||
return self._decorate_user(self._repository.get_by_id(user_id))
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Failed to get user: {exc}")
|
||||
return None
|
||||
|
||||
def get_users(self, page: int = 1, page_size: int = 50, keyword: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||
"""分页查询用户列表。"""
|
||||
try:
|
||||
available_models = self._get_available_model_names()
|
||||
users = self._repository.get(page=page, page_size=page_size, keyword=keyword)
|
||||
return [self._normalize_user_timestamps(user) for user in users]
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to get users: {e}')
|
||||
return [
|
||||
self._decorate_user(user, available_models=available_models)
|
||||
for user in users
|
||||
]
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Failed to get users: {exc}")
|
||||
return []
|
||||
|
||||
def get_total_users_count(self, keyword: Optional[str] = None) -> int:
|
||||
"""查询用户总数。"""
|
||||
try:
|
||||
return self._repository.get_count(keyword=keyword)
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to get users count: {e}')
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Failed to get users count: {exc}")
|
||||
return 0
|
||||
|
||||
def update_user(
|
||||
@@ -102,6 +251,9 @@ class UserService:
|
||||
username: Optional[str] = None,
|
||||
ip_address: Optional[str] = None,
|
||||
whitelist_access_enabled: Optional[bool] = None,
|
||||
*,
|
||||
model_permissions_provided: bool = False,
|
||||
model_permissions: Any = None,
|
||||
) -> bool:
|
||||
"""更新用户信息。"""
|
||||
try:
|
||||
@@ -111,35 +263,72 @@ class UserService:
|
||||
|
||||
if ip_address:
|
||||
existing = self._repository.get_by_ip(ip_address)
|
||||
if existing and existing['id'] != user_id:
|
||||
self._logger.error(f'IP address already in use: {ip_address}')
|
||||
if existing and existing["id"] != user_id:
|
||||
self._logger.error(f"IP address already in use: {ip_address}")
|
||||
return False
|
||||
|
||||
serialized_model_permissions: Optional[str] = None
|
||||
if model_permissions_provided:
|
||||
serialized_model_permissions = self._serialize_model_permissions(model_permissions)
|
||||
|
||||
updated = self._repository.update(
|
||||
user_id,
|
||||
username,
|
||||
ip_address,
|
||||
whitelist_access_enabled,
|
||||
serialized_model_permissions,
|
||||
)
|
||||
if updated:
|
||||
self._invalidate_ip_cache(existing_user.get('ip_address'), ip_address)
|
||||
self._invalidate_ip_cache(existing_user.get("ip_address"), ip_address)
|
||||
self._logger.info(f"User updated: user_id={user_id}")
|
||||
return updated
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to update user: {e}')
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Failed to update user: {exc}")
|
||||
return False
|
||||
|
||||
def batch_update_model_permissions(self, user_ids: Any, model_permissions: Any) -> Dict[str, Any]:
|
||||
"""批量统一设置用户模型权限。"""
|
||||
normalized_user_ids = self._normalize_user_ids(user_ids)
|
||||
existing_users = self._repository.get_by_ids(normalized_user_ids)
|
||||
existing_users_by_id = {int(user["id"]): user for user in existing_users}
|
||||
missing_user_ids = [user_id for user_id in normalized_user_ids if user_id not in existing_users_by_id]
|
||||
if missing_user_ids:
|
||||
if len(missing_user_ids) == 1:
|
||||
raise ValueError(f"User not found: {missing_user_ids[0]}")
|
||||
raise ValueError(f"Users not found: {', '.join(str(user_id) for user_id in missing_user_ids)}")
|
||||
|
||||
serialized_model_permissions = self._serialize_model_permissions(model_permissions)
|
||||
self._repository.batch_update_model_permissions(normalized_user_ids, serialized_model_permissions)
|
||||
self._invalidate_ip_cache(*(user.get("ip_address") for user in existing_users))
|
||||
|
||||
payload: Any = self.MODEL_PERMISSIONS_ALL
|
||||
if serialized_model_permissions != self.MODEL_PERMISSIONS_ALL:
|
||||
payload = json.loads(serialized_model_permissions)
|
||||
|
||||
self._logger.info(
|
||||
"User model permissions batch updated: count=%s mode=%s",
|
||||
len(normalized_user_ids),
|
||||
"all" if payload == self.MODEL_PERMISSIONS_ALL else "selected",
|
||||
)
|
||||
return {
|
||||
"count": len(normalized_user_ids),
|
||||
"user_ids": normalized_user_ids,
|
||||
"model_permissions": payload,
|
||||
}
|
||||
|
||||
def delete_user(self, user_id: int) -> bool:
|
||||
"""删除用户。"""
|
||||
try:
|
||||
existing_user = self._repository.get_by_id(user_id)
|
||||
deleted = self._repository.delete(user_id)
|
||||
if deleted and existing_user:
|
||||
self._invalidate_ip_cache(existing_user.get('ip_address'))
|
||||
self._invalidate_ip_cache(existing_user.get("ip_address"))
|
||||
self._logger.info(f"User deleted: user_id={user_id}")
|
||||
return deleted
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to delete user: {e}')
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Failed to delete user: {exc}")
|
||||
return False
|
||||
|
||||
def toggle_user_status(self, user_id: int) -> bool:
|
||||
@@ -150,16 +339,16 @@ class UserService:
|
||||
return False
|
||||
updated = self._repository.update(
|
||||
user_id,
|
||||
whitelist_access_enabled=not bool(user['whitelist_access_enabled']),
|
||||
whitelist_access_enabled=not bool(user["whitelist_access_enabled"]),
|
||||
)
|
||||
if updated:
|
||||
self._invalidate_ip_cache(user.get('ip_address'))
|
||||
self._invalidate_ip_cache(user.get("ip_address"))
|
||||
self._logger.info(
|
||||
f"User whitelist toggled: user_id={user_id}, enabled={not bool(user['whitelist_access_enabled'])}"
|
||||
)
|
||||
return updated
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to toggle user status: {e}')
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Failed to toggle user status: {exc}")
|
||||
return False
|
||||
|
||||
def get_user_by_ip(
|
||||
@@ -179,9 +368,117 @@ class UserService:
|
||||
|
||||
if not cached_user:
|
||||
return None
|
||||
if require_whitelist_access and not bool(cached_user.get('whitelist_access_enabled')):
|
||||
if require_whitelist_access and not bool(cached_user.get("whitelist_access_enabled")):
|
||||
return None
|
||||
return self._normalize_user_timestamps(cached_user)
|
||||
except Exception as e:
|
||||
self._logger.error(f'Failed to get user by IP: {e}')
|
||||
return self._decorate_user(cached_user)
|
||||
except Exception as exc:
|
||||
self._logger.error(f"Failed to get user by IP: {exc}")
|
||||
return None
|
||||
|
||||
def get_accessible_models_for_user(
|
||||
self,
|
||||
user: Optional[Dict[str, Any]],
|
||||
available_models: Optional[Sequence[str]] = None,
|
||||
) -> List[str]:
|
||||
"""返回用户在给定模型集合内可访问的模型。"""
|
||||
if not user:
|
||||
return []
|
||||
|
||||
resolved_available_models = list(
|
||||
self._get_available_model_names()
|
||||
if available_models is None
|
||||
else available_models
|
||||
)
|
||||
permissions = user.get("model_permissions")
|
||||
if permissions == self.MODEL_PERMISSIONS_ALL or user.get("model_permissions_mode") == "all":
|
||||
return resolved_available_models
|
||||
|
||||
if isinstance(permissions, list):
|
||||
explicit_models = self._dedupe_models(permissions)
|
||||
else:
|
||||
parsed_permissions = self._deserialize_model_permissions(permissions)
|
||||
if parsed_permissions is None:
|
||||
return resolved_available_models
|
||||
explicit_models = list(parsed_permissions)
|
||||
|
||||
if not resolved_available_models:
|
||||
return explicit_models
|
||||
|
||||
available_model_set = set(resolved_available_models)
|
||||
return [model_name for model_name in explicit_models if model_name in available_model_set]
|
||||
|
||||
def can_user_access_model(
|
||||
self,
|
||||
user: Optional[Dict[str, Any]],
|
||||
model_name: str,
|
||||
available_models: Optional[Sequence[str]] = None,
|
||||
) -> bool:
|
||||
"""判断用户是否可访问指定模型。"""
|
||||
normalized_model_name = str(model_name or "").strip()
|
||||
if not normalized_model_name or not user:
|
||||
return False
|
||||
|
||||
permissions = user.get("model_permissions")
|
||||
if (
|
||||
permissions == self.MODEL_PERMISSIONS_ALL
|
||||
or user.get("model_permissions_mode") == "all"
|
||||
) and available_models is None:
|
||||
return True
|
||||
|
||||
return normalized_model_name in set(
|
||||
self.get_accessible_models_for_user(user, available_models=available_models)
|
||||
)
|
||||
|
||||
def sync_model_permissions(self) -> int:
|
||||
"""同步并清理已删除模型对应的显式授权。"""
|
||||
try:
|
||||
available_models = set(self._get_available_model_names())
|
||||
updated_count = 0
|
||||
for user in self._repository.list_all():
|
||||
current_raw = str(user.get("model_permissions") or "").strip() or self.MODEL_PERMISSIONS_ALL
|
||||
parsed_permissions = self._deserialize_model_permissions(current_raw)
|
||||
if parsed_permissions is None:
|
||||
expected_raw = self.MODEL_PERMISSIONS_ALL
|
||||
else:
|
||||
filtered_permissions = [
|
||||
model_name
|
||||
for model_name in parsed_permissions
|
||||
if model_name in available_models
|
||||
]
|
||||
expected_raw = json.dumps(filtered_permissions, ensure_ascii=True)
|
||||
|
||||
if current_raw == expected_raw:
|
||||
continue
|
||||
|
||||
if self._repository.update(int(user["id"]), model_permissions=expected_raw):
|
||||
updated_count += 1
|
||||
self._invalidate_ip_cache(user.get("ip_address"))
|
||||
|
||||
if updated_count:
|
||||
self._logger.info("User model permissions synced: updated=%s", updated_count)
|
||||
return updated_count
|
||||
except Exception as exc:
|
||||
self._logger.error("Failed to sync user model permissions: %s", exc)
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _normalize_user_ids(user_ids: Any) -> List[int]:
|
||||
"""标准化批量操作中的用户 ID 列表。"""
|
||||
if not isinstance(user_ids, list):
|
||||
raise ValueError("User ids must be a non-empty list")
|
||||
|
||||
normalized_user_ids: List[int] = []
|
||||
seen_user_ids: set[int] = set()
|
||||
for raw_user_id in user_ids:
|
||||
try:
|
||||
user_id = int(raw_user_id)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("User ids must be integers") from exc
|
||||
if user_id <= 0 or user_id in seen_user_ids:
|
||||
continue
|
||||
seen_user_ids.add(user_id)
|
||||
normalized_user_ids.append(user_id)
|
||||
|
||||
if not normalized_user_ids:
|
||||
raise ValueError("User ids must be a non-empty list")
|
||||
return normalized_user_ids
|
||||
@@ -42,16 +42,38 @@ class FakeLogger:
|
||||
|
||||
|
||||
class FakeConfigManager:
|
||||
@staticmethod
|
||||
def is_chat_whitelist_enabled() -> bool:
|
||||
return False
|
||||
def __init__(self, whitelist_enabled: bool = False) -> None:
|
||||
self._whitelist_enabled = whitelist_enabled
|
||||
|
||||
def is_chat_whitelist_enabled(self) -> bool:
|
||||
return self._whitelist_enabled
|
||||
|
||||
|
||||
class FakeUserService:
|
||||
@staticmethod
|
||||
def get_user_by_ip(ip_address: str, require_whitelist_access: bool = True):
|
||||
_UNSET = object()
|
||||
|
||||
def __init__(self, *, user=_UNSET, accessible_models=None) -> None:
|
||||
self._user = {"username": "tester", "model_permissions": "*"} if user is self._UNSET else user
|
||||
self._accessible_models = None if accessible_models is None else list(accessible_models)
|
||||
|
||||
def get_user_by_ip(self, ip_address: str, require_whitelist_access: bool = True):
|
||||
del ip_address, require_whitelist_access
|
||||
return {"username": "tester"}
|
||||
return self._user
|
||||
|
||||
def can_user_access_model(self, user, model_name: str, available_models=None) -> bool:
|
||||
del user
|
||||
if self._accessible_models is None:
|
||||
return True
|
||||
return model_name in set(self._accessible_models)
|
||||
|
||||
def get_accessible_models_for_user(self, user, available_models=None):
|
||||
del user
|
||||
if self._accessible_models is None:
|
||||
return list(available_models or [])
|
||||
if available_models is None:
|
||||
return list(self._accessible_models)
|
||||
available_set = set(available_models)
|
||||
return [model_name for model_name in self._accessible_models if model_name in available_set]
|
||||
|
||||
|
||||
class FakeLogService:
|
||||
@@ -154,6 +176,93 @@ class ProxyControllerErrorFormatTests(unittest.TestCase):
|
||||
response.get_json(),
|
||||
)
|
||||
|
||||
def test_list_models_filters_to_models_allowed_for_whitelisted_user(self) -> None:
|
||||
provider = LLMProvider(
|
||||
name="demo",
|
||||
api="https://example.com/v1/chat/completions",
|
||||
model_list=("gpt-4.1", "gpt-4.1-mini"),
|
||||
)
|
||||
app = Flask(__name__)
|
||||
ctx = AppContext(
|
||||
logger=FakeLogger(),
|
||||
config_manager=FakeConfigManager(whitelist_enabled=True),
|
||||
root_path=Path(__file__).resolve().parents[1],
|
||||
flask_app=app,
|
||||
)
|
||||
ProxyController(
|
||||
ctx,
|
||||
StubProxyService((None, 200, None)),
|
||||
FakeUserService(accessible_models=["demo/gpt-4.1-mini"]),
|
||||
FakeLogService(),
|
||||
FakeProviderManager(provider),
|
||||
)
|
||||
|
||||
response = app.test_client().get("/v1/models", environ_base={"REMOTE_ADDR": "127.0.0.1"})
|
||||
|
||||
self.assertEqual(200, response.status_code)
|
||||
payload = response.get_json()
|
||||
self.assertEqual(["demo/gpt-4.1-mini"], [item["id"] for item in payload["data"]])
|
||||
|
||||
def test_list_models_rejects_non_whitelisted_ip_when_whitelist_enabled(self) -> None:
|
||||
provider = LLMProvider(
|
||||
name="demo",
|
||||
api="https://example.com/v1/chat/completions",
|
||||
model_list=("gpt-4.1",),
|
||||
)
|
||||
app = Flask(__name__)
|
||||
ctx = AppContext(
|
||||
logger=FakeLogger(),
|
||||
config_manager=FakeConfigManager(whitelist_enabled=True),
|
||||
root_path=Path(__file__).resolve().parents[1],
|
||||
flask_app=app,
|
||||
)
|
||||
ProxyController(
|
||||
ctx,
|
||||
StubProxyService((None, 200, None)),
|
||||
FakeUserService(user=None),
|
||||
FakeLogService(),
|
||||
FakeProviderManager(provider),
|
||||
)
|
||||
|
||||
response = app.test_client().get("/v1/models", environ_base={"REMOTE_ADDR": "127.0.0.1"})
|
||||
|
||||
self.assertEqual(403, response.status_code)
|
||||
self.assertEqual("ip_not_whitelisted", response.get_json()["error"]["code"])
|
||||
|
||||
def test_chat_completions_rejects_model_not_allowed_for_whitelisted_user(self) -> None:
|
||||
provider = LLMProvider(
|
||||
name="demo",
|
||||
api="https://example.com/v1/chat/completions",
|
||||
model_list=("gpt-4.1", "gpt-4.1-mini"),
|
||||
)
|
||||
logger = FakeLogger()
|
||||
app = Flask(__name__)
|
||||
ctx = AppContext(
|
||||
logger=logger,
|
||||
config_manager=FakeConfigManager(whitelist_enabled=True),
|
||||
root_path=Path(__file__).resolve().parents[1],
|
||||
flask_app=app,
|
||||
)
|
||||
ProxyController(
|
||||
ctx,
|
||||
StubProxyService((None, 200, None)),
|
||||
FakeUserService(accessible_models=["demo/gpt-4.1-mini"]),
|
||||
FakeLogService(),
|
||||
FakeProviderManager(provider),
|
||||
)
|
||||
|
||||
response = app.test_client().post(
|
||||
"/v1/chat/completions",
|
||||
json={"model": "demo/gpt-4.1", "messages": [{"role": "user", "content": "hi"}]},
|
||||
environ_base={"REMOTE_ADDR": "127.0.0.1"},
|
||||
)
|
||||
|
||||
self.assertEqual(403, response.status_code)
|
||||
self.assertEqual("model_not_allowed", response.get_json()["error"]["code"])
|
||||
self.assertTrue(
|
||||
any("is not allowed to access model=demo/gpt-4.1" in msg for msg in logger.messages("warning"))
|
||||
)
|
||||
|
||||
def test_chat_completions_returns_openai_style_error_payload_for_upstream_failures(self) -> None:
|
||||
provider = LLMProvider(
|
||||
name="demo",
|
||||
|
||||
@@ -409,28 +409,19 @@ class ProviderTemplateTransportTests(unittest.TestCase):
|
||||
self.assertIn("id=\"selectedProviderCount\"", html)
|
||||
self.assertIn("id=\"enableSelectedProvidersBtn\"", html)
|
||||
self.assertIn("id=\"disableSelectedProvidersBtn\"", html)
|
||||
self.assertIn("id=\"deleteSelectedProvidersBtn\"", html)
|
||||
self.assertIn("id=\"clearSelectedProvidersBtn\"", html)
|
||||
self.assertNotIn("id=\"deleteSelectedProvidersBtn\"", html)
|
||||
self.assertNotIn("id=\"clearSelectedProvidersBtn\"", html)
|
||||
self.assertIn("function updateProviderSelectionUi()", html)
|
||||
self.assertIn("function toggleProviderSelection(name, checked)", html)
|
||||
self.assertIn("function toggleAllProvidersSelection(checked)", html)
|
||||
self.assertIn("function clearSelectedProviders()", html)
|
||||
self.assertIn("function setProviderEnabled(name, enabled)", html)
|
||||
self.assertIn("function runProviderBatchAction(action)", html)
|
||||
self.assertIn("function openProviderBatchDeleteModal(names)", html)
|
||||
self.assertIn("function confirmProviderBatchDelete()", html)
|
||||
self.assertIn("function syncProviderBatchDeleteModal()", html)
|
||||
self.assertIn("/api/providers/batch", html)
|
||||
self.assertIn("/api/providers/${encodeURIComponent(normalizedName)}/${enabled ? 'enable' : 'disable'}", html)
|
||||
self.assertIn('id="providerSelectAllCheckbox"', html)
|
||||
self.assertIn('id="providerBatchDeleteModal"', html)
|
||||
self.assertIn('id="providerBatchDeleteMessage"', html)
|
||||
self.assertIn('id="confirmProviderBatchDeleteBtn"', html)
|
||||
self.assertIn('class="modal-dialog modal-dialog-centered provider-batch-delete-modal-dialog"', html)
|
||||
self.assertIn('data-provider-row-checkbox="${encodedProviderName}"', html)
|
||||
self.assertIn("class=\"btn-action ${isEnabled ? 'btn-delete' : 'btn-edit'}\"", html)
|
||||
self.assertIn("providerBatchDeleteModalInstance = new bootstrap.Modal(providerBatchDeleteModalElement);", html)
|
||||
self.assertNotIn('id="providerBatchDeleteModal" tabindex="-1" data-bs-backdrop="static" data-bs-keyboard="false"', html)
|
||||
self.assertNotIn("window.confirm(`确认批量删除已选中的 ${selectedNames.length} 个 Provider?`)", html)
|
||||
self.assertNotIn("setupCustomSelect('providerFormat');", html)
|
||||
self.assertNotIn("setupCustomSelect('providerStreamFormat');", html)
|
||||
@@ -511,7 +502,7 @@ class ProviderTemplateTransportTests(unittest.TestCase):
|
||||
|
||||
self.assertIn(".providers-page .provider-help-popover {", css)
|
||||
self.assertIn(".providers-page .provider-batch-summary {", css)
|
||||
self.assertIn(".providers-page .provider-batch-delete-modal-dialog {", css)
|
||||
self.assertNotIn(".providers-page .provider-batch-delete-modal-dialog {", css)
|
||||
self.assertIn(".providers-page .provider-table-checkbox {", css)
|
||||
self.assertIn(".providers-page .field-label-with-help {", css)
|
||||
self.assertIn(".providers-page .entry-import-editor {", css)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from src.repositories import UserRepository
|
||||
from src.services import UserService
|
||||
from src.utils.database import create_connection_factory
|
||||
|
||||
|
||||
class DummyLogger:
|
||||
def info(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def error(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def warning(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
def debug(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class FakeConfigManager:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def get_raw_config(self):
|
||||
return deepcopy(self.payload)
|
||||
|
||||
|
||||
class UserModelPermissionTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tempdir = tempfile.TemporaryDirectory()
|
||||
self._db_path = Path(self._tempdir.name) / "requests.db"
|
||||
self._get_connection = create_connection_factory(self._db_path)
|
||||
self._config = {
|
||||
"providers": [
|
||||
{
|
||||
"name": "demo",
|
||||
"api": "https://example.com/v1/chat/completions",
|
||||
"api_key": "sk-demo",
|
||||
"model_list": ["gpt-4.1", "gpt-4.1-mini"],
|
||||
}
|
||||
]
|
||||
}
|
||||
self._ctx = SimpleNamespace(
|
||||
logger=DummyLogger(),
|
||||
config_manager=FakeConfigManager(self._config),
|
||||
)
|
||||
self._repository = UserRepository(self._get_connection)
|
||||
self._service = UserService(self._ctx, self._repository)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tempdir.cleanup()
|
||||
|
||||
def test_user_repository_defaults_model_permissions_to_wildcard(self) -> None:
|
||||
user_id = self._repository.create("alice", "127.0.0.1")
|
||||
|
||||
user = self._repository.get_by_id(user_id)
|
||||
|
||||
self.assertEqual("*", user["model_permissions"])
|
||||
|
||||
def test_update_user_model_permissions_supports_explicit_models(self) -> None:
|
||||
user_id = self._repository.create("alice", "127.0.0.1")
|
||||
|
||||
updated = self._service.update_user(
|
||||
user_id,
|
||||
model_permissions_provided=True,
|
||||
model_permissions=["demo/gpt-4.1-mini"],
|
||||
)
|
||||
user = self._service.get_user_by_id(user_id)
|
||||
|
||||
self.assertTrue(updated)
|
||||
self.assertEqual(["demo/gpt-4.1-mini"], user["model_permissions"])
|
||||
self.assertFalse(self._service.can_user_access_model(user, "demo/gpt-4.1"))
|
||||
self.assertTrue(self._service.can_user_access_model(user, "demo/gpt-4.1-mini"))
|
||||
|
||||
def test_sync_model_permissions_prunes_deleted_models_from_explicit_users(self) -> None:
|
||||
user_id = self._repository.create("alice", "127.0.0.1")
|
||||
self._service.update_user(
|
||||
user_id,
|
||||
model_permissions_provided=True,
|
||||
model_permissions=["demo/gpt-4.1", "demo/gpt-4.1-mini"],
|
||||
)
|
||||
|
||||
self._config["providers"][0]["model_list"] = ["gpt-4.1"]
|
||||
updated_count = self._service.sync_model_permissions()
|
||||
user = self._service.get_user_by_id(user_id)
|
||||
|
||||
self.assertEqual(1, updated_count)
|
||||
self.assertEqual(["demo/gpt-4.1"], user["model_permissions"])
|
||||
|
||||
def test_batch_update_model_permissions_updates_multiple_users(self) -> None:
|
||||
alice_id = self._repository.create("alice", "127.0.0.1")
|
||||
bob_id = self._repository.create("bob", "127.0.0.2")
|
||||
|
||||
result = self._service.batch_update_model_permissions(
|
||||
[alice_id, bob_id],
|
||||
["demo/gpt-4.1-mini"],
|
||||
)
|
||||
|
||||
alice = self._service.get_user_by_id(alice_id)
|
||||
bob = self._service.get_user_by_id(bob_id)
|
||||
self.assertEqual(2, result["count"])
|
||||
self.assertEqual(["demo/gpt-4.1-mini"], result["model_permissions"])
|
||||
self.assertEqual(["demo/gpt-4.1-mini"], alice["model_permissions"])
|
||||
self.assertEqual(["demo/gpt-4.1-mini"], bob["model_permissions"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user