ForcomeBot 2.2.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
src/auth/routes.py ADDED
@@ -0,0 +1,385 @@
1
+ """认证相关API路由"""
2
+ import logging
3
+ from datetime import datetime
4
+ from typing import Optional, List
5
+
6
+ from fastapi import APIRouter, HTTPException, Depends, Request, Query
7
+ from pydantic import BaseModel
8
+ from sqlalchemy import select, desc, func
9
+
10
+ from .database import get_db
11
+ from .models import User, OperationLog
12
+ from .dingtalk import get_dingtalk_client
13
+ from .middleware import (
14
+ get_current_user,
15
+ get_optional_user,
16
+ get_jwt_handler,
17
+ is_auth_enabled,
18
+ log_operation
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # 创建路由器
24
+ router = APIRouter(prefix="/api/auth", tags=["auth"])
25
+
26
+
27
+ # ============ 请求/响应模型 ============
28
+
29
+ class DingTalkLoginRequest(BaseModel):
30
+ """钉钉登录请求"""
31
+ code: str # 授权码
32
+
33
+
34
+ class DingTalkH5LoginRequest(BaseModel):
35
+ """钉钉H5免登请求"""
36
+ code: str # 免登授权码
37
+
38
+
39
+ class LoginResponse(BaseModel):
40
+ """登录响应"""
41
+ token: str
42
+ user: dict
43
+
44
+
45
+ class UserResponse(BaseModel):
46
+ """用户信息响应"""
47
+ user: dict
48
+
49
+
50
+ class OperationLogQuery(BaseModel):
51
+ """操作日志查询参数"""
52
+ user_id: Optional[int] = None
53
+ action: Optional[str] = None
54
+ resource: Optional[str] = None
55
+ start_time: Optional[str] = None
56
+ end_time: Optional[str] = None
57
+ page: int = 1
58
+ page_size: int = 20
59
+
60
+
61
+ # ============ 钉钉登录API ============
62
+
63
+ @router.post("/dingtalk/login", response_model=LoginResponse)
64
+ async def dingtalk_login(data: DingTalkLoginRequest, request: Request):
65
+ """
66
+ 钉钉扫码登录
67
+
68
+ 通过钉钉授权码获取用户信息并登录
69
+ """
70
+ dingtalk_client = get_dingtalk_client()
71
+ if not dingtalk_client:
72
+ raise HTTPException(status_code=503, detail="钉钉服务未配置")
73
+
74
+ try:
75
+ # 通过授权码获取用户信息
76
+ user_info = await dingtalk_client.get_user_info_by_code(data.code)
77
+
78
+ # 查找或创建用户
79
+ user = await _get_or_create_user(user_info)
80
+
81
+ # 更新最后登录时间
82
+ await _update_last_login(user.id)
83
+
84
+ # 生成JWT Token
85
+ jwt_handler = get_jwt_handler()
86
+ token = jwt_handler.create_token(
87
+ user_id=user.id,
88
+ dingtalk_userid=user.dingtalk_userid,
89
+ name=user.name,
90
+ extra_data={"is_admin": user.is_admin}
91
+ )
92
+
93
+ # 记录登录日志
94
+ await log_operation(
95
+ user=user,
96
+ action="login",
97
+ resource="auth",
98
+ detail={"method": "dingtalk_qrcode"},
99
+ request=request
100
+ )
101
+
102
+ logger.info(f"用户登录成功: {user.name} ({user.dingtalk_userid})")
103
+
104
+ return LoginResponse(token=token, user=user.to_dict())
105
+
106
+ except Exception as e:
107
+ logger.error(f"钉钉登录失败: {e}")
108
+ raise HTTPException(status_code=401, detail=f"登录失败: {str(e)}")
109
+
110
+
111
+ @router.post("/dingtalk/h5-login", response_model=LoginResponse)
112
+ async def dingtalk_h5_login(data: DingTalkH5LoginRequest, request: Request):
113
+ """
114
+ 钉钉H5免登(工作台内嵌应用)
115
+
116
+ 通过钉钉JSAPI获取的免登授权码登录
117
+ """
118
+ dingtalk_client = get_dingtalk_client()
119
+ if not dingtalk_client:
120
+ raise HTTPException(status_code=503, detail="钉钉服务未配置")
121
+
122
+ try:
123
+ # 通过授权码获取用户信息
124
+ user_info = await dingtalk_client.get_user_info_by_code(data.code)
125
+
126
+ # 查找或创建用户
127
+ user = await _get_or_create_user(user_info)
128
+
129
+ # 更新最后登录时间
130
+ await _update_last_login(user.id)
131
+
132
+ # 生成JWT Token
133
+ jwt_handler = get_jwt_handler()
134
+ token = jwt_handler.create_token(
135
+ user_id=user.id,
136
+ dingtalk_userid=user.dingtalk_userid,
137
+ name=user.name,
138
+ extra_data={"is_admin": user.is_admin}
139
+ )
140
+
141
+ # 记录登录日志
142
+ await log_operation(
143
+ user=user,
144
+ action="login",
145
+ resource="auth",
146
+ detail={"method": "dingtalk_h5"},
147
+ request=request
148
+ )
149
+
150
+ logger.info(f"用户H5免登成功: {user.name} ({user.dingtalk_userid})")
151
+
152
+ return LoginResponse(token=token, user=user.to_dict())
153
+
154
+ except Exception as e:
155
+ logger.error(f"钉钉H5免登失败: {e}")
156
+ raise HTTPException(status_code=401, detail=f"登录失败: {str(e)}")
157
+
158
+
159
+ @router.get("/dingtalk/qrcode-url")
160
+ async def get_qrcode_url(redirect_uri: str, state: str = ""):
161
+ """
162
+ 获取钉钉扫码登录URL
163
+
164
+ Args:
165
+ redirect_uri: 回调地址
166
+ state: 状态参数
167
+ """
168
+ dingtalk_client = get_dingtalk_client()
169
+ if not dingtalk_client:
170
+ raise HTTPException(status_code=503, detail="钉钉服务未配置")
171
+
172
+ url = dingtalk_client.generate_qrcode_url(redirect_uri, state)
173
+ return {"url": url}
174
+
175
+
176
+ @router.get("/dingtalk/config")
177
+ async def get_dingtalk_config():
178
+ """
179
+ 获取钉钉配置信息(前端使用)
180
+
181
+ 返回AppKey和CorpId,用于前端初始化钉钉JSAPI
182
+ """
183
+ dingtalk_client = get_dingtalk_client()
184
+
185
+ if not dingtalk_client:
186
+ return {
187
+ "enabled": False,
188
+ "app_key": "",
189
+ "corp_id": "",
190
+ "agent_id": ""
191
+ }
192
+
193
+ return {
194
+ "enabled": True,
195
+ "app_key": dingtalk_client.app_key,
196
+ "corp_id": dingtalk_client.corp_id,
197
+ "agent_id": dingtalk_client.agent_id
198
+ }
199
+
200
+
201
+ # ============ 用户信息API ============
202
+
203
+ @router.get("/user", response_model=UserResponse)
204
+ async def get_user_info(user: User = Depends(get_current_user)):
205
+ """获取当前登录用户信息"""
206
+ return UserResponse(user=user.to_dict())
207
+
208
+
209
+ @router.post("/logout")
210
+ async def logout(request: Request, user: User = Depends(get_current_user)):
211
+ """登出"""
212
+ # 记录登出日志
213
+ await log_operation(
214
+ user=user,
215
+ action="logout",
216
+ resource="auth",
217
+ request=request
218
+ )
219
+
220
+ return {"status": "ok", "message": "已登出"}
221
+
222
+
223
+ @router.get("/status")
224
+ async def get_auth_status(user: Optional[User] = Depends(get_optional_user)):
225
+ """
226
+ 获取认证状态
227
+
228
+ 返回当前认证是否启用,以及用户是否已登录
229
+ """
230
+ return {
231
+ "auth_enabled": is_auth_enabled(),
232
+ "logged_in": user is not None,
233
+ "user": user.to_dict() if user else None
234
+ }
235
+
236
+
237
+ # ============ 操作日志API ============
238
+
239
+ @router.get("/operation-logs")
240
+ async def get_operation_logs(
241
+ user_id: Optional[int] = Query(None, description="用户ID"),
242
+ action: Optional[str] = Query(None, description="操作类型"),
243
+ resource: Optional[str] = Query(None, description="操作资源"),
244
+ start_time: Optional[str] = Query(None, description="开始时间"),
245
+ end_time: Optional[str] = Query(None, description="结束时间"),
246
+ page: int = Query(1, ge=1, description="页码"),
247
+ page_size: int = Query(20, ge=1, le=100, description="每页数量"),
248
+ current_user: User = Depends(get_current_user)
249
+ ):
250
+ """
251
+ 查询操作日志
252
+
253
+ 支持按用户、操作类型、资源、时间范围筛选
254
+ """
255
+ db = get_db()
256
+ async with db.session_factory() as session:
257
+ # 构建查询
258
+ query = select(OperationLog)
259
+
260
+ # 应用筛选条件
261
+ if user_id:
262
+ query = query.where(OperationLog.user_id == user_id)
263
+ if action:
264
+ query = query.where(OperationLog.action == action)
265
+ if resource:
266
+ query = query.where(OperationLog.resource == resource)
267
+ if start_time:
268
+ try:
269
+ start_dt = datetime.fromisoformat(start_time)
270
+ query = query.where(OperationLog.created_at >= start_dt)
271
+ except ValueError:
272
+ pass
273
+ if end_time:
274
+ try:
275
+ end_dt = datetime.fromisoformat(end_time)
276
+ query = query.where(OperationLog.created_at <= end_dt)
277
+ except ValueError:
278
+ pass
279
+
280
+ # 获取总数
281
+ count_query = select(func.count()).select_from(query.subquery())
282
+ total_result = await session.execute(count_query)
283
+ total = total_result.scalar() or 0
284
+
285
+ # 分页和排序
286
+ query = query.order_by(desc(OperationLog.created_at))
287
+ query = query.offset((page - 1) * page_size).limit(page_size)
288
+
289
+ # 执行查询
290
+ result = await session.execute(query)
291
+ logs = result.scalars().all()
292
+
293
+ return {
294
+ "logs": [log.to_dict() for log in logs],
295
+ "total": total,
296
+ "page": page,
297
+ "page_size": page_size,
298
+ "total_pages": (total + page_size - 1) // page_size
299
+ }
300
+
301
+
302
+ @router.get("/operation-logs/actions")
303
+ async def get_operation_actions(current_user: User = Depends(get_current_user)):
304
+ """获取所有操作类型(用于筛选)"""
305
+ db = get_db()
306
+ async with db.session_factory() as session:
307
+ query = select(OperationLog.action).distinct()
308
+ result = await session.execute(query)
309
+ actions = [row[0] for row in result.fetchall()]
310
+
311
+ return {"actions": actions}
312
+
313
+
314
+ @router.get("/operation-logs/resources")
315
+ async def get_operation_resources(current_user: User = Depends(get_current_user)):
316
+ """获取所有操作资源(用于筛选)"""
317
+ db = get_db()
318
+ async with db.session_factory() as session:
319
+ query = select(OperationLog.resource).distinct()
320
+ result = await session.execute(query)
321
+ resources = [row[0] for row in result.fetchall()]
322
+
323
+ return {"resources": resources}
324
+
325
+
326
+ # ============ 辅助函数 ============
327
+
328
+ async def _get_or_create_user(user_info: dict) -> User:
329
+ """查找或创建用户"""
330
+ db = get_db()
331
+ async with db.session_factory() as session:
332
+ # 查找现有用户
333
+ result = await session.execute(
334
+ select(User).where(User.dingtalk_userid == user_info["userid"])
335
+ )
336
+ user = result.scalar_one_or_none()
337
+
338
+ if user:
339
+ # 更新用户信息
340
+ user.name = user_info.get("name", user.name)
341
+ user.avatar = user_info.get("avatar", user.avatar)
342
+ user.mobile = user_info.get("mobile", user.mobile)
343
+ user.email = user_info.get("email", user.email)
344
+ user.department = user_info.get("department", user.department)
345
+ user.title = user_info.get("title", user.title)
346
+ user.dingtalk_unionid = user_info.get("unionid", user.dingtalk_unionid)
347
+ user.updated_at = datetime.now()
348
+ await session.commit()
349
+ await session.refresh(user)
350
+ else:
351
+ # 创建新用户
352
+ user = User(
353
+ dingtalk_userid=user_info["userid"],
354
+ dingtalk_unionid=user_info.get("unionid"),
355
+ name=user_info.get("name", ""),
356
+ avatar=user_info.get("avatar"),
357
+ mobile=user_info.get("mobile"),
358
+ email=user_info.get("email"),
359
+ department=user_info.get("department"),
360
+ title=user_info.get("title"),
361
+ is_active=True,
362
+ is_admin=False, # 默认非管理员
363
+ created_at=datetime.now(),
364
+ updated_at=datetime.now()
365
+ )
366
+ session.add(user)
367
+ await session.commit()
368
+ await session.refresh(user)
369
+
370
+ logger.info(f"创建新用户: {user.name} ({user.dingtalk_userid})")
371
+
372
+ return user
373
+
374
+
375
+ async def _update_last_login(user_id: int):
376
+ """更新用户最后登录时间"""
377
+ db = get_db()
378
+ async with db.session_factory() as session:
379
+ result = await session.execute(
380
+ select(User).where(User.id == user_id)
381
+ )
382
+ user = result.scalar_one_or_none()
383
+ if user:
384
+ user.last_login = datetime.now()
385
+ await session.commit()
@@ -0,0 +1,7 @@
1
+ # Client layer
2
+ """Client implementations for QianXun and LangBot connections."""
3
+
4
+ from .qianxun import QianXunClient
5
+ from .langbot import LangBotClient
6
+
7
+ __all__ = ["QianXunClient", "LangBotClient"]