feat: 添加飞书用户模块和订阅功能支持

- 新增feishu_users模块用于处理飞书用户身份验证和权限管理
- 新增subscriptions模块用于处理订阅相关功能
- 新增personalization模块用于个性化服务
- 在alembic迁移配置中注册新的模型模块
- 在API路由器中添加feishu_users和subscriptions路由
- 实现事件调度服务的改进,包括错误处理和状态更新优化
- 添加飞书命令处理的权限检查机制
- 实现飞书应用票据事件处理
- 改进审计日志记录功能
```
This commit is contained in:
2026-07-27 08:02:17 +08:00
parent db751f03b4
commit d7db84571d
148 changed files with 17110 additions and 765 deletions

418
tests/test_feishu_users.py Normal file
View File

@@ -0,0 +1,418 @@
from collections.abc import Iterator
import pytest
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.core.config import get_settings
from app.core.database import Base, get_db
from app.modules.audit.models import AuditLog
from app.modules.business.models import MarketWatchlist
from app.modules.feishu_users.constants import (
FeishuCapability,
FeishuUserAuditAction,
FeishuUserRole,
FeishuUserStatus,
parse_admin_identities,
)
from app.modules.feishu_users.models import (
FeishuAdminBootstrapTombstone,
FeishuUser,
)
from app.modules.feishu_users.routes import router
from app.modules.feishu_users.services import (
FeishuIdentityService,
FeishuUserManagementService,
)
@pytest.fixture
def session_factory() -> Iterator[sessionmaker[Session]]:
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(
engine,
tables=[
AuditLog.__table__,
FeishuAdminBootstrapTombstone.__table__,
FeishuUser.__table__,
MarketWatchlist.__table__,
],
)
factory = sessionmaker(bind=engine, expire_on_commit=False)
try:
yield factory
finally:
Base.metadata.drop_all(
engine,
tables=[
MarketWatchlist.__table__,
FeishuAdminBootstrapTombstone.__table__,
FeishuUser.__table__,
AuditLog.__table__,
],
)
engine.dispose()
def test_identity_registration_is_tenant_scoped_and_bootstraps_admin(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
service = FeishuIdentityService(
db,
admin_identities="tenant-a:ou-admin",
)
admin = service.resolve_or_register(
tenant_key="tenant-a",
open_id="ou-admin",
union_id="on-admin",
user_id="u-admin",
)
ordinary = service.resolve_or_register(
tenant_key="tenant-a",
open_id="ou-user",
)
other_tenant = service.resolve_or_register(
tenant_key="tenant-b",
open_id="ou-user",
)
assert admin.role == FeishuUserRole.ADMIN
assert admin.is_admin is True
assert admin.has_capability(FeishuCapability.USER_ADMINISTRATION) is True
assert ordinary.role == FeishuUserRole.USER
assert ordinary.has_capability(FeishuCapability.PERSONAL_AI) is True
assert ordinary.has_capability(FeishuCapability.COMPANY_REPORTS) is False
assert ordinary.owner_id != other_tenant.owner_id
assert db.scalar(select(FeishuUser).where(FeishuUser.id == admin.owner_id)).union_id == (
"on-admin"
)
registrations = list(
db.execute(
select(AuditLog).where(
AuditLog.action == FeishuUserAuditAction.REGISTER
)
).scalars()
)
assert len(registrations) == 3
assert all("ou-" not in (item.request_payload or "") for item in registrations)
def test_each_configured_admin_identity_can_bootstrap_once(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
service = FeishuIdentityService(
db,
admin_identities="tenant-a:ou-admin-a,tenant-b:ou-admin-b",
)
first = service.resolve_or_register(
tenant_key="tenant-a",
open_id="ou-admin-a",
)
second = service.resolve_or_register(
tenant_key="tenant-b",
open_id="ou-admin-b",
)
assert first.role == FeishuUserRole.ADMIN
assert second.role == FeishuUserRole.ADMIN
def test_identity_refreshes_existing_sender_without_reapplying_admin_bootstrap(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
principal = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-a",
open_id="ou-existing",
)
refreshed = FeishuIdentityService(
db,
admin_identities="tenant-a:ou-existing",
).resolve_or_register(
tenant_key="tenant-a",
open_id="ou-existing",
union_id="on-existing",
)
assert refreshed.owner_id == principal.owner_id
assert refreshed.role == FeishuUserRole.USER
assert refreshed.union_id == "on-existing"
assert db.scalar(
select(AuditLog).where(
AuditLog.action == FeishuUserAuditAction.AUTHENTICATE
)
)
def test_first_registration_claims_only_matching_legacy_watchlist(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
db.add_all(
[
MarketWatchlist(
actor="ou-claim",
symbol="600000.SH",
enabled=True,
),
MarketWatchlist(
actor="ou-other",
symbol="000001.SZ",
enabled=True,
),
]
)
db.commit()
principal = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-claim",
open_id="ou-claim",
)
records = list(
db.execute(
select(MarketWatchlist).order_by(MarketWatchlist.id.asc())
).scalars()
)
assert records[0].owner_id == principal.owner_id
assert records[1].owner_id is None
def test_identity_rejects_missing_parts_and_invalid_admin_config(
session_factory: sessionmaker[Session],
) -> None:
assert parse_admin_identities("tenant-a:ou-a, tenant-b:ou-b") == {
("tenant-a", "ou-a"),
("tenant-b", "ou-b"),
}
with pytest.raises(ValueError):
parse_admin_identities("missing-separator")
with session_factory() as db:
with pytest.raises(HTTPException) as exc_info:
FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-a",
open_id=" ",
)
assert exc_info.value.status_code == 422
assert db.scalar(select(FeishuUser)) is None
def test_management_protects_last_active_admin_and_audits_denial(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
identity = FeishuIdentityService(
db,
admin_identities="tenant-a:ou-admin",
)
admin = identity.resolve_or_register(
tenant_key="tenant-a",
open_id="ou-admin",
)
user = identity.resolve_or_register(
tenant_key="tenant-a",
open_id="ou-user",
)
management = FeishuUserManagementService(db)
with pytest.raises(HTTPException) as demote_error:
management.update_user(
admin.user_code,
changes={"role": FeishuUserRole.USER},
actor="service-admin",
)
assert demote_error.value.status_code == 409
with pytest.raises(HTTPException) as disable_error:
management.update_user(
admin.user_code,
changes={"status": FeishuUserStatus.DISABLED},
actor="service-admin",
)
assert disable_error.value.status_code == 409
promoted = management.update_user(
user.user_code,
changes={"role": FeishuUserRole.ADMIN},
actor="service-admin",
)
assert promoted.role == FeishuUserRole.ADMIN
demoted = management.update_user(
admin.user_code,
changes={"role": FeishuUserRole.USER},
actor="service-admin",
)
assert demoted.role == FeishuUserRole.USER
denied = list(
db.execute(
select(AuditLog).where(
AuditLog.action == FeishuUserAuditAction.UPDATE_DENIED
)
).scalars()
)
assert len(denied) == 2
assert all(item.actor == "service-admin" for item in denied)
def test_management_validates_timezone_and_quiet_hours(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
user = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-a",
open_id="ou-user",
)
management = FeishuUserManagementService(db)
updated = management.update_user(
user.user_code,
changes={
"timezone": "Europe/London",
"quiet_hours_start": "22:30",
"quiet_hours_end": "07:15",
},
actor="service-admin",
)
assert updated.timezone == "Europe/London"
assert updated.quiet_hours_start.isoformat() == "22:30:00"
assert updated.quiet_hours_end.isoformat() == "07:15:00"
with pytest.raises(HTTPException) as timezone_error:
management.update_user(
user.user_code,
changes={"timezone": "Invalid/Nowhere"},
actor="service-admin",
)
assert timezone_error.value.status_code == 422
with pytest.raises(HTTPException) as quiet_error:
management.update_user(
user.user_code,
changes={"quiet_hours_end": None},
actor="service-admin",
)
assert quiet_error.value.status_code == 422
cleared = management.update_user(
user.user_code,
changes={
"quiet_hours_start": None,
"quiet_hours_end": None,
},
actor="service-admin",
)
assert cleared.quiet_hours_start is None
assert cleared.quiet_hours_end is None
def test_disabled_principal_has_no_capabilities(
session_factory: sessionmaker[Session],
) -> None:
with session_factory() as db:
principal = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-a",
open_id="ou-user",
)
FeishuUserManagementService(db).update_user(
principal.user_code,
changes={"status": FeishuUserStatus.DISABLED},
actor="service-admin",
)
refreshed = FeishuIdentityService(db).resolve_or_register(
tenant_key="tenant-a",
open_id="ou-user",
)
assert refreshed.is_active is False
assert refreshed.has_capability(FeishuCapability.PERSONAL_AI) is False
with pytest.raises(HTTPException) as exc_info:
refreshed.require_capability(FeishuCapability.PERSONAL_AI)
assert exc_info.value.status_code == 403
def test_internal_user_routes_use_api_principal_and_ignore_body_identity(
session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("API_KEY", "service-key")
monkeypatch.setenv("API_ACTOR", "service-admin")
monkeypatch.setenv("API_KEYS", "[]")
get_settings.cache_clear()
with session_factory() as db:
identity = FeishuIdentityService(
db,
admin_identities="tenant-a:ou-admin",
)
identity.resolve_or_register(
tenant_key="tenant-a",
open_id="ou-admin",
)
user = identity.resolve_or_register(
tenant_key="tenant-a",
open_id="ou-user",
)
user_code = user.user_code
app = FastAPI()
app.include_router(router, prefix="/api/v1/integrations/feishu")
def override_db() -> Iterator[Session]:
with session_factory() as db:
yield db
app.dependency_overrides[get_db] = override_db
client = TestClient(app)
try:
assert (
client.get("/api/v1/integrations/feishu/users").status_code
== 401
)
response = client.patch(
f"/api/v1/integrations/feishu/users/{user_code}",
headers={"X-API-Key": "service-key"},
json={
"role": "admin",
"actor": "forged-user",
"open_id": "ou-forged",
},
)
assert response.status_code == 200
assert response.json()["role"] == "admin"
assert response.json()["open_id"] == "ou-user"
listed = client.get(
"/api/v1/integrations/feishu/users?role=admin",
headers={"X-API-Key": "service-key"},
)
assert listed.status_code == 200
assert listed.json()["total"] == 2
detail = client.get(
f"/api/v1/integrations/feishu/users/{user_code}",
headers={"X-API-Key": "service-key"},
)
assert detail.status_code == 200
with session_factory() as db:
update_log = db.execute(
select(AuditLog)
.where(AuditLog.action == FeishuUserAuditAction.UPDATE)
.order_by(AuditLog.id.desc())
).scalars().first()
assert update_log is not None
assert update_log.actor == "service-admin"
assert "forged-user" not in (update_log.request_payload or "")
assert "ou-forged" not in (update_log.request_payload or "")
finally:
get_settings.cache_clear()