feat: 支持 ONLYOFFICE 持久化配置管理

- 添加 SettingsRenderForm schema 和 renderForm 字段
- 实现数据库 schema 自动迁移(onlyoffice_enabled, onlyoffice_public_url, onlyoffice_jwt_secret_encrypted)
- 新增 resolve_onlyoffice_settings() 函数支持运行时配置解析
- 知识库服务改用数据库配置替代运行时配置
- 前端添加文件渲染配置页面,支持 JWT 密钥管理
- 完善相关测试覆盖
This commit is contained in:
caoxiaozhu
2026-05-09 08:02:01 +00:00
parent 94122fd34b
commit 4fbd313f35
14 changed files with 735 additions and 314 deletions

2
.env
View File

@@ -27,7 +27,7 @@ SERVER_BLOCKING_STARTUP_TIMEOUT=12
VITE_API_BASE_URL=/api/v1 VITE_API_BASE_URL=/api/v1
VITE_AUTH_IDLE_TIMEOUT_MINUTES=30 VITE_AUTH_IDLE_TIMEOUT_MINUTES=30
ONLYOFFICE_ENABLED=true ONLYOFFICE_ENABLED=true
ONLYOFFICE_PUBLIC_URL=http://onlyoffice:80 ONLYOFFICE_PUBLIC_URL=http://10.10.10.122:8082
ONLYOFFICE_BACKEND_URL=http://main:8000 ONLYOFFICE_BACKEND_URL=http://main:8000
ONLYOFFICE_JWT_SECRET=change-me-onlyoffice ONLYOFFICE_JWT_SECRET=change-me-onlyoffice

View File

@@ -39,6 +39,8 @@ class SystemSetting(Base):
embedding_provider: Mapped[str] = mapped_column(String(64), default="GLM") embedding_provider: Mapped[str] = mapped_column(String(64), default="GLM")
embedding_model: Mapped[str] = mapped_column(String(255), default="Embedding-3") embedding_model: Mapped[str] = mapped_column(String(255), default="Embedding-3")
embedding_endpoint: Mapped[str] = mapped_column(String(512), default="https://open.bigmodel.cn/api/paas/v4/") embedding_endpoint: Mapped[str] = mapped_column(String(512), default="https://open.bigmodel.cn/api/paas/v4/")
onlyoffice_enabled: Mapped[bool] = mapped_column(Boolean, default=False)
onlyoffice_public_url: Mapped[str] = mapped_column(String(512), default="")
log_level: Mapped[str] = mapped_column(String(16), default="INFO") log_level: Mapped[str] = mapped_column(String(16), default="INFO")
retention_days: Mapped[int] = mapped_column(Integer, default=180) retention_days: Mapped[int] = mapped_column(Integer, default=180)

View File

@@ -18,6 +18,7 @@ class SystemSettingSecret(Base):
backup_api_key_encrypted: Mapped[str] = mapped_column(Text, default="") backup_api_key_encrypted: Mapped[str] = mapped_column(Text, default="")
vlm_api_key_encrypted: Mapped[str] = mapped_column(Text, default="") vlm_api_key_encrypted: Mapped[str] = mapped_column(Text, default="")
embedding_api_key_encrypted: Mapped[str] = mapped_column(Text, default="") embedding_api_key_encrypted: Mapped[str] = mapped_column(Text, default="")
onlyoffice_jwt_secret_encrypted: Mapped[str] = mapped_column(Text, default="")
smtp_password_encrypted: Mapped[str] = mapped_column(Text, default="") smtp_password_encrypted: Mapped[str] = mapped_column(Text, default="")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View File

