bid-master-cli 1.0.0__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.
- app/__init__.py +1 -0
- app/api/__init__.py +1 -0
- app/api/api_keys.py +60 -0
- app/api/auth.py +258 -0
- app/api/cli_auth.py +165 -0
- app/api/database.py +286 -0
- app/api/extract.py +158 -0
- app/api/files.py +163 -0
- app/api/health.py +62 -0
- app/api/logs.py +26 -0
- app/api/settings.py +101 -0
- app/api/simulate.py +195 -0
- app/api/statistics.py +1214 -0
- app/cli.py +894 -0
- app/config.py +93 -0
- app/dependencies.py +12 -0
- app/infrastructure/__init__.py +1 -0
- app/infrastructure/database.py +126 -0
- app/infrastructure/db_schema.py +245 -0
- app/infrastructure/email_service.py +92 -0
- app/infrastructure/llm/__init__.py +1 -0
- app/infrastructure/llm/lite_llm.py +463 -0
- app/infrastructure/log_collector.py +64 -0
- app/infrastructure/mock_storage.py +563 -0
- app/infrastructure/pg_storage.py +656 -0
- app/infrastructure/storage.py +117 -0
- app/limiter.py +7 -0
- app/main.py +141 -0
- app/models/__init__.py +1 -0
- app/models/schemas.py +204 -0
- app/services/__init__.py +1 -0
- app/services/encryption_service.py +88 -0
- app/services/extract_service.py +817 -0
- app/services/file_service.py +112 -0
- app/services/llm_service.py +65 -0
- app/services/ocr_service.py +183 -0
- app/services/prompt_builder.py +257 -0
- app/services/simulate_service.py +625 -0
- app/services/statistics_service.py +123 -0
- app/utils/__init__.py +1 -0
- app/utils/auth_dep.py +42 -0
- app/utils/crypto.py +63 -0
- app/utils/exceptions.py +53 -0
- bid_master_cli-1.0.0.dist-info/METADATA +30 -0
- bid_master_cli-1.0.0.dist-info/RECORD +47 -0
- bid_master_cli-1.0.0.dist-info/WHEEL +4 -0
- bid_master_cli-1.0.0.dist-info/entry_points.txt +2 -0
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# bid-master-backend
|
app/api/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# API routers
|
app/api/api_keys.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""
|
|
2
|
+
API Key 管理路由 — 用户可存储自己的 LLM API Key,Fernet 加密存储。
|
|
3
|
+
"""
|
|
4
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
5
|
+
from app.models.schemas import ApiKeySaveRequest
|
|
6
|
+
from app.infrastructure.pg_storage import (
|
|
7
|
+
save_api_key,
|
|
8
|
+
get_api_key,
|
|
9
|
+
delete_api_key,
|
|
10
|
+
list_user_api_keys,
|
|
11
|
+
)
|
|
12
|
+
from app.services.encryption_service import get_encryption_service
|
|
13
|
+
from app.utils.auth_dep import get_current_user
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/api-keys", tags=["api-keys"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _mask_key(key: str) -> str:
|
|
19
|
+
"""遮蔽 API Key,只显示前 5 位和后 4 位。"""
|
|
20
|
+
if len(key) <= 10:
|
|
21
|
+
return key[:2] + "****" + key[-2:]
|
|
22
|
+
return key[:5] + "****" + key[-4:]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@router.get("")
|
|
26
|
+
async def list_keys(user: dict = Depends(get_current_user)):
|
|
27
|
+
"""列出当前用户已存储的 API Key(masked)。"""
|
|
28
|
+
records = await list_user_api_keys(user["id"])
|
|
29
|
+
enc_service = get_encryption_service()
|
|
30
|
+
keys = []
|
|
31
|
+
for r in records:
|
|
32
|
+
encrypted = await get_api_key(user["id"], r["provider"])
|
|
33
|
+
if encrypted:
|
|
34
|
+
try:
|
|
35
|
+
plaintext = enc_service.decrypt(encrypted.encode()).decode()
|
|
36
|
+
keys.append({"provider": r["provider"], "masked_key": _mask_key(plaintext)})
|
|
37
|
+
except Exception:
|
|
38
|
+
keys.append({"provider": r["provider"], "masked_key": None})
|
|
39
|
+
return {"success": True, "data": {"keys": keys}}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@router.post("")
|
|
43
|
+
async def save_key(request: ApiKeySaveRequest, user: dict = Depends(get_current_user)):
|
|
44
|
+
"""保存或更新一个 API Key。明文传入,Fernet 加密后存储。"""
|
|
45
|
+
api_key = request.api_key.strip()
|
|
46
|
+
if not api_key:
|
|
47
|
+
raise HTTPException(status_code=400, detail="API Key 不能为空")
|
|
48
|
+
enc_service = get_encryption_service()
|
|
49
|
+
encrypted = enc_service.encrypt(api_key.encode()).decode()
|
|
50
|
+
await save_api_key(user["id"], request.provider, encrypted)
|
|
51
|
+
return {"success": True, "message": f"API Key for {request.provider} 已保存"}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@router.delete("/{provider}")
|
|
55
|
+
async def remove_key(provider: str, user: dict = Depends(get_current_user)):
|
|
56
|
+
"""删除指定 provider 的 API Key。"""
|
|
57
|
+
deleted = await delete_api_key(user["id"], provider)
|
|
58
|
+
if not deleted:
|
|
59
|
+
raise HTTPException(status_code=404, detail="未找到该 provider 的 API Key")
|
|
60
|
+
return {"success": True, "message": f"API Key for {provider} 已删除"}
|
app/api/auth.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Authentication API routes: register, login, refresh, logout, me, send-code, forgot-password, reset-password.
|
|
3
|
+
"""
|
|
4
|
+
import os
|
|
5
|
+
import random
|
|
6
|
+
import uuid
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
|
|
9
|
+
from fastapi import APIRouter, HTTPException, Response, Request
|
|
10
|
+
from app.models.schemas import (
|
|
11
|
+
RegisterRequest, LoginRequest, RefreshRequest,
|
|
12
|
+
SendCodeRequest, ForgotPasswordRequest, ResetPasswordRequest,
|
|
13
|
+
)
|
|
14
|
+
from app.infrastructure.pg_storage import (
|
|
15
|
+
add_user, get_user_by_username, get_user_by_email, get_user_by_id,
|
|
16
|
+
save_verification_code, verify_code, delete_verification_code,
|
|
17
|
+
save_reset_token, get_reset_token, delete_reset_token, update_user_password,
|
|
18
|
+
)
|
|
19
|
+
from app.infrastructure.email_service import send_verification_code as email_send_code, send_reset_link
|
|
20
|
+
from app.utils.crypto import (
|
|
21
|
+
generate_salt, hash_password, verify_password,
|
|
22
|
+
create_access_token, create_refresh_token, decode_token,
|
|
23
|
+
)
|
|
24
|
+
from app.config import get_settings
|
|
25
|
+
|
|
26
|
+
router = APIRouter(prefix="/auth", tags=["auth"])
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
_IS_PRODUCTION = os.getenv("RAILWAY_ENVIRONMENT") or os.getenv("RENDER") or os.getenv("FLY_APP_NAME")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@router.post("/send-code")
|
|
33
|
+
async def send_code(request: SendCodeRequest):
|
|
34
|
+
"""发送邮箱验证码(注册用)。"""
|
|
35
|
+
existing = await get_user_by_email(request.email)
|
|
36
|
+
if existing:
|
|
37
|
+
raise HTTPException(status_code=400, detail="该邮箱已被注册")
|
|
38
|
+
|
|
39
|
+
code = f"{random.randint(0, 999999):06d}"
|
|
40
|
+
expires_at = datetime.now(timezone.utc) + timedelta(minutes=5)
|
|
41
|
+
await save_verification_code(request.email, code, expires_at)
|
|
42
|
+
|
|
43
|
+
success, err_msg = await email_send_code(request.email, code)
|
|
44
|
+
if not success:
|
|
45
|
+
raise HTTPException(status_code=500, detail=f"验证码发送失败: {err_msg}")
|
|
46
|
+
|
|
47
|
+
return {"success": True, "message": "验证码已发送"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@router.post("/register")
|
|
51
|
+
async def register(request: RegisterRequest, response: Response):
|
|
52
|
+
"""注册新用户(需要邮箱验证码)。"""
|
|
53
|
+
if request.password != request.confirm_password:
|
|
54
|
+
raise HTTPException(status_code=400, detail="两次输入的密码不一致")
|
|
55
|
+
|
|
56
|
+
if not await verify_code(request.email, request.code):
|
|
57
|
+
raise HTTPException(status_code=400, detail="验证码错误或已过期")
|
|
58
|
+
|
|
59
|
+
existing = await get_user_by_email(request.email)
|
|
60
|
+
if existing:
|
|
61
|
+
raise HTTPException(status_code=400, detail="该邮箱已被注册")
|
|
62
|
+
|
|
63
|
+
# 自动生成用户名(如果未提供)
|
|
64
|
+
username = request.username
|
|
65
|
+
if not username:
|
|
66
|
+
base = request.email.split("@")[0].replace(".", "_")
|
|
67
|
+
username = base
|
|
68
|
+
suffix = 1
|
|
69
|
+
while await get_user_by_username(username):
|
|
70
|
+
username = f"{base}{suffix}"
|
|
71
|
+
suffix += 1
|
|
72
|
+
|
|
73
|
+
# 检查用户名是否已被占用
|
|
74
|
+
if await get_user_by_username(username):
|
|
75
|
+
raise HTTPException(status_code=400, detail="用户名已被使用")
|
|
76
|
+
|
|
77
|
+
salt = generate_salt()
|
|
78
|
+
password_hash = hash_password(request.password, salt)
|
|
79
|
+
|
|
80
|
+
user = await add_user({
|
|
81
|
+
"username": username,
|
|
82
|
+
"email": request.email,
|
|
83
|
+
"password_hash": password_hash,
|
|
84
|
+
"salt": salt.hex(),
|
|
85
|
+
"role": "user",
|
|
86
|
+
"is_active": True,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
await delete_verification_code(request.email)
|
|
90
|
+
|
|
91
|
+
settings = get_settings()
|
|
92
|
+
access_token = create_access_token(
|
|
93
|
+
{"sub": user["id"], "username": user["username"], "email": user["email"], "role": user["role"]},
|
|
94
|
+
settings.jwt_secret,
|
|
95
|
+
settings.jwt_access_token_expire_minutes,
|
|
96
|
+
)
|
|
97
|
+
refresh_token = create_refresh_token(
|
|
98
|
+
{"sub": user["id"]},
|
|
99
|
+
settings.jwt_secret,
|
|
100
|
+
settings.jwt_refresh_token_expire_days,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# refresh_token 设为 httpOnly cookie(与登录一致)
|
|
104
|
+
response.set_cookie(
|
|
105
|
+
"refresh_token",
|
|
106
|
+
refresh_token,
|
|
107
|
+
max_age=settings.jwt_refresh_token_expire_days * 86400,
|
|
108
|
+
httponly=True,
|
|
109
|
+
secure=bool(_IS_PRODUCTION),
|
|
110
|
+
samesite="lax",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
"access_token": access_token,
|
|
115
|
+
"refresh_token": refresh_token,
|
|
116
|
+
"token_type": "bearer",
|
|
117
|
+
"user": {"id": user["id"], "username": user["username"], "email": user.get("email"), "role": user["role"]},
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@router.post("/login")
|
|
122
|
+
async def login(request: LoginRequest, response: Response):
|
|
123
|
+
"""邮箱登录。"""
|
|
124
|
+
user = await get_user_by_email(request.email)
|
|
125
|
+
if not user or not user.get("is_active"):
|
|
126
|
+
raise HTTPException(status_code=401, detail="邮箱或密码错误")
|
|
127
|
+
|
|
128
|
+
salt_bytes = bytes.fromhex(user["salt"])
|
|
129
|
+
if not verify_password(request.password, salt_bytes, user["password_hash"]):
|
|
130
|
+
raise HTTPException(status_code=401, detail="邮箱或密码错误")
|
|
131
|
+
|
|
132
|
+
settings = get_settings()
|
|
133
|
+
access_token = create_access_token(
|
|
134
|
+
{"sub": user["id"], "username": user["username"], "email": user.get("email"), "role": user["role"]},
|
|
135
|
+
settings.jwt_secret,
|
|
136
|
+
settings.jwt_access_token_expire_minutes,
|
|
137
|
+
)
|
|
138
|
+
refresh_token = create_refresh_token(
|
|
139
|
+
{"sub": user["id"]},
|
|
140
|
+
settings.jwt_secret,
|
|
141
|
+
settings.jwt_refresh_token_expire_days,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# refresh_token 设为 httpOnly cookie
|
|
145
|
+
response.set_cookie(
|
|
146
|
+
"refresh_token",
|
|
147
|
+
refresh_token,
|
|
148
|
+
max_age=settings.jwt_refresh_token_expire_days * 86400,
|
|
149
|
+
httponly=True,
|
|
150
|
+
secure=bool(_IS_PRODUCTION),
|
|
151
|
+
samesite="lax",
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
"access_token": access_token,
|
|
156
|
+
"refresh_token": refresh_token,
|
|
157
|
+
"token_type": "bearer",
|
|
158
|
+
"user": {"id": user["id"], "username": user["username"], "email": user.get("email"), "role": user["role"]},
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@router.post("/refresh")
|
|
163
|
+
async def refresh(request: Request):
|
|
164
|
+
"""用 httpOnly cookie 中的 refresh_token 获取新的 access_token。"""
|
|
165
|
+
import logging
|
|
166
|
+
_log = logging.getLogger(__name__)
|
|
167
|
+
|
|
168
|
+
refresh_token = request.cookies.get("refresh_token")
|
|
169
|
+
if not refresh_token:
|
|
170
|
+
_log.warning("auth refresh 失败: cookie 中缺少 refresh_token (cookies=%s)", list(request.cookies.keys()))
|
|
171
|
+
raise HTTPException(status_code=401, detail="缺少 refresh token")
|
|
172
|
+
|
|
173
|
+
payload = decode_token(refresh_token, get_settings().jwt_secret)
|
|
174
|
+
if not payload:
|
|
175
|
+
_log.warning("auth refresh 失败: refresh_token 解码失败(可能已过期)")
|
|
176
|
+
raise HTTPException(status_code=401, detail="无效或已过期的 refresh token")
|
|
177
|
+
if payload.get("type") != "refresh":
|
|
178
|
+
_log.warning("auth refresh 失败: token 类型错误 (type=%s)", payload.get("type"))
|
|
179
|
+
raise HTTPException(status_code=401, detail="无效的 refresh token")
|
|
180
|
+
|
|
181
|
+
user_id = payload.get("sub")
|
|
182
|
+
user = await get_user_by_id(user_id)
|
|
183
|
+
if not user or not user.get("is_active"):
|
|
184
|
+
_log.warning("auth refresh 失败: 用户不存在或已禁用 (sub=%s)", user_id)
|
|
185
|
+
raise HTTPException(status_code=401, detail="用户不存在或已禁用")
|
|
186
|
+
|
|
187
|
+
settings = get_settings()
|
|
188
|
+
access_token = create_access_token(
|
|
189
|
+
{"sub": user["id"], "username": user["username"], "email": user.get("email"), "role": user["role"]},
|
|
190
|
+
settings.jwt_secret,
|
|
191
|
+
settings.jwt_access_token_expire_minutes,
|
|
192
|
+
)
|
|
193
|
+
return {"access_token": access_token, "token_type": "bearer"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@router.post("/logout")
|
|
197
|
+
async def logout(response: Response):
|
|
198
|
+
"""退出登录,清除 refresh_token cookie。"""
|
|
199
|
+
response.delete_cookie("refresh_token", secure=bool(_IS_PRODUCTION))
|
|
200
|
+
return {"success": True, "message": "已退出登录"}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@router.get("/me")
|
|
204
|
+
async def me(request: Request):
|
|
205
|
+
"""获取当前用户信息。"""
|
|
206
|
+
auth_header = request.headers.get("Authorization", "")
|
|
207
|
+
if not auth_header.startswith("Bearer "):
|
|
208
|
+
raise HTTPException(status_code=401, detail="未认证")
|
|
209
|
+
|
|
210
|
+
token = auth_header[7:]
|
|
211
|
+
payload = decode_token(token, get_settings().jwt_secret)
|
|
212
|
+
if not payload or payload.get("type") != "access":
|
|
213
|
+
raise HTTPException(status_code=401, detail="无效或过期的 token")
|
|
214
|
+
|
|
215
|
+
user_id = payload.get("sub")
|
|
216
|
+
user = await get_user_by_id(user_id)
|
|
217
|
+
if not user:
|
|
218
|
+
raise HTTPException(status_code=401, detail="用户不存在")
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
"id": user["id"],
|
|
222
|
+
"username": user["username"],
|
|
223
|
+
"email": user.get("email"),
|
|
224
|
+
"role": user["role"],
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@router.post("/forgot-password")
|
|
229
|
+
async def forgot_password(request: ForgotPasswordRequest):
|
|
230
|
+
"""发送密码重置链接到邮箱。"""
|
|
231
|
+
user = await get_user_by_email(request.email)
|
|
232
|
+
if not user:
|
|
233
|
+
return {"success": True, "message": "如果该邮箱已注册,重置链接已发送"}
|
|
234
|
+
|
|
235
|
+
token = str(uuid.uuid4())
|
|
236
|
+
expires_at = datetime.now(timezone.utc) + timedelta(minutes=30)
|
|
237
|
+
await save_reset_token(token, user["id"], expires_at)
|
|
238
|
+
|
|
239
|
+
success, err_msg = await send_reset_link(request.email, token)
|
|
240
|
+
if not success:
|
|
241
|
+
raise HTTPException(status_code=500, detail=f"邮件发送失败: {err_msg}")
|
|
242
|
+
|
|
243
|
+
return {"success": True, "message": "如果该邮箱已注册,重置链接已发送"}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
@router.post("/reset-password")
|
|
247
|
+
async def reset_password(request: ResetPasswordRequest):
|
|
248
|
+
"""通过 token 重置密码。"""
|
|
249
|
+
record = await get_reset_token(request.token)
|
|
250
|
+
if not record:
|
|
251
|
+
raise HTTPException(status_code=400, detail="重置链接无效或已过期")
|
|
252
|
+
|
|
253
|
+
salt = generate_salt()
|
|
254
|
+
password_hash = hash_password(request.new_password, salt)
|
|
255
|
+
await update_user_password(record["user_id"], password_hash, salt.hex())
|
|
256
|
+
await delete_reset_token(request.token)
|
|
257
|
+
|
|
258
|
+
return {"success": True, "message": "密码重置成功,请使用新密码登录"}
|
app/api/cli_auth.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import secrets
|
|
4
|
+
from datetime import datetime, timedelta, timezone
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, HTTPException
|
|
7
|
+
from pydantic import BaseModel
|
|
8
|
+
|
|
9
|
+
from app.config import get_settings
|
|
10
|
+
from app.infrastructure.database import get_database
|
|
11
|
+
from app.infrastructure.pg_storage import get_user_by_id
|
|
12
|
+
from app.utils.auth_dep import get_current_user
|
|
13
|
+
from app.utils.crypto import create_access_token
|
|
14
|
+
|
|
15
|
+
router = APIRouter(prefix="/cli-auth", tags=["cli-auth"])
|
|
16
|
+
|
|
17
|
+
DEVICE_CODE_EXPIRE_MINUTES = 10
|
|
18
|
+
CLI_TOKEN_EXPIRE_DAYS = 30
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CliDeviceStartResponse(BaseModel):
|
|
22
|
+
device_code: str
|
|
23
|
+
user_code: str
|
|
24
|
+
verification_uri: str
|
|
25
|
+
expires_in: int
|
|
26
|
+
interval: int
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CliAuthorizeRequest(BaseModel):
|
|
30
|
+
device_code: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CliPollRequest(BaseModel):
|
|
34
|
+
device_code: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CliTokenResponse(BaseModel):
|
|
38
|
+
access_token: str
|
|
39
|
+
token_type: str = "bearer"
|
|
40
|
+
expires_in: int
|
|
41
|
+
user: dict
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _now() -> datetime:
|
|
45
|
+
return datetime.now(timezone.utc)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _new_device_code() -> str:
|
|
49
|
+
return secrets.token_urlsafe(32)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _new_user_code() -> str:
|
|
53
|
+
raw = secrets.token_hex(4).upper()
|
|
54
|
+
return f"{raw[:4]}-{raw[4:]}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@router.post("/start", response_model=CliDeviceStartResponse)
|
|
58
|
+
async def start_cli_auth():
|
|
59
|
+
device_code = _new_device_code()
|
|
60
|
+
user_code = _new_user_code()
|
|
61
|
+
expires_at = _now() + timedelta(minutes=DEVICE_CODE_EXPIRE_MINUTES)
|
|
62
|
+
|
|
63
|
+
db = await get_database()
|
|
64
|
+
await db.execute(
|
|
65
|
+
"""INSERT INTO cli_device_codes (device_code, user_code, status, expires_at, created_at)
|
|
66
|
+
VALUES ($1, $2, 'pending', $3, $4)""",
|
|
67
|
+
device_code,
|
|
68
|
+
user_code,
|
|
69
|
+
expires_at,
|
|
70
|
+
_now(),
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
frontend_url = get_settings().frontend_url.rstrip("/")
|
|
74
|
+
return CliDeviceStartResponse(
|
|
75
|
+
device_code=device_code,
|
|
76
|
+
user_code=user_code,
|
|
77
|
+
verification_uri=f"{frontend_url}/cli-auth?device_code={device_code}",
|
|
78
|
+
expires_in=DEVICE_CODE_EXPIRE_MINUTES * 60,
|
|
79
|
+
interval=2,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@router.post("/authorize")
|
|
84
|
+
async def authorize_cli_device(
|
|
85
|
+
request: CliAuthorizeRequest,
|
|
86
|
+
current_user: dict = Depends(get_current_user),
|
|
87
|
+
):
|
|
88
|
+
db = await get_database()
|
|
89
|
+
record = await db.fetch_one(
|
|
90
|
+
"SELECT * FROM cli_device_codes WHERE device_code = $1",
|
|
91
|
+
request.device_code,
|
|
92
|
+
)
|
|
93
|
+
if not record:
|
|
94
|
+
raise HTTPException(status_code=404, detail="授权请求不存在")
|
|
95
|
+
if record["status"] != "pending":
|
|
96
|
+
raise HTTPException(status_code=400, detail="授权请求已处理")
|
|
97
|
+
if record["expires_at"] < _now():
|
|
98
|
+
await db.execute(
|
|
99
|
+
"UPDATE cli_device_codes SET status = 'expired' WHERE device_code = $1",
|
|
100
|
+
request.device_code,
|
|
101
|
+
)
|
|
102
|
+
raise HTTPException(status_code=400, detail="授权请求已过期")
|
|
103
|
+
|
|
104
|
+
await db.execute(
|
|
105
|
+
"""UPDATE cli_device_codes
|
|
106
|
+
SET status = 'authorized', user_id = $1, authorized_at = $2
|
|
107
|
+
WHERE device_code = $3""",
|
|
108
|
+
current_user["id"],
|
|
109
|
+
_now(),
|
|
110
|
+
request.device_code,
|
|
111
|
+
)
|
|
112
|
+
return {"success": True}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@router.post("/poll")
|
|
116
|
+
async def poll_cli_auth(request: CliPollRequest):
|
|
117
|
+
db = await get_database()
|
|
118
|
+
record = await db.fetch_one(
|
|
119
|
+
"SELECT * FROM cli_device_codes WHERE device_code = $1",
|
|
120
|
+
request.device_code,
|
|
121
|
+
)
|
|
122
|
+
if not record:
|
|
123
|
+
raise HTTPException(status_code=404, detail="授权请求不存在")
|
|
124
|
+
if record["expires_at"] < _now() and record["status"] == "pending":
|
|
125
|
+
await db.execute(
|
|
126
|
+
"UPDATE cli_device_codes SET status = 'expired' WHERE device_code = $1",
|
|
127
|
+
request.device_code,
|
|
128
|
+
)
|
|
129
|
+
return {"status": "expired"}
|
|
130
|
+
if record["status"] == "pending":
|
|
131
|
+
return {"status": "pending"}
|
|
132
|
+
if record["status"] != "authorized" or not record.get("user_id"):
|
|
133
|
+
return {"status": record["status"]}
|
|
134
|
+
|
|
135
|
+
user = await get_user_by_id(record["user_id"])
|
|
136
|
+
if not user or not user.get("is_active"):
|
|
137
|
+
raise HTTPException(status_code=401, detail="用户不存在或已禁用")
|
|
138
|
+
|
|
139
|
+
settings = get_settings()
|
|
140
|
+
access_token = create_access_token(
|
|
141
|
+
{
|
|
142
|
+
"sub": user["id"],
|
|
143
|
+
"username": user["username"],
|
|
144
|
+
"email": user.get("email"),
|
|
145
|
+
"role": user["role"],
|
|
146
|
+
"scope": "cli",
|
|
147
|
+
},
|
|
148
|
+
settings.jwt_secret,
|
|
149
|
+
CLI_TOKEN_EXPIRE_DAYS * 24 * 60,
|
|
150
|
+
)
|
|
151
|
+
await db.execute(
|
|
152
|
+
"UPDATE cli_device_codes SET status = 'consumed' WHERE device_code = $1",
|
|
153
|
+
request.device_code,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return CliTokenResponse(
|
|
157
|
+
access_token=access_token,
|
|
158
|
+
expires_in=CLI_TOKEN_EXPIRE_DAYS * 86400,
|
|
159
|
+
user={"id": user["id"], "username": user["username"], "email": user.get("email"), "role": user["role"]},
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@router.get("/me")
|
|
164
|
+
async def cli_me(current_user: dict = Depends(get_current_user)):
|
|
165
|
+
return {"user": current_user}
|