merge main
This commit is contained in:
Vendored
+13
-13
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-CtDh7BHk.js"></script>
|
<script type="module" crossorigin src="/assets/index-DSXie0ty.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-D7ShJUt4.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import AdminAuthoriz from './pages/AdminAuthoriz';
|
|||||||
import AdminConsume from './pages/AdminConsume';
|
import AdminConsume from './pages/AdminConsume';
|
||||||
import AdminLoginPage from './pages/AdminLoginPage';
|
import AdminLoginPage from './pages/AdminLoginPage';
|
||||||
import AdminDashboard from './pages/AdminDashboard';
|
import AdminDashboard from './pages/AdminDashboard';
|
||||||
import AdminPlatform from './pages/Adminplatform';
|
import AdminPlatform from './pages/AdminPlatform';
|
||||||
import AdminUsers from './pages/AdminUsers';
|
import AdminUsers from './pages/AdminUsers';
|
||||||
import AdminModels from './pages/AdminModels';
|
import AdminModels from './pages/AdminModels';
|
||||||
import AdminSettings from './pages/AdminSettings';
|
import AdminSettings from './pages/AdminSettings';
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
|
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { encrypt, decrypt } from './crypto';
|
import { encrypt, decrypt, isCryptoAvailable } from './crypto';
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||||
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY;
|
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
|
||||||
|
|
||||||
interface RequestOptions {
|
interface RequestOptions {
|
||||||
method?: string;
|
method?: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* AES-256-GCM encryption/decryption for API request/response.
|
* AES-256-GCM encryption/decryption for API request/response.
|
||||||
* Uses Web Crypto API with a shared symmetric key.
|
* Uses Web Crypto API with a shared symmetric key.
|
||||||
|
* Note: Web Crypto API is only available in secure contexts (HTTPS or localhost).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ALGO = 'AES-GCM';
|
const ALGO = 'AES-GCM';
|
||||||
@@ -9,10 +10,21 @@ const TAG_LENGTH = 128;
|
|||||||
|
|
||||||
let cryptoKey: CryptoKey | null = null;
|
let cryptoKey: CryptoKey | null = null;
|
||||||
|
|
||||||
|
export function isCryptoAvailable(): boolean {
|
||||||
|
return typeof window !== 'undefined' &&
|
||||||
|
typeof crypto !== 'undefined' &&
|
||||||
|
typeof crypto.subtle !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
async function getCryptoKey(): Promise<CryptoKey> {
|
async function getCryptoKey(): Promise<CryptoKey> {
|
||||||
if (cryptoKey) return cryptoKey;
|
if (cryptoKey) return cryptoKey;
|
||||||
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
|
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
|
||||||
if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured');
|
if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured');
|
||||||
|
|
||||||
|
if (!isCryptoAvailable()) {
|
||||||
|
throw new Error('Web Crypto API not available (requires HTTPS or localhost)');
|
||||||
|
}
|
||||||
|
|
||||||
let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
|
let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
|
||||||
// AES-256 requires exactly 32 bytes — pad or truncate to match backend
|
// AES-256 requires exactly 32 bytes — pad or truncate to match backend
|
||||||
if (keyBytes.length !== 32) {
|
if (keyBytes.length !== 32) {
|
||||||
@@ -25,6 +37,9 @@ async function getCryptoKey(): Promise<CryptoKey> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function encrypt(plaintext: string): Promise<string> {
|
export async function encrypt(plaintext: string): Promise<string> {
|
||||||
|
if (!isCryptoAvailable()) {
|
||||||
|
throw new Error('Encryption not available in non-secure context');
|
||||||
|
}
|
||||||
const key = await getCryptoKey();
|
const key = await getCryptoKey();
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||||
const encoded = new TextEncoder().encode(plaintext);
|
const encoded = new TextEncoder().encode(plaintext);
|
||||||
@@ -38,6 +53,9 @@ export async function encrypt(plaintext: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function decrypt(cipherB64: string): Promise<string> {
|
export async function decrypt(cipherB64: string): Promise<string> {
|
||||||
|
if (!isCryptoAvailable()) {
|
||||||
|
throw new Error('Decryption not available in non-secure context');
|
||||||
|
}
|
||||||
const key = await getCryptoKey();
|
const key = await getCryptoKey();
|
||||||
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
|
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
|
||||||
const iv = combined.slice(0, IV_LENGTH);
|
const iv = combined.slice(0, IV_LENGTH);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from app.api.v1.recent_generation import router as recent_generation_router
|
|||||||
from app.api.v1.test import router as test_router
|
from app.api.v1.test import router as test_router
|
||||||
from app.api.v1.user_oauth import router as user_oauth_router
|
from app.api.v1.user_oauth import router as user_oauth_router
|
||||||
from app.api.v1.user_oauth_app import router as user_oauth_app_router
|
from app.api.v1.user_oauth_app import router as user_oauth_app_router
|
||||||
|
from app.api.v1.user_oauth_account import router as user_oauth_account_router
|
||||||
from app.api.v1.upload_material import router as upload_material_router
|
from app.api.v1.upload_material import router as upload_material_router
|
||||||
from app.api.v1.pre_test_template import router as pre_test_template_router
|
from app.api.v1.pre_test_template import router as pre_test_template_router
|
||||||
from app.api.v1.material_consumption import router as material_consumption_router
|
from app.api.v1.material_consumption import router as material_consumption_router
|
||||||
@@ -51,6 +52,7 @@ api_router.include_router(recent_generation_router)
|
|||||||
api_router.include_router(test_router)
|
api_router.include_router(test_router)
|
||||||
api_router.include_router(user_oauth_router)
|
api_router.include_router(user_oauth_router)
|
||||||
api_router.include_router(user_oauth_app_router)
|
api_router.include_router(user_oauth_app_router)
|
||||||
|
api_router.include_router(user_oauth_account_router)
|
||||||
api_router.include_router(upload_material_router)
|
api_router.include_router(upload_material_router)
|
||||||
api_router.include_router(pre_test_template_router)
|
api_router.include_router(pre_test_template_router)
|
||||||
api_router.include_router(material_consumption_router)
|
api_router.include_router(material_consumption_router)
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.dependencies import get_current_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user_oauth_account import (
|
||||||
|
OAuthAccountListResponse,
|
||||||
|
DeleteOAuthAccountRequest,
|
||||||
|
)
|
||||||
|
from app.services.user_oauth_account_service import (
|
||||||
|
get_oauth_account_list,
|
||||||
|
delete_oauth_account,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/oauth-account", tags=["oauth-account"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/list",
|
||||||
|
summary="获取授权账户列表",
|
||||||
|
description="获取授权账户列表,支持按广告主ID、授权ID、广告账户名称筛选",
|
||||||
|
)
|
||||||
|
async def oauth_account_list(
|
||||||
|
advertiser_id: str | None = Query(None, description="广告主账户ID"),
|
||||||
|
oauth_id: str | None = Query(None, description="授权ID"),
|
||||||
|
advertiser_name: str | None = Query(None, description="广告账户名称(模糊查询)"),
|
||||||
|
page: int = Query(1, ge=1, description="页码"),
|
||||||
|
page_size: int = Query(10, ge=1, le=100, description="每页数量"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
获取授权账户列表
|
||||||
|
|
||||||
|
- **advertiser_id**: 广告主账户ID(可选)
|
||||||
|
- **oauth_id**: 授权ID(可选)
|
||||||
|
- **advertiser_name**: 广告账户名称,支持模糊查询(可选)
|
||||||
|
- **page**: 页码,默认为1
|
||||||
|
- **page_size**: 每页数量,默认为10,最大100
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
result = await get_oauth_account_list(
|
||||||
|
db=db,
|
||||||
|
page=page,
|
||||||
|
page_size=page_size,
|
||||||
|
advertiser_id=advertiser_id,
|
||||||
|
oauth_id=oauth_id,
|
||||||
|
advertiser_name=advertiser_name,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "查询成功",
|
||||||
|
"data": result["data"],
|
||||||
|
"pagination": {
|
||||||
|
"page": result["page"],
|
||||||
|
"page_size": result["page_size"],
|
||||||
|
"total": result["total"],
|
||||||
|
"total_pages": result["total_pages"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/delete",
|
||||||
|
summary="删除授权账户",
|
||||||
|
description="软删除授权账户",
|
||||||
|
)
|
||||||
|
async def delete_oauth_account_api(
|
||||||
|
id: str = Query(..., description="授权账户表id"),
|
||||||
|
current_user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
try:
|
||||||
|
await delete_oauth_account(
|
||||||
|
db=db,
|
||||||
|
account_id=id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"message": "删除成功",
|
||||||
|
}
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=str(e),
|
||||||
|
)
|
||||||
@@ -8,3 +8,5 @@ from app.enums.credit_record import *
|
|||||||
from app.enums.token_usage import *
|
from app.enums.token_usage import *
|
||||||
from app.enums.generation_task import *
|
from app.enums.generation_task import *
|
||||||
from app.enums.recent_generation import *
|
from app.enums.recent_generation import *
|
||||||
|
from app.enums.generation_status import *
|
||||||
|
from app.enums.sms import *
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationMode(str, Enum):
|
||||||
|
"""生成模式。"""
|
||||||
|
STANDARD = "standard"
|
||||||
|
FAST = "fast"
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationType(str, Enum):
|
||||||
|
"""生成类型。"""
|
||||||
|
video = "video"
|
||||||
|
image = "image"
|
||||||
|
|
||||||
|
|
||||||
|
class ChatGenerationTaskStatus(str, Enum):
|
||||||
|
"""聊天生成任务状态。"""
|
||||||
|
pending = "pending"
|
||||||
|
processing = "processing"
|
||||||
|
completed = "completed"
|
||||||
|
failed = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class ChatGenerationPipelineStage(str, Enum):
|
||||||
|
"""聊天生成任务阶段。"""
|
||||||
|
waiting = "waiting"
|
||||||
|
prompt_optimization = "prompt_optimization"
|
||||||
|
video_generation = "video_generation"
|
||||||
|
post_processing = "post_processing"
|
||||||
|
completed = "completed"
|
||||||
|
failed = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class ChatGenerationTaskEventType(str, Enum):
|
||||||
|
"""聊天生成任务事件类型。"""
|
||||||
|
TASK_CREATED = "TASK_CREATED"
|
||||||
|
TASK_DELETED = "TASK_DELETED"
|
||||||
|
TASK_CANCELLED = "TASK_CANCELLED"
|
||||||
|
PROMPT_OPT_STARTED = "PROMPT_OPT_STARTED"
|
||||||
|
PROMPT_OPT_COMPLETED = "PROMPT_OPT_COMPLETED"
|
||||||
|
PROMPT_OPT_FAILED = "PROMPT_OPT_FAILED"
|
||||||
|
VIDEO_GEN_STARTED = "VIDEO_GEN_STARTED"
|
||||||
|
VIDEO_GEN_COMPLETED = "VIDEO_GEN_COMPLETED"
|
||||||
|
VIDEO_GEN_FAILED = "VIDEO_GEN_FAILED"
|
||||||
|
POST_PROCESSING_STARTED = "POST_PROCESSING_STARTED"
|
||||||
|
POST_PROCESSING_COMPLETED = "POST_PROCESSING_COMPLETED"
|
||||||
|
POST_PROCESSING_FAILED = "POST_PROCESSING_FAILED"
|
||||||
|
TASK_COMPLETED = "TASK_COMPLETED"
|
||||||
|
TASK_FAILED = "TASK_FAILED"
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationStatus(str, Enum):
|
||||||
|
"""生成状态。"""
|
||||||
|
prompt_optimized = "prompt_optimized"
|
||||||
|
generating = "generating"
|
||||||
|
completed = "completed"
|
||||||
|
failed = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class GenerationType(str, Enum):
|
||||||
|
"""生成类型。"""
|
||||||
|
video = "video"
|
||||||
|
image = "image"
|
||||||
|
|
||||||
|
|
||||||
|
# 生成配置常量
|
||||||
|
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
||||||
|
ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
|
||||||
|
RESOLUTIONS = ["480p", "720p", "1080p"]
|
||||||
|
IMAGE_SIZES = ["2K", "4K"]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class SmsScene(str, Enum):
|
||||||
|
"""短信场景。"""
|
||||||
|
register = "register"
|
||||||
|
login = "login"
|
||||||
|
common = "common"
|
||||||
|
set_password = "set_password"
|
||||||
@@ -1,29 +1,17 @@
|
|||||||
from enum import Enum
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.enums.generation_status import (
|
||||||
|
GenerationStatus,
|
||||||
|
GenerationType,
|
||||||
|
DURATIONS,
|
||||||
|
ASPECT_RATIOS,
|
||||||
|
RESOLUTIONS,
|
||||||
|
IMAGE_SIZES,
|
||||||
|
)
|
||||||
from app.schemas.common import NaiveDatetime, NaiveDatetimeOptional
|
from app.schemas.common import NaiveDatetime, NaiveDatetimeOptional
|
||||||
from app.services.operation_log import log_operation
|
from app.services.operation_log import log_operation
|
||||||
|
|
||||||
|
|
||||||
class GenerationStatus(str, Enum):
|
|
||||||
prompt_optimized = "prompt_optimized"
|
|
||||||
generating = "generating"
|
|
||||||
completed = "completed"
|
|
||||||
failed = "failed"
|
|
||||||
|
|
||||||
|
|
||||||
class GenerationType(str, Enum):
|
|
||||||
video = "video"
|
|
||||||
image = "image"
|
|
||||||
|
|
||||||
|
|
||||||
DURATIONS = [4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
|
|
||||||
ASPECT_RATIOS = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"]
|
|
||||||
RESOLUTIONS = ["480p", "720p", "1080p"]
|
|
||||||
IMAGE_SIZES = ["2K", "4K"]
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizeParams(BaseModel):
|
class OptimizeParams(BaseModel):
|
||||||
project_id: str
|
project_id: str
|
||||||
prompt: str = Field(..., max_length=500)
|
prompt: str = Field(..., max_length=500)
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
from enum import Enum
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from app.enums.sms import SmsScene
|
||||||
class SmsScene(str, Enum):
|
|
||||||
register = "register"
|
|
||||||
login = "login"
|
|
||||||
common = "common"
|
|
||||||
set_password = "set_password"
|
|
||||||
|
|
||||||
|
|
||||||
class SmsSendRequest(BaseModel):
|
class SmsSendRequest(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class UserOAuthAccountOut(BaseModel):
|
||||||
|
id: str = Field(..., description="主键")
|
||||||
|
oauth_id: str = Field(..., description="授权表中的id")
|
||||||
|
advertiser_id: Optional[str] = Field(None, description="广告主账户id")
|
||||||
|
advertiser_name: Optional[str] = Field(None, description="广告账户名")
|
||||||
|
advertiser_role: Optional[str] = Field(None, description="广告账户类型")
|
||||||
|
created_at: datetime = Field(..., description="创建时间")
|
||||||
|
updated_at: datetime = Field(..., description="更新时间")
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationInfo(BaseModel):
|
||||||
|
page: int = Field(..., description="当前页码")
|
||||||
|
page_size: int = Field(..., description="每页数量")
|
||||||
|
total: int = Field(..., description="总记录数")
|
||||||
|
total_pages: int = Field(..., description="总页数")
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthAccountListResponse(BaseModel):
|
||||||
|
code: int = Field(0, description="返回码,0表示成功")
|
||||||
|
message: str = Field("查询成功", description="返回消息")
|
||||||
|
data: List[UserOAuthAccountOut] = Field(..., description="授权账户列表数据")
|
||||||
|
pagination: PaginationInfo = Field(..., description="分页信息")
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteOAuthAccountRequest(BaseModel):
|
||||||
|
id: str = Field(..., description="授权账户表id")
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.user_oauth_account import UserOAuthAccount
|
||||||
|
from app.models.user_oauth import UserOAuth
|
||||||
|
|
||||||
|
|
||||||
|
async def get_oauth_account_list(
|
||||||
|
db: AsyncSession,
|
||||||
|
page: int = 1,
|
||||||
|
page_size: int = 20,
|
||||||
|
advertiser_id: str | None = None,
|
||||||
|
oauth_id: str | None = None,
|
||||||
|
advertiser_name: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
# 构建连表查询
|
||||||
|
query = select(
|
||||||
|
UserOAuthAccount.id,
|
||||||
|
UserOAuthAccount.advertiser_id,
|
||||||
|
UserOAuthAccount.advertiser_name,
|
||||||
|
UserOAuthAccount.advertiser_role,
|
||||||
|
UserOAuthAccount.oauth_id,
|
||||||
|
UserOAuthAccount.created_at,
|
||||||
|
UserOAuth.account_id,
|
||||||
|
UserOAuth.account_name,
|
||||||
|
UserOAuth.account_role,
|
||||||
|
UserOAuth.account_username,
|
||||||
|
UserOAuth.account_userid,
|
||||||
|
UserOAuth.open_type,
|
||||||
|
).join(
|
||||||
|
UserOAuth,
|
||||||
|
UserOAuth.id == UserOAuthAccount.oauth_id
|
||||||
|
).where(
|
||||||
|
UserOAuth.deleted_at.is_(None),
|
||||||
|
UserOAuthAccount.deleted_at.is_(None),
|
||||||
|
UserOAuth.user_id == user_id, # 过滤当前登录用户
|
||||||
|
)
|
||||||
|
|
||||||
|
# 添加筛选条件
|
||||||
|
if advertiser_id:
|
||||||
|
query = query.where(UserOAuthAccount.advertiser_id == advertiser_id)
|
||||||
|
if oauth_id:
|
||||||
|
query = query.where(UserOAuthAccount.oauth_id == oauth_id)
|
||||||
|
if advertiser_name:
|
||||||
|
query = query.where(UserOAuthAccount.advertiser_name.like(f"%{advertiser_name}%"))
|
||||||
|
|
||||||
|
# 查询总数
|
||||||
|
count_query = select(func.count()).select_from(query.subquery())
|
||||||
|
total_result = await db.execute(count_query)
|
||||||
|
total = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# 查询分页数据
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
query = query.offset(offset).limit(page_size).order_by(UserOAuthAccount.created_at.desc())
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
accounts = result.all()
|
||||||
|
|
||||||
|
total_pages = (total + page_size - 1) // page_size if total > 0 else 0
|
||||||
|
# 转换为字典列表(或 Pydantic 实例列表)
|
||||||
|
data = [
|
||||||
|
{
|
||||||
|
"id": row.id,
|
||||||
|
"advertiser_id": row.advertiser_id,
|
||||||
|
"advertiser_name": row.advertiser_name,
|
||||||
|
"advertiser_role": row.advertiser_role,
|
||||||
|
"oauth_id": row.oauth_id,
|
||||||
|
"account_id": row.account_id,
|
||||||
|
"account_name": row.account_name,
|
||||||
|
"account_role": row.account_role,
|
||||||
|
"account_username": row.account_username,
|
||||||
|
"account_userid": row.account_userid,
|
||||||
|
"open_type": row.open_type,
|
||||||
|
"created_at": row.created_at,
|
||||||
|
}
|
||||||
|
for row in accounts
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"data": data,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"total": total,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_oauth_account(
|
||||||
|
db: AsyncSession,
|
||||||
|
account_id: str,
|
||||||
|
user_id: str,
|
||||||
|
) -> bool:
|
||||||
|
result = await db.execute(
|
||||||
|
select(UserOAuthAccount).join(
|
||||||
|
UserOAuth,
|
||||||
|
UserOAuth.id == UserOAuthAccount.oauth_id
|
||||||
|
).where(
|
||||||
|
UserOAuthAccount.id == account_id,
|
||||||
|
UserOAuthAccount.deleted_at.is_(None),
|
||||||
|
UserOAuth.user_id == user_id, # 过滤当前登录用户
|
||||||
|
)
|
||||||
|
)
|
||||||
|
account = result.scalar_one_or_none()
|
||||||
|
if not account:
|
||||||
|
raise ValueError("授权账户不存在")
|
||||||
|
|
||||||
|
# 软删除
|
||||||
|
account.deleted_at = func.now()
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
return True
|
||||||
+94
-94
File diff suppressed because one or more lines are too long
Vendored
+1
-1
@@ -28,7 +28,7 @@
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<script type="module" crossorigin src="/assets/index-CV-e6vqH.js"></script>
|
<script type="module" crossorigin src="/assets/index-CyfX-OBY.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-eLRg4pQk.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-eLRg4pQk.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -3,10 +3,10 @@
|
|||||||
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
|
* Handles auth tokens, request/response encryption, snake_case→camelCase conversion.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { encrypt, decrypt } from './crypto';
|
import { encrypt, decrypt, isCryptoAvailable } from './crypto';
|
||||||
|
|
||||||
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
const BASE_URL = import.meta.env.VITE_API_BASE || 'http://localhost:8000';
|
||||||
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY;
|
const USE_ENCRYPTION = !!import.meta.env.VITE_ENCRYPTION_KEY && isCryptoAvailable();
|
||||||
|
|
||||||
interface RequestOptions {
|
interface RequestOptions {
|
||||||
method?: string;
|
method?: string;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* AES-256-GCM encryption/decryption for API request/response.
|
* AES-256-GCM encryption/decryption for API request/response.
|
||||||
* Uses Web Crypto API with a shared symmetric key.
|
* Uses Web Crypto API with a shared symmetric key.
|
||||||
|
* Note: Web Crypto API is only available in secure contexts (HTTPS or localhost).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const ALGO = 'AES-GCM';
|
const ALGO = 'AES-GCM';
|
||||||
@@ -9,10 +10,21 @@ const TAG_LENGTH = 128;
|
|||||||
|
|
||||||
let cryptoKey: CryptoKey | null = null;
|
let cryptoKey: CryptoKey | null = null;
|
||||||
|
|
||||||
|
export function isCryptoAvailable(): boolean {
|
||||||
|
return typeof window !== 'undefined' &&
|
||||||
|
typeof crypto !== 'undefined' &&
|
||||||
|
typeof crypto.subtle !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
async function getCryptoKey(): Promise<CryptoKey> {
|
async function getCryptoKey(): Promise<CryptoKey> {
|
||||||
if (cryptoKey) return cryptoKey;
|
if (cryptoKey) return cryptoKey;
|
||||||
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
|
const keyB64 = import.meta.env.VITE_ENCRYPTION_KEY || '';
|
||||||
if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured');
|
if (!keyB64) throw new Error('VITE_ENCRYPTION_KEY not configured');
|
||||||
|
|
||||||
|
if (!isCryptoAvailable()) {
|
||||||
|
throw new Error('Web Crypto API not available (requires HTTPS or localhost)');
|
||||||
|
}
|
||||||
|
|
||||||
let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
|
let keyBytes = Uint8Array.from(atob(keyB64), c => c.charCodeAt(0));
|
||||||
// AES-256 requires exactly 32 bytes — pad or truncate to match backend
|
// AES-256 requires exactly 32 bytes — pad or truncate to match backend
|
||||||
if (keyBytes.length !== 32) {
|
if (keyBytes.length !== 32) {
|
||||||
@@ -25,6 +37,9 @@ async function getCryptoKey(): Promise<CryptoKey> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function encrypt(plaintext: string): Promise<string> {
|
export async function encrypt(plaintext: string): Promise<string> {
|
||||||
|
if (!isCryptoAvailable()) {
|
||||||
|
throw new Error('Encryption not available in non-secure context');
|
||||||
|
}
|
||||||
const key = await getCryptoKey();
|
const key = await getCryptoKey();
|
||||||
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH));
|
||||||
const encoded = new TextEncoder().encode(plaintext);
|
const encoded = new TextEncoder().encode(plaintext);
|
||||||
@@ -38,6 +53,9 @@ export async function encrypt(plaintext: string): Promise<string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function decrypt(cipherB64: string): Promise<string> {
|
export async function decrypt(cipherB64: string): Promise<string> {
|
||||||
|
if (!isCryptoAvailable()) {
|
||||||
|
throw new Error('Decryption not available in non-secure context');
|
||||||
|
}
|
||||||
const key = await getCryptoKey();
|
const key = await getCryptoKey();
|
||||||
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
|
const combined = Uint8Array.from(atob(cipherB64), c => c.charCodeAt(0));
|
||||||
const iv = combined.slice(0, IV_LENGTH);
|
const iv = combined.slice(0, IV_LENGTH);
|
||||||
|
|||||||
@@ -23,6 +23,20 @@ const formatDateTime = (dateStr: string) => {
|
|||||||
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 安全拼接URL,避免双斜杠
|
||||||
|
const buildUrl = (path: string): string => {
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE || "http://localhost:8000";
|
||||||
|
// 如果已经是完整URL(以http://或https://开头),直接返回
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
// 移除路径开头的斜杠(如果有)
|
||||||
|
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
|
||||||
|
// 移除baseUrl结尾的斜杠(如果有)
|
||||||
|
const cleanBase = baseUrl.endsWith('/') ? baseUrl.slice(0, -1) : baseUrl;
|
||||||
|
return `${cleanBase}/${cleanPath}`;
|
||||||
|
};
|
||||||
|
|
||||||
interface AuthorizationData {
|
interface AuthorizationData {
|
||||||
id: string;
|
id: string;
|
||||||
status: string;
|
status: string;
|
||||||
@@ -363,7 +377,7 @@ const AuthorizationPage: React.FC = () => {
|
|||||||
<div style={{ width: '100%', height: 120, marginBottom: 12, borderRadius: 8, overflow: 'hidden' }}>
|
<div style={{ width: '100%', height: 120, marginBottom: 12, borderRadius: 8, overflow: 'hidden' }}>
|
||||||
{item.thumb ? (
|
{item.thumb ? (
|
||||||
<img
|
<img
|
||||||
src={item.thumb}
|
src={buildUrl(item.thumb)}
|
||||||
alt={item.typeName}
|
alt={item.typeName}
|
||||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user