@@ -110,6 +110,20 @@ class SettingsLogForm(BaseModel):
return value.strip() return value.strip()
class SettingsRenderForm(BaseModel):
enabled: bool = False
publicUrl: str = Field(default="", max_length=512)
jwtSecret: str = Field(default="", max_length=1024)
jwtSecretConfigured: bool = False
@field_validator("publicUrl", "jwtSecret", mode="before")
@classmethod
def strip_string(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip()
class SettingsMailForm(BaseModel): class SettingsMailForm(BaseModel):
smtpHost: str = Field(min_length=1, max_length=255) smtpHost: str = Field(min_length=1, max_length=255)
port: int = Field(default=465, ge=1, le=65535) port: int = Field(default=465, ge=1, le=65535)
@@ -146,6 +160,7 @@ class SettingsRead(BaseModel):
companyForm: SettingsCompanyForm companyForm: SettingsCompanyForm
adminForm: SettingsAdminForm adminForm: SettingsAdminForm
llmForm: SettingsLlmForm llmForm: SettingsLlmForm
renderForm: SettingsRenderForm
logForm: SettingsLogForm logForm: SettingsLogForm
mailForm: SettingsMailForm mailForm: SettingsMailForm
@@ -154,6 +169,7 @@ class SettingsWrite(BaseModel):
companyForm: SettingsCompanyForm companyForm: SettingsCompanyForm
adminForm: SettingsAdminForm adminForm: SettingsAdminForm
llmForm: SettingsLlmForm llmForm: SettingsLlmForm
renderForm: SettingsRenderForm
logForm: SettingsLogForm logForm: SettingsLogForm
mailForm: SettingsMailForm mailForm: SettingsMailForm

View File

@@ -28,6 +28,7 @@ from app.schemas.knowledge import (
KnowledgePreviewPageRead, KnowledgePreviewPageRead,
KnowledgePreviewStatRead, KnowledgePreviewStatRead,
) )
from app.services.settings import resolve_onlyoffice_settings
logger = get_logger("app.services.knowledge") logger = get_logger("app.services.knowledge")
@@ -239,34 +240,35 @@ class KnowledgeService:
) -> KnowledgeOnlyOfficeConfigRead: ) -> KnowledgeOnlyOfficeConfigRead:
self.ensure_library_ready() self.ensure_library_ready()
settings = get_settings() settings = get_settings()
if not settings.onlyoffice_enabled: onlyoffice_settings = resolve_onlyoffice_settings()
if not onlyoffice_settings.enabled:
logger.warning( logger.warning(
"ONLYOFFICE disabled in runtime config doc=%s enabled=%s public_url=%s backend_url=%s jwt_set=%s", "ONLYOFFICE disabled in runtime config doc=%s enabled=%s public_url=%s backend_url=%s jwt_set=%s",
document_id, document_id,
settings.onlyoffice_enabled, onlyoffice_settings.enabled,
settings.onlyoffice_public_url, onlyoffice_settings.public_url,
settings.onlyoffice_backend_url, onlyoffice_settings.backend_url,
bool(settings.onlyoffice_jwt_secret), bool(onlyoffice_settings.jwt_secret),
) )
raise ValueError("ONLYOFFICE 预览未启用。") raise ValueError("ONLYOFFICE 预览未启用。")
if not settings.onlyoffice_public_url or not settings.onlyoffice_backend_url: if not onlyoffice_settings.public_url or not onlyoffice_settings.backend_url:
logger.warning( logger.warning(
"ONLYOFFICE config incomplete doc=%s enabled=%s public_url=%s backend_url=%s jwt_set=%s", "ONLYOFFICE config incomplete doc=%s enabled=%s public_url=%s backend_url=%s jwt_set=%s",
document_id, document_id,
settings.onlyoffice_enabled, onlyoffice_settings.enabled,
settings.onlyoffice_public_url, onlyoffice_settings.public_url,
settings.onlyoffice_backend_url, onlyoffice_settings.backend_url,
bool(settings.onlyoffice_jwt_secret), bool(onlyoffice_settings.jwt_secret),
) )
raise ValueError("ONLYOFFICE 地址配置不完整。") raise ValueError("ONLYOFFICE 地址配置不完整。")
if not settings.onlyoffice_jwt_secret: if not onlyoffice_settings.jwt_secret:
logger.warning( logger.warning(
"ONLYOFFICE JWT missing doc=%s enabled=%s public_url=%s backend_url=%s jwt_set=%s", "ONLYOFFICE JWT missing doc=%s enabled=%s public_url=%s backend_url=%s jwt_set=%s",
document_id, document_id,
settings.onlyoffice_enabled, onlyoffice_settings.enabled,
settings.onlyoffice_public_url, onlyoffice_settings.public_url,
settings.onlyoffice_backend_url, onlyoffice_settings.backend_url,
bool(settings.onlyoffice_jwt_secret), bool(onlyoffice_settings.jwt_secret),
) )
raise ValueError("ONLYOFFICE JWT 密钥未配置。") raise ValueError("ONLYOFFICE JWT 密钥未配置。")
@@ -277,8 +279,8 @@ class KnowledgeService:
raise ValueError("当前文件格式不支持 ONLYOFFICE 预览。") raise ValueError("当前文件格式不支持 ONLYOFFICE 预览。")
document_type = self._resolve_onlyoffice_document_type(extension) document_type = self._resolve_onlyoffice_document_type(extension)
backend_base_url = settings.onlyoffice_backend_url.rstrip("/") backend_base_url = onlyoffice_settings.backend_url.rstrip("/")
public_url = settings.onlyoffice_public_url.rstrip("/") public_url = onlyoffice_settings.public_url.rstrip("/")
access_token = self._build_onlyoffice_access_token(document_id) access_token = self._build_onlyoffice_access_token(document_id)
document_url = ( document_url = (
f"{backend_base_url}{settings.api_v1_prefix}/knowledge/documents/{document_id}/onlyoffice/content" f"{backend_base_url}{settings.api_v1_prefix}/knowledge/documents/{document_id}/onlyoffice/content"
@@ -322,7 +324,7 @@ class KnowledgeService:
"width": "100%", "width": "100%",
"height": "100%", "height": "100%",
} }
config["token"] = jwt.encode(config, settings.onlyoffice_jwt_secret, algorithm="HS256") config["token"] = jwt.encode(config, onlyoffice_settings.jwt_secret, algorithm="HS256")
return KnowledgeOnlyOfficeConfigRead( return KnowledgeOnlyOfficeConfigRead(
documentServerUrl=public_url, documentServerUrl=public_url,
@@ -330,11 +332,11 @@ class KnowledgeService:
) )
def validate_onlyoffice_access_token(self, document_id: str, access_token: str) -> None: def validate_onlyoffice_access_token(self, document_id: str, access_token: str) -> None:
settings = get_settings() onlyoffice_settings = resolve_onlyoffice_settings()
try: try:
payload = jwt.decode( payload = jwt.decode(
access_token, access_token,
settings.onlyoffice_jwt_secret, onlyoffice_settings.jwt_secret,
algorithms=["HS256"], algorithms=["HS256"],
) )
except jwt.PyJWTError as exc: except jwt.PyJWTError as exc:
@@ -666,12 +668,12 @@ class KnowledgeService:
return f"{entry['id']}-v{version}-{checksum or 'nochecksum'}" return f"{entry['id']}-v{version}-{checksum or 'nochecksum'}"
def _build_onlyoffice_access_token(self, document_id: str) -> str: def _build_onlyoffice_access_token(self, document_id: str) -> str:
settings = get_settings() onlyoffice_settings = resolve_onlyoffice_settings()
payload = { payload = {
"scope": "onlyoffice-content", "scope": "onlyoffice-content",
"document_id": document_id, "document_id": document_id,
} }
return jwt.encode(payload, settings.onlyoffice_jwt_secret, algorithm="HS256") return jwt.encode(payload, onlyoffice_settings.jwt_secret, algorithm="HS256")
@staticmethod @staticmethod
def _resolve_onlyoffice_document_type(extension: str) -> str: def _resolve_onlyoffice_document_type(extension: str) -> str:

View File

@@ -3,6 +3,7 @@ from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from sqlalchemy import inspect, text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.core.admin_secret import legacy_admin_secret_to_password_hash, read_admin_secret, verify_admin_secret from app.core.admin_secret import legacy_admin_secret_to_password_hash, read_admin_secret, verify_admin_secret
@@ -10,6 +11,7 @@ from app.core.config import get_settings
from app.core.secret_box import decrypt_secret, encrypt_secret from app.core.secret_box import decrypt_secret, encrypt_secret
from app.core.security import hash_password, verify_password from app.core.security import hash_password, verify_password
from app.db.base import Base from app.db.base import Base
from app.db.session import get_session_factory
from app.models.system_model_setting import SystemModelSetting from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret from app.models.system_setting_secret import SystemSettingSecret
@@ -85,6 +87,14 @@ class AdminCredentialRecord:
password_hash: str password_hash: str
@dataclass(frozen=True, slots=True)
class OnlyOfficeRuntimeConfig:
enabled: bool
public_url: str
backend_url: str
jwt_secret: str
class SettingsService: class SettingsService:
def __init__(self, db: Session) -> None: def __init__(self, db: Session) -> None:
self.db = db self.db = db
@@ -93,6 +103,7 @@ class SettingsService:
def ensure_settings_ready(self) -> tuple[SystemSetting, SystemSettingSecret]: def ensure_settings_ready(self) -> tuple[SystemSetting, SystemSettingSecret]:
Base.metadata.create_all(bind=self.db.get_bind()) Base.metadata.create_all(bind=self.db.get_bind())
self._ensure_settings_schema()
settings_row = self.repository.get_settings() settings_row = self.repository.get_settings()
secrets_row = self.repository.get_secrets() secrets_row = self.repository.get_secrets()
@@ -116,6 +127,9 @@ class SettingsService:
settings_row.admin_account = admin_username settings_row.admin_account = admin_username
should_commit = True should_commit = True
if self._sync_onlyoffice_defaults(settings_row, secrets_row):
should_commit = True
if should_commit: if should_commit:
self.db.commit() self.db.commit()
self.db.refresh(settings_row) self.db.refresh(settings_row)
@@ -215,6 +229,15 @@ class SettingsService:
payload.llmForm.embeddingApiKey, payload.llmForm.embeddingApiKey,
) )
if payload.renderForm.enabled and not payload.renderForm.publicUrl:
raise ValueError("启用 ONLYOFFICE 时必须配置服务地址。")
if (
payload.renderForm.enabled
and not payload.renderForm.jwtSecret
and not secrets_row.onlyoffice_jwt_secret_encrypted
):
raise ValueError("启用 ONLYOFFICE 时必须配置 JWT 密钥。")
settings_row.main_provider = model_rows["main"].provider settings_row.main_provider = model_rows["main"].provider
settings_row.main_model = model_rows["main"].model_name settings_row.main_model = model_rows["main"].model_name
settings_row.main_endpoint = model_rows["main"].endpoint settings_row.main_endpoint = model_rows["main"].endpoint
@@ -227,6 +250,8 @@ class SettingsService:
settings_row.embedding_provider = model_rows["embedding"].provider settings_row.embedding_provider = model_rows["embedding"].provider
settings_row.embedding_model = model_rows["embedding"].model_name settings_row.embedding_model = model_rows["embedding"].model_name
settings_row.embedding_endpoint = model_rows["embedding"].endpoint settings_row.embedding_endpoint = model_rows["embedding"].endpoint
settings_row.onlyoffice_enabled = payload.renderForm.enabled
settings_row.onlyoffice_public_url = payload.renderForm.publicUrl
settings_row.log_level = payload.logForm.level settings_row.log_level = payload.logForm.level
settings_row.retention_days = payload.logForm.retentionDays settings_row.retention_days = payload.logForm.retentionDays
@@ -248,6 +273,11 @@ class SettingsService:
settings_row.digest_time = payload.mailForm.digestTime settings_row.digest_time = payload.mailForm.digestTime
settings_row.default_receiver = payload.mailForm.defaultReceiver settings_row.default_receiver = payload.mailForm.defaultReceiver
self._replace_secret_if_present(
secrets_row,
"onlyoffice_jwt_secret_encrypted",
payload.renderForm.jwtSecret,
)
self._replace_secret_if_present(secrets_row, "smtp_password_encrypted", payload.mailForm.password) self._replace_secret_if_present(secrets_row, "smtp_password_encrypted", payload.mailForm.password)
self.db.add(settings_row) self.db.add(settings_row)
@@ -376,6 +406,8 @@ class SettingsService:
embedding_provider="GLM", embedding_provider="GLM",
embedding_model="Embedding-3", embedding_model="Embedding-3",
embedding_endpoint="https://open.bigmodel.cn/api/paas/v4/", embedding_endpoint="https://open.bigmodel.cn/api/paas/v4/",
onlyoffice_enabled=bool(self.runtime_settings.onlyoffice_enabled),
onlyoffice_public_url=str(self.runtime_settings.onlyoffice_public_url or "").strip(),
log_level="INFO", log_level="INFO",
retention_days=180, retention_days=180,
archive_cycle="weekly", archive_cycle="weekly",
@@ -420,6 +452,64 @@ class SettingsService:
if normalized_api_key: if normalized_api_key:
model_row.api_key_encrypted = encrypt_secret(normalized_api_key) model_row.api_key_encrypted = encrypt_secret(normalized_api_key)
def _ensure_settings_schema(self) -> None:
bind = self.db.get_bind()
inspector = inspect(bind)
table_names = set(inspector.get_table_names())
migration_statements: list[str] = []
if "system_settings" in table_names:
settings_columns = {column["name"] for column in inspector.get_columns("system_settings")}
if "onlyoffice_enabled" not in settings_columns:
migration_statements.append(
"ALTER TABLE system_settings ADD COLUMN onlyoffice_enabled BOOLEAN DEFAULT 0"
)
if "onlyoffice_public_url" not in settings_columns:
migration_statements.append(
"ALTER TABLE system_settings ADD COLUMN onlyoffice_public_url VARCHAR(512) DEFAULT ''"
)
if "system_setting_secrets" in table_names:
secret_columns = {column["name"] for column in inspector.get_columns("system_setting_secrets")}
if "onlyoffice_jwt_secret_encrypted" not in secret_columns:
migration_statements.append(
"ALTER TABLE system_setting_secrets ADD COLUMN onlyoffice_jwt_secret_encrypted TEXT DEFAULT ''"
)
for statement in migration_statements:
self.db.execute(text(statement))
if migration_statements:
self.db.commit()
def _sync_onlyoffice_defaults(
self,
settings_row: SystemSetting,
secrets_row: SystemSettingSecret,
) -> bool:
should_commit = False
runtime_public_url = str(self.runtime_settings.onlyoffice_public_url or "").strip()
runtime_jwt_secret = str(self.runtime_settings.onlyoffice_jwt_secret or "").strip()
if not str(settings_row.onlyoffice_public_url or "").strip() and runtime_public_url:
settings_row.onlyoffice_public_url = runtime_public_url
should_commit = True
if not secrets_row.onlyoffice_jwt_secret_encrypted and runtime_jwt_secret:
secrets_row.onlyoffice_jwt_secret_encrypted = encrypt_secret(runtime_jwt_secret)
should_commit = True
if (
not settings_row.onlyoffice_enabled
and self.runtime_settings.onlyoffice_enabled
and (runtime_public_url or runtime_jwt_secret)
):
settings_row.onlyoffice_enabled = True
should_commit = True
return should_commit
@staticmethod @staticmethod
def _serialize( def _serialize(
settings_row: SystemSetting, settings_row: SystemSetting,
@@ -473,6 +563,12 @@ class SettingsService:
"embeddingApiKey": "", "embeddingApiKey": "",
"embeddingApiKeyConfigured": bool(embedding_model.api_key_encrypted), "embeddingApiKeyConfigured": bool(embedding_model.api_key_encrypted),
}, },
renderForm={
"enabled": settings_row.onlyoffice_enabled,
"publicUrl": settings_row.onlyoffice_public_url,
"jwtSecret": "",
"jwtSecretConfigured": bool(secrets_row.onlyoffice_jwt_secret_encrypted),
},
logForm={ logForm={
"level": settings_row.log_level, "level": settings_row.log_level,
"retentionDays": settings_row.retention_days, "retentionDays": settings_row.retention_days,
@@ -498,3 +594,38 @@ class SettingsService:
"defaultReceiver": settings_row.default_receiver, "defaultReceiver": settings_row.default_receiver,
}, },
) )
def resolve_onlyoffice_settings(db: Session | None = None) -> OnlyOfficeRuntimeConfig:
runtime_settings = get_settings()
owned_session = False
session = db
try:
if session is None:
session_factory = get_session_factory()
session = session_factory()
owned_session = True
service = SettingsService(session)
settings_row, secrets_row = service.ensure_settings_ready()
jwt_secret = ""
if secrets_row.onlyoffice_jwt_secret_encrypted:
jwt_secret = decrypt_secret(secrets_row.onlyoffice_jwt_secret_encrypted)
return OnlyOfficeRuntimeConfig(
enabled=settings_row.onlyoffice_enabled,
public_url=str(settings_row.onlyoffice_public_url or "").strip(),
backend_url=str(runtime_settings.onlyoffice_backend_url or "").strip(),
jwt_secret=jwt_secret,
)
except Exception:
return OnlyOfficeRuntimeConfig(
enabled=bool(runtime_settings.onlyoffice_enabled),
public_url=str(runtime_settings.onlyoffice_public_url or "").strip(),
backend_url=str(runtime_settings.onlyoffice_backend_url or "").strip(),
jwt_secret=str(runtime_settings.onlyoffice_jwt_secret or "").strip(),
)
finally:
if owned_session and session is not None:
session.close()

View File

@@ -1,8 +1,31 @@
from __future__ import annotations from __future__ import annotations
from pathlib import Path
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.core.config import Settings, get_settings from app.core.config import Settings, get_settings
from app.core import secret_box
from app.db.base import Base
from app.models.system_model_setting import SystemModelSetting
from app.models.system_setting import SystemSetting
from app.models.system_setting_secret import SystemSettingSecret
from app.schemas.settings import SettingsWrite
from app.services.knowledge import KnowledgeService from app.services.knowledge import KnowledgeService
from app.services.settings import SettingsService
def build_session_factory(db_file: Path):
engine = create_engine(
f"sqlite+pysqlite:///{db_file.as_posix()}",
connect_args={"check_same_thread": False},
)
SystemSetting.__table__.create(bind=engine)
SystemSettingSecret.__table__.create(bind=engine)
SystemModelSetting.__table__.create(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)
def test_onlyoffice_config_is_read_only_for_admin_users(tmp_path, monkeypatch) -> None: def test_onlyoffice_config_is_read_only_for_admin_users(tmp_path, monkeypatch) -> None:
@@ -53,3 +76,61 @@ def test_onlyoffice_config_is_read_only_for_admin_users(tmp_path, monkeypatch) -
finally: finally:
monkeypatch.setitem(Settings.model_config, "env_file", original_env_file) monkeypatch.setitem(Settings.model_config, "env_file", original_env_file)
get_settings.cache_clear() get_settings.cache_clear()
def test_onlyoffice_config_prefers_saved_settings_snapshot(tmp_path, monkeypatch) -> None:
env_file = tmp_path / ".env"
env_file.write_text(
"\n".join(
[
"ADMIN_EMAIL=admin@example.com",
"ONLYOFFICE_ENABLED=false",
"ONLYOFFICE_BACKEND_URL=http://main:8000",
]
)
+ "\n",
encoding="utf-8",
)
original_env_file = Settings.model_config.get("env_file")
monkeypatch.setitem(Settings.model_config, "env_file", (env_file,))
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", tmp_path / "settings.key")
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
get_settings.cache_clear()
session_factory = build_session_factory(tmp_path / "settings.db")
monkeypatch.setattr("app.services.settings.get_session_factory", lambda: session_factory)
try:
with session_factory() as db:
service = SettingsService(db)
payload = service.get_settings_snapshot().model_dump()
payload["renderForm"]["enabled"] = True
payload["renderForm"]["publicUrl"] = "http://10.10.10.122:8082"
payload["renderForm"]["jwtSecret"] = "change-me-onlyoffice"
service.save_settings_snapshot(SettingsWrite(**payload))
service = KnowledgeService(storage_root=tmp_path)
service.ensure_library_ready()
document_id = "db-backed-docx"
folder = "制度政策"
stored_name = f"{document_id}__制度预览.docx"
target_path = tmp_path / "knowledge" / folder / stored_name
target_path.write_bytes(b"fake-docx-content")
current_user = CurrentUserContext(
username="admin",
name="管理员",
role_codes=["manager"],
is_admin=True,
)
config = service.build_onlyoffice_config(document_id, current_user)
assert config.documentServerUrl == "http://10.10.10.122:8082"
assert config.config["document"]["url"].startswith(
"http://main:8000/api/v1/knowledge/documents/db-backed-docx/onlyoffice/content?access_token="
)
finally:
monkeypatch.setitem(Settings.model_config, "env_file", original_env_file)
get_settings.cache_clear()

View File

@@ -53,6 +53,9 @@ def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) ->
payload["adminForm"]["confirmPassword"] = "54321" payload["adminForm"]["confirmPassword"] = "54321"
payload["llmForm"]["mainModel"] = "glm-4.5" payload["llmForm"]["mainModel"] = "glm-4.5"
payload["llmForm"]["mainApiKey"] = "main-secret" payload["llmForm"]["mainApiKey"] = "main-secret"
payload["renderForm"]["enabled"] = True
payload["renderForm"]["publicUrl"] = "http://10.10.10.122:8082"
payload["renderForm"]["jwtSecret"] = "change-me-onlyoffice"
payload["mailForm"]["password"] = "smtp-secret" payload["mailForm"]["password"] = "smtp-secret"
saved_snapshot = service.save_settings_snapshot(SettingsWrite(**payload)) saved_snapshot = service.save_settings_snapshot(SettingsWrite(**payload))
@@ -62,15 +65,26 @@ def test_settings_service_persists_non_secret_and_secret_fields(monkeypatch) ->
assert saved_snapshot.llmForm.mainModel == "glm-4.5" assert saved_snapshot.llmForm.mainModel == "glm-4.5"
assert saved_snapshot.llmForm.mainApiKey == "" assert saved_snapshot.llmForm.mainApiKey == ""
assert saved_snapshot.llmForm.mainApiKeyConfigured is True assert saved_snapshot.llmForm.mainApiKeyConfigured is True
assert saved_snapshot.renderForm.enabled is True
assert saved_snapshot.renderForm.publicUrl == "http://10.10.10.122:8082"
assert saved_snapshot.renderForm.jwtSecret == ""
assert saved_snapshot.renderForm.jwtSecretConfigured is True
assert saved_snapshot.mailForm.password == "" assert saved_snapshot.mailForm.password == ""
assert saved_snapshot.mailForm.passwordConfigured is True assert saved_snapshot.mailForm.passwordConfigured is True
assert saved_snapshot.adminForm.newPassword == "" assert saved_snapshot.adminForm.newPassword == ""
assert saved_snapshot.adminForm.adminPasswordConfigured is True assert saved_snapshot.adminForm.adminPasswordConfigured is True
model_row = db.get(SystemModelSetting, "main") model_row = db.get(SystemModelSetting, "main")
settings_row = db.get(SystemSetting, "default")
secrets_row = db.get(SystemSettingSecret, "default")
assert model_row is not None assert model_row is not None
assert model_row.model_name == "glm-4.5" assert model_row.model_name == "glm-4.5"
assert model_row.api_key_encrypted assert model_row.api_key_encrypted
assert settings_row is not None
assert settings_row.onlyoffice_enabled is True
assert settings_row.onlyoffice_public_url == "http://10.10.10.122:8082"
assert secrets_row is not None
assert secrets_row.onlyoffice_jwt_secret_encrypted
assert service.load_saved_model_api_key("main") == "main-secret" assert service.load_saved_model_api_key("main") == "main-secret"
assert service.verify_admin_login("admin-root", "54321") is not None assert service.verify_admin_login("admin-root", "54321") is not None

View File

@@ -403,6 +403,53 @@
</div> </div>
</template> </template>
<template v-else-if="activeSection === 'rendering'">
<section class="settings-card">
<div class="card-head">
<div>
<h4>ONLYOFFICE 服务配置</h4>
<p>维护文件渲染开关文档服务对外地址和 JWT 密钥后端回调地址继续由部署配置管理</p>
</div>
</div>
<div class="switch-group">
<button class="switch-row" type="button" @click="toggleBoolean('renderForm', 'enabled')">
<span class="switch-copy">
<strong>启用 ONLYOFFICE 文件渲染</strong>
<small>启用后知识库中的 Office 文件将优先走 ONLYOFFICE 在线预览</small>
</span>
<span class="switch" :class="{ active: pageState.renderForm.enabled }"><i></i></span>
</button>
</div>
<div class="form-grid">
<label class="field field-full">
<span><em>*</em> ONLYOFFICE 服务地址</span>
<input
v-model="pageState.renderForm.publicUrl"
type="text"
placeholder="例如 http://10.10.10.122:8082"
/>
</label>
<label class="field field-full">
<span><em>*</em> JWT 密钥</span>
<input
v-model="pageState.renderForm.jwtSecret"
type="password"
autocomplete="off"
@focus="clearRenderSecretMask"
:placeholder="pageState.renderForm.jwtSecretConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
/>
<small v-if="pageState.renderForm.jwtSecretConfigured" class="secret-bound-state">
<i class="mdi mdi-database-lock"></i>
<span>已从数据库加密加载预览签名会使用已保存密钥</span>
</small>
</label>
</div>
</section>
</template>
<template v-else-if="activeSection === 'logs'"> <template v-else-if="activeSection === 'logs'">
<section class="settings-card"> <section class="settings-card">
<div class="card-head"> <div class="card-head">

View File

@@ -24,6 +24,7 @@ import {
shouldRenderOnlyOfficePreview shouldRenderOnlyOfficePreview
} from './knowledgePreviewMode.js' } from './knowledgePreviewMode.js'
import { resolveKnowledgePreviewLayoutState } from './knowledgePreviewLayout.js' import { resolveKnowledgePreviewLayoutState } from './knowledgePreviewLayout.js'
import { resolveInitialKnowledgeFolder } from './knowledgeFolderSelection.js'
import { buildOnlyOfficePreviewConfig } from './onlyOfficePreviewConfig.js' import { buildOnlyOfficePreviewConfig } from './onlyOfficePreviewConfig.js'
function triggerFileDownload(blob, filename) { function triggerFileDownload(blob, filename) {
@@ -80,7 +81,7 @@ export default {
const { toast } = useToast() const { toast } = useToast()
const documentSearch = ref('') const documentSearch = ref('')
const activeFolder = ref('差旅规范') const activeFolder = ref('')
const folders = ref([]) const folders = ref([])
const documents = ref([]) const documents = ref([])
const selectedDocument = ref(null) const selectedDocument = ref(null)
@@ -260,10 +261,7 @@ export default {
documents.value = payload.documents || [] documents.value = payload.documents || []
emit('summary-change', { totalDocuments: documents.value.length }) emit('summary-change', { totalDocuments: documents.value.length })
const activeExists = folders.value.some((folder) => folder.name === activeFolder.value) activeFolder.value = resolveInitialKnowledgeFolder(folders.value, activeFolder.value)
if (!activeExists) {
activeFolder.value = folders.value[0]?.name || ''
}
if (options.preserveSelection && selectedDocument.value?.id) { if (options.preserveSelection && selectedDocument.value?.id) {
const exists = documents.value.some((doc) => doc.id === selectedDocument.value.id) const exists = documents.value.some((doc) => doc.id === selectedDocument.value.id)

View File

@@ -8,6 +8,7 @@ const SETTINGS_STORAGE_KEY = 'x-financial-settings-draft'
const CURRENT_YEAR = new Date().getFullYear() const CURRENT_YEAR = new Date().getFullYear()
const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible' const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible'
const MODEL_SECRET_MASK = '********' const MODEL_SECRET_MASK = '********'
const RENDER_SECRET_MASK = '********'
const SECTION_DEFINITIONS = [ const SECTION_DEFINITIONS = [
{ {
@@ -34,6 +35,14 @@ const SECTION_DEFINITIONS = [
longDesc: '集中维护主模型、备份模型、VLM 模型和 Embedding 模型的接入参数,供 AI 助手和识别链路调用。', longDesc: '集中维护主模型、备份模型、VLM 模型和 Embedding 模型的接入参数,供 AI 助手和识别链路调用。',
actionLabel: '保存模型配置' actionLabel: '保存模型配置'
}, },
{
id: 'rendering',
label: '文件渲染',
title: 'ONLYOFFICE 文件渲染配置',
desc: '文档预览服务与访问密钥',
longDesc: '集中管理 ONLYOFFICE 文件渲染开关、文档服务地址和 JWT 密钥。服务端内部回调地址继续由部署配置维护。',
actionLabel: '保存文件渲染配置'
},
{ {
id: 'logs', id: 'logs',
label: '日志策略', label: '日志策略',
@@ -193,6 +202,12 @@ function buildDefaultState(companyProfile, currentUser) {
embeddingApiKey: '', embeddingApiKey: '',
embeddingApiKeyConfigured: false embeddingApiKeyConfigured: false
}, },
renderForm: {
enabled: false,
publicUrl: '',
jwtSecret: '',
jwtSecretConfigured: false
},
logForm: { logForm: {
level: 'INFO', level: 'INFO',
retentionDays: 180, retentionDays: 180,
@@ -252,6 +267,7 @@ function mergeState(baseState, overrideState) {
companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) }, companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) },
adminForm: { ...baseState.adminForm, ...(overrideState?.adminForm || {}) }, adminForm: { ...baseState.adminForm, ...(overrideState?.adminForm || {}) },
llmForm: mergedLlmForm, llmForm: mergedLlmForm,
renderForm: { ...baseState.renderForm, ...(overrideState?.renderForm || {}) },
logForm: { ...baseState.logForm, ...(overrideState?.logForm || {}) }, logForm: { ...baseState.logForm, ...(overrideState?.logForm || {}) },
mailForm: { ...baseState.mailForm, ...(overrideState?.mailForm || {}) } mailForm: { ...baseState.mailForm, ...(overrideState?.mailForm || {}) }
} }
@@ -272,6 +288,10 @@ function sanitizeForStorage(state) {
vlmApiKey: '', vlmApiKey: '',
embeddingApiKey: '' embeddingApiKey: ''
}, },
renderForm: {
...state.renderForm,
jwtSecret: ''
},
logForm: { ...state.logForm }, logForm: { ...state.logForm },
mailForm: { mailForm: {
...state.mailForm, ...state.mailForm,
@@ -312,6 +332,28 @@ function buildLlmPayload(llmForm) {
return payload return payload
} }
function isRenderSecretMask(value) {
return value === RENDER_SECRET_MASK
}
function maskConfiguredRenderSecret(state) {
if (state.renderForm.jwtSecretConfigured && !normalizeValue(state.renderForm.jwtSecret)) {
state.renderForm.jwtSecret = RENDER_SECRET_MASK
}
return state
}
function buildRenderPayload(renderForm) {
const payload = { ...renderForm }
if (isRenderSecretMask(payload.jwtSecret)) {
payload.jwtSecret = ''
}
return payload
}
function persistSettings(state) { function persistSettings(state) {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return return
@@ -346,6 +388,11 @@ function computeSectionStatus(state) {
state.llmForm.embeddingEndpoint state.llmForm.embeddingEndpoint
) )
), ),
rendering: Boolean(
!state.renderForm.enabled ||
(normalizeValue(state.renderForm.publicUrl) &&
(normalizeValue(state.renderForm.jwtSecret) || state.renderForm.jwtSecretConfigured))
),
logs: Boolean( logs: Boolean(
normalizeValue(state.logForm.level) && normalizeValue(state.logForm.level) &&
Number(state.logForm.retentionDays) > 0 && Number(state.logForm.retentionDays) > 0 &&
@@ -399,6 +446,7 @@ export default {
mergeDraft = false, mergeDraft = false,
preserveModelApiKeys = false, preserveModelApiKeys = false,
preserveAdminPasswords = false, preserveAdminPasswords = false,
preserveRenderSecret = false,
preserveMailPassword = false preserveMailPassword = false
} = options } = options
@@ -421,11 +469,15 @@ export default {
nextState.adminForm.confirmPassword = currentState.adminForm.confirmPassword nextState.adminForm.confirmPassword = currentState.adminForm.confirmPassword
} }
if (preserveRenderSecret) {
nextState.renderForm.jwtSecret = currentState.renderForm.jwtSecret
}
if (preserveMailPassword) { if (preserveMailPassword) {
nextState.mailForm.password = currentState.mailForm.password nextState.mailForm.password = currentState.mailForm.password
} }
pageState.value = maskConfiguredModelSecrets(nextState) pageState.value = maskConfiguredRenderSecret(maskConfiguredModelSecrets(nextState))
persistSettings(pageState.value) persistSettings(pageState.value)
updateBrandPreviewFromState(pageState.value) updateBrandPreviewFromState(pageState.value)
} }
@@ -446,6 +498,7 @@ export default {
companyForm: { ...pageState.value.companyForm }, companyForm: { ...pageState.value.companyForm },
adminForm: { ...pageState.value.adminForm }, adminForm: { ...pageState.value.adminForm },
llmForm: buildLlmPayload(pageState.value.llmForm), llmForm: buildLlmPayload(pageState.value.llmForm),
renderForm: buildRenderPayload(pageState.value.renderForm),
logForm: { ...pageState.value.logForm }, logForm: { ...pageState.value.logForm },
mailForm: { ...pageState.value.mailForm } mailForm: { ...pageState.value.mailForm }
} }
@@ -511,6 +564,12 @@ export default {
} }
} }
function clearRenderSecretMask() {
if (isRenderSecretMask(pageState.value.renderForm.jwtSecret)) {
pageState.value.renderForm.jwtSecret = ''
}
}
async function testModelConnection(testKey) { async function testModelConnection(testKey) {
const config = MODEL_TEST_CONFIGS[testKey] const config = MODEL_TEST_CONFIGS[testKey]
const payload = buildModelTestPayload(testKey) const payload = buildModelTestPayload(testKey)
@@ -562,6 +621,7 @@ export default {
await persistRemoteSettings('企业信息已保存并应用到当前系统。', { await persistRemoteSettings('企业信息已保存并应用到当前系统。', {
preserveModelApiKeys: true, preserveModelApiKeys: true,
preserveAdminPasswords: true, preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true preserveMailPassword: true
}) })
} }
@@ -599,6 +659,7 @@ export default {
await persistRemoteSettings('管理员安全设置已保存。', { await persistRemoteSettings('管理员安全设置已保存。', {
preserveModelApiKeys: true, preserveModelApiKeys: true,
preserveAdminPasswords: false, preserveAdminPasswords: false,
preserveRenderSecret: true,
preserveMailPassword: true preserveMailPassword: true
}) })
} }
@@ -622,6 +683,28 @@ export default {
await persistRemoteSettings('模型配置已保存。', { await persistRemoteSettings('模型配置已保存。', {
preserveModelApiKeys: true, preserveModelApiKeys: true,
preserveAdminPasswords: true, preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true
})
}
async function saveRenderingSection() {
const renderForm = pageState.value.renderForm
if (renderForm.enabled && !normalizeValue(renderForm.publicUrl)) {
toast('启用 ONLYOFFICE 时请输入服务地址。')
return
}
if (renderForm.enabled && !normalizeValue(renderForm.jwtSecret) && !renderForm.jwtSecretConfigured) {
toast('启用 ONLYOFFICE 时请输入 JWT 密钥。')
return
}
await persistRemoteSettings('文件渲染配置已保存。', {
preserveModelApiKeys: true,
preserveAdminPasswords: true,
preserveRenderSecret: false,
preserveMailPassword: true preserveMailPassword: true
}) })
} }
@@ -642,6 +725,7 @@ export default {
await persistRemoteSettings('日志策略已保存。', { await persistRemoteSettings('日志策略已保存。', {
preserveModelApiKeys: true, preserveModelApiKeys: true,
preserveAdminPasswords: true, preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: true preserveMailPassword: true
}) })
} }
@@ -662,6 +746,7 @@ export default {
await persistRemoteSettings('邮箱配置已保存。', { await persistRemoteSettings('邮箱配置已保存。', {
preserveModelApiKeys: true, preserveModelApiKeys: true,
preserveAdminPasswords: true, preserveAdminPasswords: true,
preserveRenderSecret: true,
preserveMailPassword: false preserveMailPassword: false
}) })
} }
@@ -687,6 +772,11 @@ export default {
return return
} }
if (activeSection.value === 'rendering') {
await saveRenderingSection()
return
}
await saveMailSection() await saveMailSection()
} }
@@ -699,6 +789,7 @@ export default {
activeSectionConfig, activeSectionConfig,
activateSection, activateSection,
applyProviderPreset, applyProviderPreset,
clearRenderSecretMask,
clearModelSecretMask, clearModelSecretMask,
completedSectionCount, completedSectionCount,
getModelTestState, getModelTestState,

View File

@@ -0,0 +1,10 @@
export function resolveInitialKnowledgeFolder(folders, currentFolder = '') {
const normalizedCurrentFolder = String(currentFolder || '').trim()
const normalizedFolders = Array.isArray(folders) ? folders : []
if (normalizedCurrentFolder && normalizedFolders.some((folder) => folder?.name === normalizedCurrentFolder)) {
return normalizedCurrentFolder
}
return normalizedFolders[0]?.name || ''
}

View File

@@ -0,0 +1,28 @@
import assert from 'node:assert/strict'
import { resolveInitialKnowledgeFolder } from '../src/views/scripts/knowledgeFolderSelection.js'
function testFallsBackToFirstFolderWhenCurrentFolderDoesNotExist() {
const folders = [{ name: '财务知识库' }, { name: '制度政策' }, { name: '差旅规范' }]
assert.equal(resolveInitialKnowledgeFolder(folders, '差旅规范(旧值)'), '财务知识库')
}
function testKeepsCurrentFolderWhenItStillExists() {
const folders = [{ name: '财务知识库' }, { name: '制度政策' }, { name: '差旅规范' }]
assert.equal(resolveInitialKnowledgeFolder(folders, '制度政策'), '制度政策')
}
function testReturnsEmptyStringWhenFoldersAreEmpty() {
assert.equal(resolveInitialKnowledgeFolder([], '差旅规范'), '')
}
function run() {
testFallsBackToFirstFolderWhenCurrentFolderDoesNotExist()
testKeepsCurrentFolderWhenItStillExists()
testReturnsEmptyStringWhenFoldersAreEmpty()
console.log('knowledge folder selection tests passed')
}
run()