feat(links): 实现基于权重的轮询算法和链接管理功能

- 新增链接权重字段,支持1-100范围设置
- 修改轮询算法为基于权重的选择机制
- 更新链接API接口返回统一使用LinkInfo模型
- 添加更新链接权重的PATCH端点
- 调整链接仓库查询逻辑,只包含激活状态链接
- 迁移链接相关Pydantic模型到task模块统一管理
- 修改分页响应格式为通用PaginatedResponse包装
- 禁用OpenTelemetry监控配置
This commit is contained in:
danial
2025-09-30 17:02:02 +08:00
parent 736a7467e6
commit 8bc8e1c664
32 changed files with 1426 additions and 539 deletions

View File

@@ -54,15 +54,15 @@ LOG_MAX_SIZE=10485760
LOG_BACKUP_COUNT=5
# OpenTelemetry简化配置
OTEL_ENABLED=true
OTEL_SERVICE_NAME=apple-exchange-backend
OTEL_ENABLED=false
OTEL_SERVICE_NAME=苹果官网下单
OTEL_SERVICE_VERSION=2.0.0
OTEL_EXPORTER_ENDPOINT=http://38.38.251.113:31547
OTEL_EXPORTER_PROTOCOL=grpc
OTEL_EXPORTER_TIMEOUT=30
OTEL_TRACES_ENABLED=true
OTEL_METRICS_ENABLED=true
OTEL_LOGS_ENABLED=true
OTEL_TRACES_ENABLED=false
OTEL_METRICS_ENABLED=false
OTEL_LOGS_ENABLED=false
OTEL_SAMPLER_RATIO=1.0
OTEL_BATCH_SIZE=512
OTEL_EXPORT_INTERVAL=5000

View File

@@ -9,9 +9,12 @@ from app.core.database import get_async_db
from app.core.log import get_logger
from app.schemas.link import (
LinkCreate,
LinkListResponse,
LinkResponse,
LinkStatus,
LinkUpdate,
)
from app.schemas.task import (
LinkInfo,
PaginatedResponse,
)
from app.services.link_service import LinksService
@@ -24,10 +27,10 @@ def get_link_service(db: AsyncSession = Depends(get_async_db)) -> LinksService:
return LinksService(db)
@router.post("/", response_model=LinkResponse)
@router.post("/", response_model=LinkInfo)
async def create_link(
link_data: LinkCreate, link_service: LinksService = Depends(get_link_service)
):
) -> LinkInfo:
"""创建新链接"""
try:
return await link_service.create_link(link_data)
@@ -41,7 +44,7 @@ async def create_link(
raise HTTPException(status_code=500, detail="创建链接失败")
@router.get("/list", response_model=LinkListResponse)
@router.get("/list", response_model=PaginatedResponse[LinkInfo])
async def get_links(
page: int = Query(1, ge=1, description="页码"),
size: int = Query(20, ge=1, le=1000, description="每页大小"),
@@ -49,7 +52,7 @@ async def get_links(
max_amount: float | None = Query(None, description="最大金额"),
url_pattern: str | None = Query(None, description="URL模式"),
link_service: LinksService = Depends(get_link_service),
):
) -> PaginatedResponse[LinkInfo]:
"""获取链接列表"""
try:
result = await link_service.get_links(
@@ -66,10 +69,10 @@ async def get_links(
raise HTTPException(status_code=500, detail="获取链接列表失败")
@router.get("/{link_id}", response_model=LinkResponse)
@router.get("/{link_id}", response_model=LinkInfo)
async def get_link(
link_id: int, link_service: LinksService = Depends(get_link_service)
):
) -> LinkInfo:
"""获取单个链接详情"""
try:
link = await link_service.get_link(str(link_id))
@@ -87,7 +90,7 @@ async def get_link(
@router.delete("/{link_id}")
async def delete_link(
link_id: str, link_service: LinksService = Depends(get_link_service)
):
) -> dict[str, str]:
"""删除链接"""
try:
success = await link_service.delete_link(link_id)
@@ -108,7 +111,7 @@ async def toggle_link_status(
link_id: str,
status: LinkStatus,
link_service: LinksService = Depends(get_link_service),
):
) -> LinkInfo:
"""切换链接状态"""
try:
updated_link = await link_service.update_link_status(link_id, status)
@@ -122,3 +125,25 @@ async def toggle_link_status(
except Exception as e:
logger.error(f"切换链接状态失败: {str(e)}", link_id=link_id, exc_info=True)
raise HTTPException(status_code=500, detail="切换链接状态失败")
@router.patch("/{link_id}/weight")
async def update_link_weight(
link_id: str,
weight: int = Query(..., ge=1, le=100, description="权重值(1-100)"),
link_service: LinksService = Depends(get_link_service),
) -> LinkInfo:
"""更新链接权重"""
try:
link_update = LinkUpdate(url=None, amount=None, weight=weight, status=None)
updated_link = await link_service.update_link(link_id, link_update)
if not updated_link:
logger.warning(f"更新链接权重失败 - 链接不存在: {link_id}")
raise HTTPException(status_code=404, detail="链接不存在")
logger.info(f"链接权重更新成功: {link_id} -> {weight}")
return updated_link
except HTTPException:
raise
except Exception as e:
logger.error(f"更新链接权重失败: {str(e)}", link_id=link_id, exc_info=True)
raise HTTPException(status_code=500, detail="更新链接权重失败")

View File

@@ -12,13 +12,11 @@ from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_async_db
from app.core.log import get_logger
from app.models.orders import OrderStatus
from app.schemas.link import LinkResponse
from app.schemas.order import (
OrderDetailResponse,
OrderStatsResponse,
)
from app.schemas.task import CardInfo, UserInfo, LinkInfo
from app.schemas.user_data import UserInfoResponse
from app.services.order_business_service import OrderService
router = APIRouter()
@@ -56,7 +54,8 @@ async def get_orders(
# Convert SQLAlchemy models to Pydantic response schemas
result = []
for order in orders:
link_response = LinkResponse(
link_response = LinkInfo(
weight=order.links.weight,
id=order.links.id,
url=order.links.url,
amount=order.links.amount,
@@ -79,7 +78,7 @@ async def get_orders(
)
)
user_data = UserInfoResponse(
user_data = UserInfo(
id=order.user_data.id,
email=order.user_data.email,
phone=order.user_data.phone,

View File

@@ -9,10 +9,13 @@ from app.core.database import get_async_db
from app.core.log import get_logger
from app.schemas.user_data import (
UserDataCreate,
UserDataListResponse,
UserDataResponse,
UserDataUploadResponse,
)
from app.schemas.task import (
BulkDeleteUserDataResponse,
PaginatedResponse,
)
from app.services.user_data_service import UserDataService
logger = get_logger(__name__)
@@ -79,6 +82,30 @@ async def get_user_data(
raise HTTPException(status_code=500, detail="获取用户数据详情失败")
@router.delete("/all", response_model=BulkDeleteUserDataResponse, summary="批量删除所有用户数据")
async def bulk_delete_all_user_data(
skip_orders: bool = Query(False, description="是否跳过有关联订单的用户数据"),
service: UserDataService = Depends(get_user_data_service),
):
"""
批量软删除所有用户数据
- **skip_orders**: 是否跳过有关联订单的用户数据默认false会删除包括有关联订单的所有数据
返回删除统计信息,包括总用户数、删除用户数和跳过用户数
"""
try:
result = await service.delete_all_user_data(skip_orders=skip_orders)
logger.info(
f"批量删除用户数据完成: total={result['total_users']}, "
f"deleted={result['deleted_users']}, skipped={result['skipped_users']}"
)
return result
except Exception as e:
logger.error(f"批量删除用户数据失败: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="批量删除用户数据失败")
@router.delete("/{user_id}", summary="删除用户数据")
async def delete_user_data(
user_id: str, service: UserDataService = Depends(get_user_data_service)
@@ -105,7 +132,7 @@ async def delete_user_data(
raise HTTPException(status_code=500, detail="删除用户数据失败")
@router.get("/list", response_model=UserDataListResponse, summary="获取用户数据列表")
@router.get("/list", response_model=PaginatedResponse[UserDataResponse], summary="获取用户数据列表")
async def get_user_data_list(
page: int = Query(1, ge=1, description="页码"),
size: int = Query(20, ge=1, le=100, description="每页大小"),
@@ -202,3 +229,5 @@ async def batch_upload_user_data(
except Exception as e:
logger.error(f"批量上传用户数据失败: {str(e)}", exc_info=True)
raise HTTPException(status_code=500, detail="批量上传用户数据失败")

View File

@@ -13,7 +13,7 @@ from sqlalchemy.ext.asyncio import (
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app.core.config import get_settings

View File

@@ -5,7 +5,7 @@
from typing import TYPE_CHECKING, Any
from enum import Enum
from sqlalchemy import Boolean, Float, Index, String
from sqlalchemy import Boolean, Float, Index, Integer, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .base import BaseModel
@@ -33,6 +33,7 @@ class Links(BaseModel):
url: Mapped[str] = mapped_column(String(255), nullable=False, comment="链接地址")
amount: Mapped[float] = mapped_column(Float, nullable=False, comment="金额")
weight: Mapped[int] = mapped_column(Integer, default=1, nullable=False, comment="权重(1-100)")
status: Mapped[LinkStatus] = mapped_column(
default=LinkStatus.ACTIVE, comment="链接状态"
)

View File

@@ -7,7 +7,7 @@ from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.log import get_logger
from app.models.links import Links
from app.models.links import LinkStatus, Links
from app.repositories.base_repository import BaseRepository
logger = get_logger(__name__)
@@ -142,28 +142,55 @@ class LinkRepository(BaseRepository[Links]):
self, current_position: int = 0
) -> tuple[Links, int] | None:
"""
从轮询池中获取下一个链接
从轮询池中获取下一个链接(基于权重的轮询算法)
Args:
current_position: 当前位置
current_position: 当前轮询位置
Returns:
(链接实例, 新位置) 或 None
"""
# 获取所有链接按ID排序确保顺序一致
query = select(Links).order_by(Links.id)
# 获取所有激活状态且未软删除的链接,按权重降序排列
query = select(Links).where(
Links.status == LinkStatus.ACTIVE.value,
Links.is_deleted == False
).order_by(Links.weight.desc())
result = await self.db_session.execute(query)
links = list(result.scalars().all())
if not links:
return None
# 计算下一个位置
next_position = (current_position + 1) % len(links)
next_link = links[next_position]
# 计算总权重
total_weight = sum(link.weight for link in links)
if total_weight == 0:
return None
logger.info(f"从轮询池获取链接: {next_link.id}, 位置: {next_position}")
return next_link, next_position
# 使用基于权重的轮询算法
# current_position 在总权重范围内循环,确保权重越高的链接被选中的频率越高
position_in_weight_cycle = current_position % total_weight
# 遍历链接,累积权重直到找到应该被选中的链接
accumulated_weight = 0
for link in links:
accumulated_weight += link.weight
if position_in_weight_cycle < accumulated_weight:
# 计算下一个位置,确保在总权重范围内循环
next_position = (current_position + 1) % total_weight if total_weight > 0 else 0
logger.info(
f"从权重轮询池获取链接: {link.id}, 权重: {link.weight}, "
f"累积权重: {accumulated_weight}, 位置: {next_position}"
)
return link, next_position
# 理论上不应该到达这里,但作为保险措施
next_position = (current_position + 1) % total_weight if total_weight > 0 else 0
selected_link = links[0]
logger.info(
f"从权重轮询池获取链接(默认): {selected_link.id}, 权重: {selected_link.weight}, 位置: {next_position}"
)
return selected_link, next_position
async def get_link_by_pool_position(self, position: int) -> Links | None:
"""
@@ -181,9 +208,14 @@ class LinkRepository(BaseRepository[Links]):
async def get_pool_size(self) -> int:
"""
获取轮询池大小
获取轮询池大小(仅包含激活状态且未软删除的链接)
Returns:
池中链接总数
"""
return await self.count()
query = select(func.count()).where(
Links.status == LinkStatus.ACTIVE.value,
Links.is_deleted == False
)
result = await self.db_session.execute(query)
return result.scalar() or 0

View File

@@ -130,3 +130,61 @@ class UserDataRepository(BaseRepository[UserData]):
# 创建新用户
new_user = await self.create(**user_data)
return new_user, True
async def bulk_soft_delete(self, skip_orders: bool = False) -> dict[str, any]:
"""
批量软删除用户数据
Args:
skip_orders: 是否跳过有关联订单的用户数据
Returns:
删除统计信息
"""
try:
# 获取所有未删除的用户数据
all_users = await self.get_all(include_deleted=False)
if not all_users:
return {
"total_users": 0,
"deleted_users": 0,
"skipped_users": 0,
"message": "没有找到需要删除的用户数据"
}
deleted_count = 0
skipped_count = 0
user_ids_to_delete = []
for user in all_users:
# 检查是否有关联订单
if skip_orders:
user_with_orders = await self.get_user_with_orders(user.id)
if user_with_orders and user_with_orders.orders:
skipped_count += 1
continue
user_ids_to_delete.append(user.id)
deleted_count += 1
# 批量执行软删除
if user_ids_to_delete:
await self.bulk_delete(user_ids_to_delete)
logger.info(
f"批量软删除用户数据完成: total={len(all_users)}, "
f"deleted={deleted_count}, skipped={skipped_count}"
)
return {
"total_users": len(all_users),
"deleted_users": deleted_count,
"skipped_users": skipped_count,
"message": f"成功软删除 {deleted_count} 个用户数据" +
(f",跳过 {skipped_count} 个有关联订单的用户数据" if skipped_count > 0 else "")
}
except Exception as e:
logger.error(f"批量软删除用户数据失败: {str(e)}", exc_info=True)
raise

View File

@@ -1,5 +1,6 @@
"""
Pydantic schemas for API request/response models
统一管理的所有 Pydantic 模型
"""
from .health import (
@@ -8,39 +9,45 @@ from .health import (
LivenessCheckResponse,
ReadinessCheckResponse,
)
from .link import (
LinkCreate,
LinkListResponse,
LinkPoolResponse,
LinkResponse,
LinkStatsResponse,
LinkUpdate,
)
from .order import (
OrderDetailResponse,
OrderStatsResponse,
UploadUrlRequest,
UploadUrlResponse,
)
from .task import (
BatchProcessRequest,
CardInfo,
DeleteAllDataResponse,
GiftCardDetailCreate,
GiftCardInfoCreate,
GiftCardInfoResponse,
GiftCardRequest,
GiftCardResponse,
GiftCardSubmissionRequest,
GiftCardSubmissionResponse,
LinkBase,
LinkCreate,
LinkInfo,
LinkPoolResponse,
LinkStatsResponse,
LinkStatus,
LinkUpdate,
OrderDetailResponse,
OrderStatsResponse,
PaginatedResponse,
ProcessOrderRequest,
QueueStatsResponse,
TaskControlRequest,
TaskControlResponse,
TaskListResponse,
TaskListItem,
TaskStateResponse,
TaskStatusResponse,
UploadUrlRequest,
UploadUrlResponse,
UserInfo,
WorkerStatusResponse,
)
from .user_data import (
UserDataBase,
UserDataCreate,
UserDataListResponse,
UserDataResponse,
UserDataStatsResponse,
UserDataUpdate,
UserDataUploadResponse,
WorkerStatusResponse,
)
__all__ = [
@@ -49,32 +56,45 @@ __all__ = [
"LinkInfo",
"CardInfo",
"PaginatedResponse",
"LinkStatus",
# User data schemas
"UserDataBase",
"UserDataCreate",
"UserDataUpdate",
"UserDataResponse",
"UserDataUploadResponse",
"UserDataStatsResponse",
# Link schemas
"LinkBase",
"LinkCreate",
"LinkUpdate",
"LinkPoolResponse",
"LinkStatsResponse",
# Order schemas
"OrderStatsResponse",
"OrderDetailResponse",
"UploadUrlRequest",
"UploadUrlResponse",
# Gift card schemas
"GiftCardRequest",
"GiftCardResponse",
"GiftCardSubmissionRequest",
"GiftCardSubmissionResponse",
"GiftCardInfoCreate",
"GiftCardInfoResponse",
"GiftCardDetailCreate",
# Task schemas
"ProcessOrderRequest",
"BatchProcessRequest",
"TaskStatusResponse",
"WorkerStatusResponse",
"QueueStatsResponse",
# Link schemas
"LinkCreate",
"LinkUpdate",
"LinkResponse",
"LinkListResponse",
"LinkPoolResponse",
"LinkStatsResponse",
# User data schemas
"UserDataCreate",
"UserDataUpdate",
"UserDataResponse",
"UserDataListResponse",
"UserDataUploadResponse",
"UserDataStatsResponse",
"TaskControlRequest",
"TaskControlResponse",
"TaskStateResponse",
"TaskListResponse",
"TaskListItem",
"DeleteAllDataResponse",
# Health schemas
"HealthCheckResponse",
"DetailedHealthCheckResponse",

View File

@@ -21,5 +21,3 @@ __all__ = [
"GiftCardDetailCreate",
]
# 为了向后兼容,保留别名
GiftCardInfoResponse = CardInfo

View File

@@ -1,103 +1,27 @@
"""
链接相关的Pydantic模型
已迁移到 app.schemas.task 模块中统一管理
"""
from datetime import datetime
from enum import Enum
# 从统一schema导入所有链接相关模型
from app.schemas.task import (
LinkBase,
LinkCreate,
LinkInfo,
LinkPoolResponse,
LinkStatsResponse,
LinkUpdate,
LinkStatus,
PaginatedResponse,
)
from pydantic import BaseModel, ConfigDict, Field
class LinkStatus(str, Enum):
"""链接状态枚举"""
ACTIVE = "active"
INACTIVE = "inactive"
class LinkBase(BaseModel):
"""链接基础模型"""
url: str = Field(..., description="链接URL", max_length=255)
amount: float = Field(..., description="金额", gt=0)
status: LinkStatus = Field(LinkStatus.ACTIVE, description="链接状态")
class LinkCreate(LinkBase):
"""创建链接请求模型"""
pass
class LinkUpdate(BaseModel):
"""更新链接请求模型"""
url: str | None = Field(None, description="链接URL", max_length=255)
amount: float | None = Field(None, description="金额", gt=0)
status: LinkStatus | None = Field(None, description="链接状态")
# 从统一schema导入LinkInfo延迟导入避免循环导入
try:
from app.schemas.task import LinkInfo
# 为了向后兼容,保留别名
LinkResponse = LinkInfo
except ImportError:
# 如果导入失败保持原有的LinkResponse
class LinkResponse(LinkBase):
"""链接响应模型"""
id: str = Field(..., description="链接ID")
created_at: str = Field(..., description="创建时间")
updated_at: str = Field(..., description="更新时间")
model_config = ConfigDict(from_attributes=True)
@classmethod
def from_orm(cls, obj):
"""Custom ORM conversion to handle datetime serialization"""
data = {}
for field in cls.model_fields:
value = getattr(obj, field, None)
if isinstance(value, datetime):
data[field] = value.isoformat()
else:
data[field] = value
return cls(**data)
# 从统一schema导入PaginatedResponse延迟导入避免循环导入
try:
from app.schemas.task import PaginatedResponse
# 为了向后兼容,保留别名
LinkListResponse = PaginatedResponse[LinkResponse]
except ImportError:
# 如果导入失败保持原有的LinkListResponse
class LinkListResponse(BaseModel):
"""链接列表响应模型"""
items: list[LinkResponse]
total: int
page: int
size: int
pages: int
class LinkPoolResponse(BaseModel):
"""轮询池响应模型"""
link: LinkResponse
pool_position: int = Field(..., description="在轮询池中的位置")
class LinkStatsResponse(BaseModel):
"""链接统计响应模型"""
total_links: int = Field(..., description="总链接数")
total_orders: int = Field(..., description="总订单数")
average_amount: float = Field(..., description="平均金额")
min_amount: float = Field(..., description="最小金额")
max_amount: float = Field(..., description="最大金额")
__all__ = [
"LinkBase",
"LinkCreate",
"LinkInfo",
"LinkPoolResponse",
"LinkStatsResponse",
"LinkUpdate",
"LinkStatus",
"PaginatedResponse",
]

View File

@@ -1,56 +1,20 @@
"""
订单相关的Pydantic模型
已迁移到 app.schemas.task 模块中统一管理
"""
from pydantic import BaseModel, ConfigDict, Field
# 从统一schema导入所有订单相关模型
from app.schemas.task import (
OrderDetailResponse,
OrderStatsResponse,
UploadUrlRequest,
UploadUrlResponse,
UserInfo,
)
from app.models.orders import OrderStatus
from app.schemas.task import CardInfo, LinkInfo, UserInfo
class OrderStatsResponse(BaseModel):
"""订单统计响应"""
total: int
pending: int
processing: int
success: int
failed: int
last_update: str
class OrderDetailResponse(BaseModel):
"""订单详情响应 - 与数据库结构完全一致"""
id: str = Field(..., description="订单ID")
status: OrderStatus = Field(..., description="订单状态")
created_at: str = Field(..., description="创建时间")
updated_at: str = Field(..., description="更新时间")
final_order_url: str | None = Field(None, description="最终订单URL")
final_order_id: str | None = Field(None, description="最终苹果订单ID")
failure_reason: str | None = Field(None, description="失败原因")
user_data_id: str = Field(..., description="用户数据ID")
links_id: str = Field(..., description="链接ID")
# 关联关系
user_data: UserInfo = Field(description="用户数据")
links: LinkInfo = Field(description="链接信息")
gift_cards: list[CardInfo] = Field(default_factory=list, description="礼品卡列表")
model_config = ConfigDict(from_attributes=True)
class UploadUrlRequest(BaseModel):
"""上传URL请求"""
url: str = Field(..., min_length=1, description="上传URL")
thread_id: str | None = Field(None, description="线程ID")
class UploadUrlResponse(BaseModel):
"""上传URL响应"""
success: bool
message: str
upload_config_id: str
url: str
__all__ = [
"OrderDetailResponse",
"OrderStatsResponse",
"UploadUrlRequest",
"UploadUrlResponse",
]

View File

@@ -4,14 +4,21 @@
"""
from datetime import datetime
from enum import Enum
from typing import Any, Generic, List, TypeVar
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.enums.task import OrderTaskStatus
from app.models.orders import OrderStatus
from app.models.giftcards import GiftCardStatus
from app.models.links import LinkStatus
class LinkStatus(str, Enum):
"""链接状态枚举"""
ACTIVE = "active"
INACTIVE = "inactive"
T = TypeVar("T")
@@ -101,8 +108,6 @@ class UserInfo(BaseModel):
model_config = ConfigDict(from_attributes=True)
# 为了向后兼容,保留 TaskUserInfo 别名
TaskUserInfo = UserInfo
class LinkInfo(BaseModel):
@@ -111,6 +116,7 @@ class LinkInfo(BaseModel):
id: str = Field(..., description="链接ID")
url: str = Field(..., description="链接地址")
amount: float = Field(..., description="金额")
weight: int = Field(..., description="权重(1-100)")
status: LinkStatus = Field(..., description="链接状态")
created_at: str = Field(description="创建时间")
updated_at: str = Field(description="更新时间")
@@ -118,8 +124,6 @@ class LinkInfo(BaseModel):
model_config = ConfigDict(from_attributes=True)
# 为了向后兼容,保留 TaskLinkInfo 别名
TaskLinkInfo = LinkInfo
class CardInfo(BaseModel):
@@ -137,8 +141,6 @@ class CardInfo(BaseModel):
model_config = ConfigDict(from_attributes=True)
# 为了向后兼容,保留 TaskCardInfo 别名
TaskCardInfo = CardInfo
class TaskListItem(BaseModel):
@@ -268,3 +270,173 @@ class PaginatedResponse(BaseModel, Generic[T]):
pages: int = Field(..., description="总页数")
model_config = ConfigDict(from_attributes=True, arbitrary_types_allowed=True)
# 用户数据相关模型
class UserDataBase(BaseModel):
"""用户数据基础模型"""
first_name: str = Field(..., description="名字", max_length=255)
last_name: str = Field(..., description="姓氏", max_length=255)
email: str = Field(..., description="邮箱", max_length=255)
phone: str = Field(..., description="电话", max_length=50)
street_address: str = Field(..., description="街道地址", max_length=500)
city: str = Field(..., description="城市", max_length=255)
state: str = Field(..., description="州/省", max_length=255)
zip_code: str = Field(..., description="邮编", max_length=20)
@field_validator("email")
@classmethod
def validate_email(cls, v):
"""验证邮箱格式"""
import re
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(pattern, v):
raise ValueError("邮箱格式不正确")
return v
class UserDataCreate(UserDataBase):
"""创建用户数据请求模型"""
pass
class UserDataUpdate(BaseModel):
"""更新用户数据请求模型"""
first_name: str | None = Field(None, description="名字", max_length=255)
last_name: str | None = Field(None, description="姓氏", max_length=255)
email: str | None = Field(None, description="邮箱", max_length=255)
phone: str | None = Field(None, description="电话", max_length=50)
street_address: str | None = Field(None, description="街道地址", max_length=500)
city: str | None = Field(None, description="城市", max_length=255)
state: str | None = Field(None, description="州/省", max_length=255)
zip_code: str | None = Field(None, description="邮编", max_length=20)
class UserDataResponse(UserDataBase):
"""用户数据响应模型"""
id: str = Field(..., description="用户数据ID")
created_at: str = Field(..., description="创建时间")
updated_at: str = Field(..., description="更新时间")
model_config = ConfigDict(from_attributes=True)
class UserDataUploadResponse(BaseModel):
"""用户数据上传响应模型"""
user_data: UserDataResponse
message: str = Field(..., description="响应消息")
class UserDataStatsResponse(BaseModel):
"""用户数据统计响应模型"""
total_users: int = Field(..., description="总用户数")
total_orders: int = Field(..., description="总订单数")
recent_uploads: int = Field(..., description="最近上传数量")
success_rate: float = Field(..., description="成功率")
class BulkDeleteUserDataResponse(BaseModel):
"""批量删除用户数据响应模型"""
total_users: int = Field(..., description="总用户数")
deleted_users: int = Field(..., description="已删除用户数")
skipped_users: int = Field(..., description="跳过的用户数")
message: str = Field(..., description="响应消息")
# 链接相关模型
class LinkBase(BaseModel):
"""链接基础模型"""
url: str = Field(..., description="链接URL", max_length=255)
amount: float = Field(..., description="金额", gt=0)
weight: int = Field(1, description="权重(1-100)", ge=1, le=100)
status: LinkStatus = Field(LinkStatus.ACTIVE, description="链接状态")
class LinkCreate(LinkBase):
"""创建链接请求模型"""
pass
class LinkUpdate(BaseModel):
"""更新链接请求模型"""
url: str | None = Field(None, description="链接URL", max_length=255)
amount: float | None = Field(None, description="金额", gt=0)
weight: int | None = Field(None, description="权重(1-100)", ge=1, le=100)
status: LinkStatus | None = Field(None, description="链接状态")
model_config = ConfigDict(use_enum_values=True)
class LinkPoolResponse(BaseModel):
"""轮询池响应模型"""
link: LinkInfo
pool_position: int = Field(..., description="在轮询池中的位置")
class LinkStatsResponse(BaseModel):
"""链接统计响应模型"""
total_links: int = Field(..., description="总链接数")
total_orders: int = Field(..., description="总订单数")
average_amount: float = Field(..., description="平均金额")
min_amount: float = Field(..., description="最小金额")
max_amount: float = Field(..., description="最大金额")
# 订单相关模型
class OrderStatsResponse(BaseModel):
"""订单统计响应"""
total: int
pending: int
processing: int
success: int
failed: int
last_update: str
class OrderDetailResponse(BaseModel):
"""订单详情响应 - 与数据库结构完全一致"""
id: str = Field(..., description="订单ID")
status: OrderStatus = Field(..., description="订单状态")
created_at: str = Field(..., description="创建时间")
updated_at: str = Field(..., description="更新时间")
final_order_url: str | None = Field(None, description="最终订单URL")
final_order_id: str | None = Field(None, description="最终苹果订单ID")
failure_reason: str | None = Field(None, description="失败原因")
user_data_id: str = Field(..., description="用户数据ID")
links_id: str = Field(..., description="链接ID")
# 关联关系
user_data: UserInfo = Field(description="用户数据")
links: LinkInfo = Field(description="链接信息")
gift_cards: list[CardInfo] = Field(default_factory=list, description="礼品卡列表")
model_config = ConfigDict(from_attributes=True)
class UploadUrlRequest(BaseModel):
"""上传URL请求"""
url: str = Field(..., min_length=1, description="上传URL")
thread_id: str | None = Field(None, description="线程ID")
class UploadUrlResponse(BaseModel):
"""上传URL响应"""
success: bool
message: str
upload_config_id: str
url: str

View File

@@ -1,104 +1,27 @@
"""
用户数据相关的Pydantic模型
已迁移到 app.schemas.task 模块中统一管理
"""
from datetime import datetime
# 从统一schema导入所有用户数据相关模型
from app.schemas.task import (
UserDataBase,
UserDataCreate,
UserDataUpdate,
UserDataResponse,
UserDataUploadResponse,
UserDataStatsResponse,
PaginatedResponse,
UserInfo,
)
from pydantic import BaseModel, ConfigDict, Field, field_validator
class UserDataBase(BaseModel):
"""用户数据基础模型"""
first_name: str = Field(..., description="名字", max_length=255)
last_name: str = Field(..., description="姓氏", max_length=255)
email: str = Field(..., description="邮箱", max_length=255)
phone: str = Field(..., description="电话", max_length=50)
street_address: str = Field(..., description="街道地址", max_length=500)
city: str = Field(..., description="城市", max_length=255)
state: str = Field(..., description="州/省", max_length=255)
zip_code: str = Field(..., description="邮编", max_length=20)
@field_validator("email")
@classmethod
def validate_email(cls, v):
"""验证邮箱格式"""
import re
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
if not re.match(pattern, v):
raise ValueError("邮箱格式不正确")
return v
class UserDataCreate(UserDataBase):
"""创建用户数据请求模型"""
pass
class UserDataUpdate(BaseModel):
"""更新用户数据请求模型"""
first_name: str | None = Field(None, description="名字", max_length=255)
last_name: str | None = Field(None, description="姓氏", max_length=255)
email: str | None = Field(None, description="邮箱", max_length=255)
phone: str | None = Field(None, description="电话", max_length=50)
street_address: str | None = Field(None, description="街道地址", max_length=500)
city: str | None = Field(None, description="城市", max_length=255)
state: str | None = Field(None, description="州/省", max_length=255)
zip_code: str | None = Field(None, description="邮编", max_length=20)
class UserDataResponse(UserDataBase):
"""用户数据响应模型"""
id: str = Field(..., description="用户数据ID")
created_at: str = Field(..., description="创建时间")
updated_at: str = Field(..., description="更新时间")
model_config = ConfigDict(from_attributes=True)
# 从统一schema导入PaginatedResponse
from app.schemas.task import PaginatedResponse
# 为了向后兼容,保留别名
UserDataListResponse = PaginatedResponse[UserDataResponse]
class UserDataUploadResponse(BaseModel):
"""用户数据上传响应模型"""
user_data: UserDataResponse
message: str = Field(..., description="响应消息")
class UserDataResponse(UserDataBase):
"""用户数据响应模型"""
id: str = Field(..., description="用户数据ID")
created_at: str = Field(..., description="创建时间")
updated_at: str = Field(..., description="更新时间")
model_config = ConfigDict(from_attributes=True)
class UserDataStatsResponse(BaseModel):
"""用户数据统计响应模型"""
total_users: int = Field(..., description="总用户数")
total_orders: int = Field(..., description="总订单数")
recent_uploads: int = Field(..., description="最近上传数量")
success_rate: float = Field(..., description="成功率")
# 从统一schema导入UserInfo延迟导入避免循环导入
try:
from app.schemas.task import UserInfo
# 为了向后兼容,保留别名
UserInfoResponse = UserInfo
except ImportError:
# 如果导入失败定义一个临时的UserInfoResponse
UserInfoResponse = UserDataResponse
__all__ = [
"UserDataBase",
"UserDataCreate",
"UserDataUpdate",
"UserDataResponse",
"UserDataUploadResponse",
"UserDataStatsResponse",
"PaginatedResponse",
"UserInfo",
]

View File

@@ -16,13 +16,15 @@ from app.models.links import Links
from app.repositories.repository_factory import RepositoryFactory
from app.schemas.link import (
LinkCreate,
LinkListResponse,
LinkPoolResponse,
LinkResponse,
LinkStatsResponse,
LinkUpdate,
LinkStatus,
)
from app.schemas.task import (
LinkInfo,
PaginatedResponse,
)
logger = get_logger(__name__)
@@ -39,7 +41,7 @@ class LinksService:
self.repo_factory = RepositoryFactory(db)
# 注意这里不再直接获取redis客户端而是在需要时调用get_redis()
async def create_link(self, link_data: LinkCreate) -> LinkResponse:
async def create_link(self, link_data: LinkCreate) -> LinkInfo:
"""
创建新链接
@@ -56,7 +58,7 @@ class LinksService:
# 创建链接
link = await self.repo_factory.links.create(
url=link_data.url, amount=link_data.amount
url=link_data.url, amount=link_data.amount, weight=link_data.weight
)
logger.info(f"创建链接成功: {link.id}")
@@ -64,7 +66,7 @@ class LinksService:
async def update_link_status(
self, link_id: str, status: LinkStatus
) -> LinkResponse | None:
) -> LinkInfo | None:
"""
更新链接状态
@@ -83,7 +85,7 @@ class LinksService:
return self._convert_to_response(updated_link)
return None
async def get_link(self, link_id: str) -> LinkResponse | None:
async def get_link(self, link_id: str) -> LinkInfo | None:
"""
获取单个链接
@@ -100,7 +102,7 @@ class LinksService:
async def update_link(
self, link_id: str, link_data: LinkUpdate
) -> LinkResponse | None:
) -> LinkInfo | None:
"""
更新链接
@@ -164,7 +166,7 @@ class LinksService:
min_amount: float | None = None,
max_amount: float | None = None,
url_pattern: str | None = None,
) -> LinkListResponse:
) -> PaginatedResponse[LinkInfo]:
"""
获取链接列表
@@ -198,7 +200,7 @@ class LinksService:
total = result.total
pages = result.pages
return LinkListResponse(
return PaginatedResponse[LinkInfo](
items=[self._convert_to_response(link) for link in links],
total=total,
page=page,
@@ -215,7 +217,7 @@ class LinksService:
page_links = sorted(page_links, key=lambda x: x.created_at, reverse=True)
return LinkListResponse(
return PaginatedResponse[LinkInfo](
items=[self._convert_to_response(link) for link in page_links],
total=total,
page=page,
@@ -297,7 +299,7 @@ class LinksService:
pool_size = await self.repo_factory.links.get_pool_size()
await redis_client.set(self.POOL_SIZE_KEY, str(pool_size))
def _convert_to_response(self, link: Links) -> LinkResponse:
def _convert_to_response(self, link: Links) -> LinkInfo:
"""
将链接模型转换为响应模型
@@ -307,10 +309,11 @@ class LinksService:
Returns:
链接响应模型
"""
return LinkResponse(
return LinkInfo(
id=link.id,
url=link.url,
amount=link.amount,
weight=link.weight,
status=link.status,
created_at=link.created_at.isoformat(),
updated_at=link.updated_at.isoformat(),

View File

@@ -14,14 +14,14 @@ from app.core.state_manager import StateType, TaskState, task_state_manager
from app.enums.task import OrderTaskStatus
from app.repositories.task_repository import TaskRepository
from app.schemas.task import (
CardInfo,
GiftCardSubmissionRequest,
GiftCardSubmissionResponse,
TaskCardInfo,
TaskLinkInfo,
GiftCardDetailCreate,
LinkInfo,
TaskListItem,
TaskListResponse,
TaskUserInfo,
GiftCardDetailCreate,
UserInfo,
)
from app.services.gift_card_service import GiftCardService
@@ -132,7 +132,7 @@ class TaskService:
user_info = None
if order.user_data:
user_data = order.user_data
user_info = TaskUserInfo(
user_info = UserInfo(
id=user_data.id,
first_name=user_data.first_name,
last_name=user_data.last_name,
@@ -150,7 +150,8 @@ class TaskService:
link_info = None
if order.links:
link = order.links
link_info = TaskLinkInfo(
link_info = LinkInfo(
weight=link.weight,
id=link.id,
url=link.url,
amount=link.amount,
@@ -170,7 +171,7 @@ class TaskService:
if gift_card_list:
card_info = []
for gift_card in gift_card_list:
card_info_item = TaskCardInfo(
card_info_item = CardInfo(
id=gift_card.id,
card_code=gift_card.card_code,
card_value=gift_card.card_value,

View File

@@ -16,12 +16,14 @@ from app.repositories.repository_factory import RepositoryFactory
from app.schemas.user_data import (
UserDataBase,
UserDataCreate,
UserDataListResponse,
UserDataResponse,
UserDataStatsResponse,
UserDataUpdate,
UserDataUploadResponse,
UserInfoResponse,
)
from app.schemas.task import (
PaginatedResponse,
UserInfo,
)
@@ -93,7 +95,7 @@ class UserDataService:
return None
return self._convert_to_response(user)
async def get_user_info(self, user_id: str) -> UserInfoResponse | None:
async def get_user_info(self, user_id: str) -> UserInfo | None:
"""
获取用户完整信息(包含所有数据库字段)
@@ -177,7 +179,7 @@ class UserDataService:
state: str | None = None,
country: str | None = None,
name_pattern: str | None = None,
) -> UserDataListResponse:
) -> PaginatedResponse[UserDataResponse]:
"""
获取用户数据列表
@@ -213,7 +215,7 @@ class UserDataService:
total = result.total
pages = result.pages
return UserDataListResponse(
return PaginatedResponse[UserDataResponse](
items=[self._convert_to_response(user) for user in users],
total=total,
page=page,
@@ -228,7 +230,7 @@ class UserDataService:
page_users = users[start_idx:end_idx]
pages = (total + size - 1) // size
return UserDataListResponse(
return PaginatedResponse[UserDataResponse](
items=[self._convert_to_response(user) for user in page_users],
total=total,
page=page,
@@ -282,7 +284,7 @@ class UserDataService:
updated_at=user.updated_at.isoformat(),
)
def _convert_to_info_response(self, user: UserData) -> UserInfoResponse:
def _convert_to_info_response(self, user: UserData) -> UserInfo:
"""
将用户数据模型转换为完整信息响应模型
@@ -292,7 +294,7 @@ class UserDataService:
Returns:
用户完整信息响应模型
"""
return UserInfoResponse(
return UserInfo(
id=user.id,
first_name=user.first_name,
last_name=user.last_name,
@@ -307,3 +309,15 @@ class UserDataService:
full_name=user.full_name,
full_address=user.full_address,
)
async def delete_all_user_data(self, skip_orders: bool = False) -> dict[str, Any]:
"""
软删除所有用户数据
Args:
skip_orders: 是否跳过有关联订单的用户数据
Returns:
删除统计信息
"""
return await self.repo_factory.user_data.bulk_soft_delete(skip_orders=skip_orders)

View File

@@ -70,6 +70,43 @@ async def _process_apple_order_async(
"order_id": order_id,
}
# 检查关联的用户数据是否已被软删除
async with db_manager.get_async_session() as session:
order_repo = OrderRepository(session)
order = await order_repo.get_by_id(order_id, relations=["user_data"])
if not order:
logger.error(f"订单不存在: {order_id}")
await task_state_manager.fail_task(
task_id, order_id, f"订单 {order_id} 不存在"
)
return {
"success": False,
"error": f"订单 {order_id} 不存在",
"order_id": order_id,
}
# 检查用户数据是否已被软删除
if order.user_data.is_deleted:
logger.warning(f"用户数据已被软删除,终止订单处理: {order_id}, user_data_id={order.user_data_id}")
await task_state_manager.fail_task(
task_id, order_id, f"用户数据 {order.user_data_id} 已被删除"
)
# 更新订单状态为失败
await order_repo.update_by_id(
order_id,
status=OrderStatus.FAILURE,
failure_reason=f"用户数据 {order.user_data_id} 已被删除",
completed_at=datetime.now(),
)
return {
"success": False,
"error": f"用户数据 {order.user_data_id} 已被删除",
"order_id": order_id,
}
# 获取分布式锁
lock_key = f"apple_order_processing:{order_id}"
lock = get_lock(

20
frontend/.hintrc Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": [
"development"
],
"hints": {
"axe/forms": [
"default",
{
"label": "off"
}
],
"axe/name-role-value": [
"default",
{
"button-name": "off"
}
],
"button-type": "off"
}
}

View File

@@ -1,6 +1,8 @@
"use client";
import { Trash2, ExternalLink, Calendar, DollarSign, Copy, Loader2, Pause, Play } from "lucide-react";
import { useState } from "react";
import { Trash2, ExternalLink, Calendar, DollarSign, Copy, Loader2, Pause, Play, Edit3, Check, X } from "lucide-react";
import { toast } from "sonner";
// 导入 Tooltip 组件
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/animate-ui/base/tooltip";
@@ -11,11 +13,15 @@ interface LinkItemProps {
link: LinkInfo;
onDelete: (linkId: string) => void;
onToggleStatus?: (linkId: string) => void;
onUpdateWeight?: (linkId: string, weight: number) => void;
isDeleting?: boolean;
isTogglingStatus?: boolean;
isUpdatingWeight?: boolean;
}
export function LinkItem({ link, onDelete, onToggleStatus, isDeleting = false, isTogglingStatus = false }: LinkItemProps) {
export function LinkItem({ link, onDelete, onToggleStatus, onUpdateWeight, isDeleting = false, isTogglingStatus = false, isUpdatingWeight = false }: LinkItemProps) {
const [isEditingWeight, setIsEditingWeight] = useState(false);
const [tempWeight, setTempWeight] = useState(link.weight);
// 截断URL显示
const truncateUrl = (url: string, maxLength: number = 30) => {
if (url.length <= maxLength) return url;
@@ -92,6 +98,31 @@ export function LinkItem({ link, onDelete, onToggleStatus, isDeleting = false, i
}
};
// 处理权重编辑
const handleWeightEdit = () => {
setTempWeight(link.weight);
setIsEditingWeight(true);
};
// 保存权重
const handleSaveWeight = async () => {
if (onUpdateWeight) {
try {
await onUpdateWeight(link.id, tempWeight);
setIsEditingWeight(false);
toast.success("权重更新成功");
} catch {
toast.error("权重更新失败");
}
}
};
// 取消权重编辑
const handleCancelWeightEdit = () => {
setTempWeight(link.weight);
setIsEditingWeight(false);
};
const statusInfo = getStatusInfo(link.status);
const StatusIcon = statusInfo.icon;
@@ -130,6 +161,54 @@ export function LinkItem({ link, onDelete, onToggleStatus, isDeleting = false, i
{/* 第二行:金额、创建时间和操作按钮 */}
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-4">
{/* 权重信息 */}
<div className="flex items-center space-x-2">
<span className="text-xs text-gray-500 dark:text-gray-400">:</span>
{isEditingWeight ? (
<div className="flex items-center space-x-1">
<input
type="number"
min="0"
max="100"
step="1"
value={tempWeight}
onChange={(e) => setTempWeight(Number(e.target.value))}
className="w-12 px-1 py-0.5 text-xs border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100"
disabled={isUpdatingWeight}
/>
<button
onClick={handleSaveWeight}
disabled={isUpdatingWeight}
className="p-0.5 text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 rounded transition-colors"
>
{isUpdatingWeight ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
</button>
<button
onClick={handleCancelWeightEdit}
disabled={isUpdatingWeight}
className="p-0.5 text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<div className="flex items-center space-x-1">
<span className="text-sm font-medium text-blue-600 dark:text-blue-400">
{link.weight}
</span>
{onUpdateWeight && (
<button
onClick={handleWeightEdit}
disabled={isUpdatingWeight}
className="p-0.5 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded transition-colors"
>
<Edit3 className="h-3 w-3" />
</button>
)}
</div>
)}
</div>
{/* 金额信息 */}
<div className="flex items-center space-x-1 text-sm">
<DollarSign className="h-4 w-4 text-green-600" />

View File

@@ -2,11 +2,12 @@
import { useState } from "react";
import { toast } from "sonner";
import { Plus, RefreshCw, AlertCircle, Loader2, Trash2, Activity } from "lucide-react";
import { Plus, RefreshCw, AlertCircle, Loader2, Trash2, Activity, ChevronLeft, ChevronRight } from "lucide-react";
import {
useCreateLinkApiV1LinksPost,
useDeleteLinkApiV1LinksLinkIdDelete,
useToggleLinkStatusApiV1LinksLinkIdStatusPatch,
useUpdateLinkWeightApiV1LinksLinkIdWeightPatch,
useGetLinksApiV1LinksListGet
} from "@/lib/api/generated/link-management.gen";
import { AppleButton } from "@/components/ui/apple-button";
@@ -25,6 +26,8 @@ export function LinkManagement({
refreshEnabled = false,
refreshInterval = 5000,
}: LinkManagementProps) {
const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 5;
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [newUrl, setNewUrl] = useState("");
const [newAmount, setNewAmount] = useState("");
@@ -34,11 +37,15 @@ export function LinkManagement({
const [isDeletingLink, setIsDeletingLink] = useState(false);
const [isTogglingStatus, setIsTogglingStatus] = useState(false);
const [togglingLinkId, setTogglingLinkId] = useState<string | null>(null);
const [isUpdatingWeight, setIsUpdatingWeight] = useState(false);
const [updatingWeightLinkId, setUpdatingWeightLinkId] = useState<string | null>(null);
const [newWeight, setNewWeight] = useState("1");
// API hooks
const createLinkMutation = useCreateLinkApiV1LinksPost();
const deleteLinkMutation = useDeleteLinkApiV1LinksLinkIdDelete();
const toggleLinkStatusMutation = useToggleLinkStatusApiV1LinksLinkIdStatusPatch();
const updateLinkWeightMutation = useUpdateLinkWeightApiV1LinksLinkIdWeightPatch();
// 获取链接列表
const {
@@ -48,7 +55,7 @@ export function LinkManagement({
refetch,
isRefetching
} = useGetLinksApiV1LinksListGet(
{ page: 1, size: 50 },
{ page: currentPage, size: ITEMS_PER_PAGE },
{
query: {
enabled: true,
@@ -71,12 +78,19 @@ export function LinkManagement({
return;
}
const weight = parseInt(newWeight) || 1;
if (weight < 0 || weight > 100) {
toast.error("权重必须在0-100之间");
return;
}
setIsAddingLink(true);
try {
await createLinkMutation.mutateAsync({
data: {
url: newUrl.trim(),
amount: amount
amount: amount,
weight: weight
}
});
toast.success("链接添加成功", {
@@ -86,6 +100,7 @@ export function LinkManagement({
setIsDialogOpen(false);
setNewUrl("");
setNewAmount("");
setNewWeight("1");
refetch();
} catch (error) {
toast.error("添加链接失败", {
@@ -153,8 +168,28 @@ export function LinkManagement({
}
};
const handleUpdateWeight = async (linkId: string, weight: number) => {
setUpdatingWeightLinkId(linkId);
setIsUpdatingWeight(true);
try {
await updateLinkWeightMutation.mutateAsync({
linkId: linkId,
params: {
weight: weight
}
});
refetch();
} catch (error) {
throw error; // 重新抛出错误,由 LinkItem 组件处理
} finally {
setIsUpdatingWeight(false);
setUpdatingWeightLinkId(null);
}
};
const links = linksData?.items || [];
const total = linksData?.total || 0;
const totalPages = Math.ceil(total / ITEMS_PER_PAGE);
return (
<div className="space-y-6">
@@ -240,6 +275,26 @@ export function LinkManagement({
</p>
</div>
<div className="space-y-2">
<Label htmlFor="weight" className="text-sm font-medium text-gray-700 dark:text-gray-300">
*
</Label>
<Input
id="weight"
type="number"
placeholder="1"
min="0"
max="100"
step="1"
value={newWeight}
onChange={(e) => setNewWeight(e.target.value)}
className="apple-input"
/>
<p className="text-xs text-gray-500 dark:text-gray-400">
0-100
</p>
</div>
</div>
<DialogFooter className="flex gap-2 pt-4">
@@ -252,7 +307,7 @@ export function LinkManagement({
</AppleButton>
<AppleButton
onClick={handleAddLink}
disabled={isAddingLink || !newUrl.trim() || !newAmount}
disabled={isAddingLink || !newUrl.trim() || !newAmount || !newWeight}
className="flex items-center gap-2 apple-glass-button"
>
{isAddingLink ? (
@@ -356,8 +411,10 @@ export function LinkManagement({
link={link}
onDelete={handleDeleteLink}
onToggleStatus={handleToggleStatus}
onUpdateWeight={handleUpdateWeight}
isDeleting={isDeletingLink}
isTogglingStatus={isTogglingStatus && togglingLinkId === link.id}
isUpdatingWeight={isUpdatingWeight && updatingWeightLinkId === link.id}
/>
))}
</div>
@@ -365,11 +422,74 @@ export function LinkManagement({
)}
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">
{currentPage} {totalPages}
</span>
<span className="text-sm text-gray-500 dark:text-gray-500">
( {total} )
</span>
</div>
<div className="flex items-center gap-2">
<AppleButton
variant="outline"
size="icon"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="w-8 h-8 rounded-lg"
>
<ChevronLeft className="h-4 w-4" />
</AppleButton>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<AppleButton
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(pageNum)}
className={`w-8 h-8 rounded-lg ${currentPage === pageNum ? 'apple-glass-button' : ''}`}
>
{pageNum}
</AppleButton>
);
})}
</div>
<AppleButton
variant="outline"
size="icon"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="w-8 h-8 rounded-lg"
>
<ChevronRight className="h-4 w-4" />
</AppleButton>
</div>
</div>
)}
{/* 底部信息 */}
{total > 0 && (
<div className="flex justify-between items-center pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<span className="text-sm text-gray-600 dark:text-gray-400">
{links.length} / {total}
{links.length}
</span>
{refreshEnabled && (
<span className="text-xs text-gray-500 dark:text-gray-500">

View File

@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useState, useMemo } from "react";
import {
RefreshCw,
User,
@@ -10,7 +10,9 @@ import {
XCircle,
AlertCircle,
Loader2,
Gift
Gift,
ChevronLeft,
ChevronRight
} from "lucide-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/animate-ui/base/tooltip";
import { useGetTaskListApiV1TasksListGet } from "@/lib/api/generated/task-management.gen";
@@ -30,6 +32,8 @@ interface TaskListProps {
export function TaskList({ refreshEnabled = false, refreshInterval = 5000, className }: TaskListProps) {
const [isLocalRefreshing, setIsLocalRefreshing] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 12;
// 获取任务列表
const {
@@ -56,6 +60,25 @@ export function TaskList({ refreshEnabled = false, refreshInterval = 5000, class
const tasks = taskListData?.tasks || [];
// 排序waiting_gift_card 状态的任务优先
const sortedTasks = useMemo(() => {
return [...tasks].sort((a, b) => {
if (a.status === "waiting_gift_card" && b.status !== "waiting_gift_card") {
return -1; // a 排在前面
}
if (a.status !== "waiting_gift_card" && b.status === "waiting_gift_card") {
return 1; // b 排在前面
}
return 0; // 保持原有顺序
});
}, [tasks]);
// 分页逻辑
const totalPages = Math.ceil(sortedTasks.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = startIndex + ITEMS_PER_PAGE;
const paginatedTasks = sortedTasks.slice(startIndex, endIndex);
// 截断错误信息
const truncateErrorMessage = (message: string, maxLength: number = 50) => {
if (message.length <= maxLength) return message;
@@ -179,8 +202,9 @@ export function TaskList({ refreshEnabled = false, refreshInterval = 5000, class
)}
{!isLoading && !error && tasks.length > 0 && (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{tasks.map((task) => (
{paginatedTasks.map((task) => (
<div
key={task.task_id}
className="bg-white dark:bg-gray-800 rounded-xl p-4 border border-gray-200 dark:border-gray-700 hover:shadow-md transition-all duration-200"
@@ -336,6 +360,70 @@ export function TaskList({ refreshEnabled = false, refreshInterval = 5000, class
</div>
))}
</div>
{/* 分页控件 */}
{totalPages > 1 && (
<div className="flex items-center justify-between pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600 dark:text-gray-400">
{currentPage} {totalPages}
</span>
<span className="text-sm text-gray-500 dark:text-gray-500">
({sortedTasks.length} )
</span>
</div>
<div className="flex items-center gap-2">
<AppleButton
variant="outline"
size="icon"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
className="w-8 h-8 rounded-lg"
>
<ChevronLeft className="h-4 w-4" />
</AppleButton>
<div className="flex items-center gap-1">
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
let pageNum;
if (totalPages <= 5) {
pageNum = i + 1;
} else if (currentPage <= 3) {
pageNum = i + 1;
} else if (currentPage >= totalPages - 2) {
pageNum = totalPages - 4 + i;
} else {
pageNum = currentPage - 2 + i;
}
return (
<AppleButton
key={pageNum}
variant={currentPage === pageNum ? "default" : "outline"}
size="sm"
onClick={() => setCurrentPage(pageNum)}
className={`w-8 h-8 rounded-lg ${currentPage === pageNum ? 'apple-glass-button' : ''}`}
>
{pageNum}
</AppleButton>
);
})}
</div>
<AppleButton
variant="outline"
size="icon"
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
disabled={currentPage === totalPages}
className="w-8 h-8 rounded-lg"
>
<ChevronRight className="h-4 w-4" />
</AppleButton>
</div>
</div>
)}
</>
)}
</div>
@@ -343,7 +431,7 @@ export function TaskList({ refreshEnabled = false, refreshInterval = 5000, class
{!isLoading && !error && (
<div className="flex justify-between items-center pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<span className="text-sm text-gray-600 dark:text-gray-400">
{tasks.length}
{paginatedTasks.length} ( {tasks.length} )
</span>
{refreshEnabled && (
<span className="text-xs text-gray-500 dark:text-gray-500">

View File

@@ -3,18 +3,19 @@
import { useEffect, useState } from "react";
import { AppleButton } from "@/components/ui/apple-button";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { Plus, Database } from "lucide-react";
import { Plus, Database, Trash2 } from "lucide-react";
import { toast } from "sonner";
import { Badge } from "../ui/badge";
import { BrushCleaning } from "../animate-ui/icons/brush-cleaning";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/animate-ui/base/tooltip';
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { AlertTriangle } from "lucide-react";
import { useInterval } from "@/lib/hooks/use-timeout";
import { useGetUserDataListApiV1UserDataListGet } from "@/lib/api/generated/user-data-management.gen";
import { useGetUserDataListApiV1UserDataListGet, useBulkDeleteAllUserDataApiV1UserDataAllDelete } from "@/lib/api/generated/user-data-management.gen";
import { FileUploadModal } from "@/components/forms/file-upload-modal";
import { GetUserDataListApiV1UserDataListGetParams } from "@/lib/api/generated/schemas";
@@ -37,11 +38,14 @@ const userDataService = {
export function UploadedDataDisplay({ refreshEnabled = true, refreshInterval = 5000 }: UploadedDataDisplayProps) {
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const { data: userDataList, isLoading, refetch } = userDataService.useUserDataList({
page: 1,
size: 10
});
const bulkDeleteMutation = useBulkDeleteAllUserDataApiV1UserDataAllDelete();
// 获取数据
const fetchUploadedData = async () => {
try {
@@ -73,16 +77,20 @@ export function UploadedDataDisplay({ refreshEnabled = true, refreshInterval = 5
setIsUploadModalOpen(false);
};
// 清空数据
const handleClearData = async () => {
// 处理删除所有数据
const handleDeleteAllData = async () => {
try {
console.log("数据清空功能待实现");
await fetchUploadedData();
} catch {
console.error("清空数据失败");
await bulkDeleteMutation.mutateAsync({});
await refetch();
setIsDeleteDialogOpen(false);
toast.success("所有用户数据已成功删除");
} catch (error) {
console.error("删除所有数据失败:", error);
toast.error("删除数据失败,请稍后重试");
}
};
return (
<>
<div className="apple-glass-card rounded-2xl p-6 transition-all duration-300">
@@ -106,6 +114,47 @@ export function UploadedDataDisplay({ refreshEnabled = true, refreshInterval = 5
{userDataList?.total}
</Badge>
)}
{(userDataList?.total || 0) > 0 && (
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogTrigger asChild>
<AppleButton variant="destructive" size="sm" className="flex items-center gap-2">
<Trash2 className="h-4 w-4" />
</AppleButton>
</DialogTrigger>
<DialogContent className="apple-glass-card max-w-md rounded-2xl shadow-2xl border-0 bg-white/90 dark:bg-gray-900/90 backdrop-blur-xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-5 w-5" />
</DialogTitle>
</DialogHeader>
<div className="py-4">
<p className="text-gray-600 dark:text-gray-300">
</p>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
{userDataList?.total || 0}
</p>
</div>
<DialogFooter className="gap-2">
<AppleButton
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
>
</AppleButton>
<AppleButton
variant="destructive"
onClick={handleDeleteAllData}
disabled={bulkDeleteMutation.isPending}
>
{bulkDeleteMutation.isPending ? "删除中..." : "确认删除"}
</AppleButton>
</DialogFooter>
</DialogContent>
</Dialog>
)}
<Dialog open={isUploadModalOpen} onOpenChange={setIsUploadModalOpen}>
<DialogTrigger asChild>
<AppleButton variant="default" size="sm" className="flex items-center gap-2 apple-glass-button">
@@ -124,27 +173,99 @@ export function UploadedDataDisplay({ refreshEnabled = true, refreshInterval = 5
</div>
</div>
) : userDataList?.items.length ? (
<ScrollArea className="h-[150px] max-h-[200px] w-full rounded-md">
<div className="p-4 space-y-2">
<ScrollArea className="h-[300px] w-full rounded-lg">
<div className="p-4 space-y-4">
{userDataList.items.map((item) => (
<TooltipProvider key={item.id}>
<Tooltip hoverable>
<TooltipTrigger render={<div
key={item.id}
className="flex justify-between items-center p-2 rounded-md bg-card hover:bg-secondary/50 group"
>
<div className="text-sm font-mono truncate max-w-[100%]">
{item.first_name} {item.last_name} {item.street_address} {item.city} {item.state} {item.zip_code} {item.email} {item.phone}
<TooltipTrigger render={
<div className="bg-white dark:bg-gray-800 p-3 rounded-xl border border-gray-200 dark:border-gray-700 hover:shadow-lg transition-all duration-300 hover:scale-[1.01] shadow-sm">
{/* 用户头像和基本信息 */}
<div className="flex items-start gap-2 mb-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm shadow-md flex-shrink-0">
{item.first_name?.[0] || 'U'}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-semibold text-gray-900 dark:text-gray-100 text-sm leading-tight">
{item.first_name} {item.last_name}
</h4>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 font-medium">
ID: {item.id}
</div>
</div>
</div>
{/* 详细信息网格 - 紧凑布局 */}
<div className="space-y-2">
{/* 地址信息 */}
<div className="flex items-start gap-2 p-2 bg-gray-50 dark:bg-gray-700/30 rounded-md">
<div className="w-6 h-6 rounded-full bg-orange-100 dark:bg-orange-900/30 flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-xs">📍</span>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-0.5 uppercase tracking-wide">
</div>
<div className="text-xs text-gray-900 dark:text-gray-100 break-words leading-relaxed">
{item.street_address}, {item.city}, {item.state} {item.zip_code}
</div>
</div>
</div>
{/* 邮箱信息 */}
<div className="flex items-start gap-2 p-2 bg-gray-50 dark:bg-gray-700/30 rounded-md">
<div className="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-xs"></span>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-0.5 uppercase tracking-wide">
</div>
<div className="text-xs text-gray-900 dark:text-gray-100 break-words leading-relaxed">
{item.email}
</div>
</div>
</div>
{/* 电话信息 */}
<div className="flex items-start gap-2 p-2 bg-gray-50 dark:bg-gray-700/30 rounded-md">
<div className="w-6 h-6 rounded-full bg-purple-100 dark:bg-purple-900/30 flex items-center justify-center flex-shrink-0 mt-0.5">
<span className="text-xs">📞</span>
</div>
<div className="flex-1 min-w-0">
<div className="text-xs font-medium text-gray-600 dark:text-gray-300 mb-0.5 uppercase tracking-wide">
</div>
<div className="text-xs text-gray-900 dark:text-gray-100 break-words leading-relaxed">
{item.phone}
</div>
</div>
</div>
</div>
</div>
} />
<TooltipContent className="bg-gray-900 dark:bg-gray-800 text-white border-none max-w-md p-4">
<div className="space-y-2">
<div className="font-semibold text-lg">{item.first_name} {item.last_name}</div>
<div className="space-y-1 text-sm">
<div>📍 {item.street_address}, {item.city}, {item.state} {item.zip_code}</div>
<div> {item.email}</div>
<div>📞 {item.phone}</div>
</div>
</div>
</div>} />
<TooltipContent className="bg-blue-600 dark:bg-blue-700 text-white border-none">
<p>{item.first_name} {item.last_name} {item.street_address} {item.city} {item.state} {item.zip_code} {item.email} {item.phone}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
))}
</div>
<ScrollBar orientation="horizontal" />
<ScrollBar
orientation="vertical"
className="w-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors rounded-full"
/>
<ScrollBar
orientation="horizontal"
className="h-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors rounded-full"
/>
</ScrollArea>
) : (
<div className="text-center py-8 text-muted-foreground">
@@ -152,17 +273,6 @@ export function UploadedDataDisplay({ refreshEnabled = true, refreshInterval = 5
</div>
)}
</div>
{(userDataList?.total || 0) > 0 && (
<div className="flex items-center pt-4 mt-4 border-t border-gray-200 dark:border-gray-700">
<AppleButton
variant="outline"
className="w-full flex items-center gap-2 apple-glass-button"
onClick={handleClearData}
>
<BrushCleaning animateOnTap />
</AppleButton>
</div>
)}
</div>
{/* 文件上传模态框 */}

View File

@@ -22,12 +22,14 @@ import type {
} from "@tanstack/react-query";
import type {
DeleteLinkApiV1LinksLinkIdDelete200,
GetLinksApiV1LinksListGetParams,
HTTPValidationError,
LinkCreate,
LinkInfo,
PaginatedResponseLinkInfo,
ToggleLinkStatusApiV1LinksLinkIdStatusPatchParams,
UpdateLinkWeightApiV1LinksLinkIdWeightPatchParams,
} from "./schemas";
import { axiosClient } from "../enhanced-axios-client";
@@ -476,7 +478,7 @@ const deleteLinkApiV1LinksLinkIdDelete = (
linkId: string,
options?: SecondParameter<typeof axiosClient>,
) => {
return axiosClient<unknown>(
return axiosClient<DeleteLinkApiV1LinksLinkIdDelete200>(
{ url: `/api/v1/links/${linkId}`, method: "DELETE" },
{ second: true, ...options },
);
@@ -563,7 +565,7 @@ const toggleLinkStatusApiV1LinksLinkIdStatusPatch = (
params: ToggleLinkStatusApiV1LinksLinkIdStatusPatchParams,
options?: SecondParameter<typeof axiosClient>,
) => {
return axiosClient<unknown>(
return axiosClient<LinkInfo>(
{ url: `/api/v1/links/${linkId}/status`, method: "PATCH", params },
{ second: true, ...options },
);
@@ -656,3 +658,105 @@ export const useToggleLinkStatusApiV1LinksLinkIdStatusPatch = <
return useMutation(mutationOptions, queryClient);
};
/**
* 更新链接权重
* @summary Update Link Weight
*/
const updateLinkWeightApiV1LinksLinkIdWeightPatch = (
linkId: string,
params: UpdateLinkWeightApiV1LinksLinkIdWeightPatchParams,
options?: SecondParameter<typeof axiosClient>,
) => {
return axiosClient<LinkInfo>(
{ url: `/api/v1/links/${linkId}/weight`, method: "PATCH", params },
{ second: true, ...options },
);
};
export const getUpdateLinkWeightApiV1LinksLinkIdWeightPatchMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateLinkWeightApiV1LinksLinkIdWeightPatch>>,
TError,
{
linkId: string;
params: UpdateLinkWeightApiV1LinksLinkIdWeightPatchParams;
},
TContext
>;
request?: SecondParameter<typeof axiosClient>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateLinkWeightApiV1LinksLinkIdWeightPatch>>,
TError,
{ linkId: string; params: UpdateLinkWeightApiV1LinksLinkIdWeightPatchParams },
TContext
> => {
const mutationKey = ["updateLinkWeightApiV1LinksLinkIdWeightPatch"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateLinkWeightApiV1LinksLinkIdWeightPatch>>,
{
linkId: string;
params: UpdateLinkWeightApiV1LinksLinkIdWeightPatchParams;
}
> = (props) => {
const { linkId, params } = props ?? {};
return updateLinkWeightApiV1LinksLinkIdWeightPatch(
linkId,
params,
requestOptions,
);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateLinkWeightApiV1LinksLinkIdWeightPatchMutationResult =
NonNullable<
Awaited<ReturnType<typeof updateLinkWeightApiV1LinksLinkIdWeightPatch>>
>;
export type UpdateLinkWeightApiV1LinksLinkIdWeightPatchMutationError =
HTTPValidationError;
/**
* @summary Update Link Weight
*/
export const useUpdateLinkWeightApiV1LinksLinkIdWeightPatch = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateLinkWeightApiV1LinksLinkIdWeightPatch>>,
TError,
{
linkId: string;
params: UpdateLinkWeightApiV1LinksLinkIdWeightPatchParams;
},
TContext
>;
request?: SecondParameter<typeof axiosClient>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof updateLinkWeightApiV1LinksLinkIdWeightPatch>>,
TError,
{ linkId: string; params: UpdateLinkWeightApiV1LinksLinkIdWeightPatchParams },
TContext
> => {
const mutationOptions =
getUpdateLinkWeightApiV1LinksLinkIdWeightPatchMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};

View File

@@ -0,0 +1,14 @@
/**
* Generated by orval v7.11.2 🍺
* Do not edit manually.
* Apple Gift Card Exchange
* Apple礼品卡兑换服务后端API - 基于FastAPI的现代异步微服务架构
* OpenAPI spec version: 2.0.0
*/
export type BulkDeleteAllUserDataApiV1UserDataAllDeleteParams = {
/**
* 是否跳过有关联订单的用户数据
*/
skip_orders?: boolean;
};

View File

@@ -0,0 +1,21 @@
/**
* Generated by orval v7.11.2 🍺
* Do not edit manually.
* Apple Gift Card Exchange
* Apple礼品卡兑换服务后端API - 基于FastAPI的现代异步微服务架构
* OpenAPI spec version: 2.0.0
*/
/**
* 批量删除用户数据响应模型
*/
export interface BulkDeleteUserDataResponse {
/** 总用户数 */
total_users: number;
/** 已删除用户数 */
deleted_users: number;
/** 跳过的用户数 */
skipped_users: number;
/** 响应消息 */
message: string;
}

View File

@@ -0,0 +1,9 @@
/**
* Generated by orval v7.11.2 🍺
* Do not edit manually.
* Apple Gift Card Exchange
* Apple礼品卡兑换服务后端API - 基于FastAPI的现代异步微服务架构
* OpenAPI spec version: 2.0.0
*/
export type DeleteLinkApiV1LinksLinkIdDelete200 = { [key: string]: string };

View File

@@ -6,10 +6,13 @@
* OpenAPI spec version: 2.0.0
*/
export * from "./bulkDeleteAllUserDataApiV1UserDataAllDeleteParams";
export * from "./bulkDeleteUserDataResponse";
export * from "./cardInfo";
export * from "./cardInfoFailureReason";
export * from "./deleteAllDataResponse";
export * from "./deleteAllDataResponseDeletedTables";
export * from "./deleteLinkApiV1LinksLinkIdDelete200";
export * from "./exportOrdersApiV1OrdersExportGetParams";
export * from "./getLinksApiV1LinksListGetParams";
export * from "./getOrdersApiV1OrdersListGetParams";
@@ -53,6 +56,7 @@ export * from "./taskListItemWorkerId";
export * from "./taskListResponse";
export * from "./taskStateResponse";
export * from "./toggleLinkStatusApiV1LinksLinkIdStatusPatchParams";
export * from "./updateLinkWeightApiV1LinksLinkIdWeightPatchParams";
export * from "./userDataCreate";
export * from "./userDataResponse";
export * from "./userDataUploadResponse";

View File

@@ -20,6 +20,12 @@ export interface LinkCreate {
* 金额
*/
amount: number;
/**
* 权重(1-100)
* @minimum 1
* @maximum 100
*/
weight?: number;
/** 链接状态 */
status?: LinkStatus;
}

View File

@@ -17,6 +17,8 @@ export interface LinkInfo {
url: string;
/** 金额 */
amount: number;
/** 权重(1-100) */
weight: number;
/** 链接状态 */
status: LinkStatus;
/** 创建时间 */

View File

@@ -0,0 +1,16 @@
/**
* Generated by orval v7.11.2 🍺
* Do not edit manually.
* Apple Gift Card Exchange
* Apple礼品卡兑换服务后端API - 基于FastAPI的现代异步微服务架构
* OpenAPI spec version: 2.0.0
*/
export type UpdateLinkWeightApiV1LinksLinkIdWeightPatchParams = {
/**
* 权重值(1-100)
* @minimum 1
* @maximum 100
*/
weight: number;
};

View File

@@ -22,6 +22,8 @@ import type {
} from "@tanstack/react-query";
import type {
BulkDeleteAllUserDataApiV1UserDataAllDeleteParams,
BulkDeleteUserDataResponse,
GetUserDataListApiV1UserDataListGetParams,
HTTPValidationError,
PaginatedResponseUserDataResponse,
@@ -906,3 +908,95 @@ export const useBatchUploadUserDataApiV1UserDataBatchUploadPost = <
return useMutation(mutationOptions, queryClient);
};
/**
* 批量软删除所有用户数据
- **skip_orders**: 是否跳过有关联订单的用户数据默认false会删除包括有关联订单的所有数据
返回删除统计信息,包括总用户数、删除用户数和跳过用户数
* @summary 批量删除所有用户数据
*/
const bulkDeleteAllUserDataApiV1UserDataAllDelete = (
params?: BulkDeleteAllUserDataApiV1UserDataAllDeleteParams,
options?: SecondParameter<typeof axiosClient>,
) => {
return axiosClient<BulkDeleteUserDataResponse>(
{ url: `/api/v1/user-data/all`, method: "DELETE", params },
{ second: true, ...options },
);
};
export const getBulkDeleteAllUserDataApiV1UserDataAllDeleteMutationOptions = <
TError = HTTPValidationError,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof bulkDeleteAllUserDataApiV1UserDataAllDelete>>,
TError,
{ params?: BulkDeleteAllUserDataApiV1UserDataAllDeleteParams },
TContext
>;
request?: SecondParameter<typeof axiosClient>;
}): UseMutationOptions<
Awaited<ReturnType<typeof bulkDeleteAllUserDataApiV1UserDataAllDelete>>,
TError,
{ params?: BulkDeleteAllUserDataApiV1UserDataAllDeleteParams },
TContext
> => {
const mutationKey = ["bulkDeleteAllUserDataApiV1UserDataAllDelete"];
const { mutation: mutationOptions, request: requestOptions } = options
? options.mutation &&
"mutationKey" in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey }, request: undefined };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof bulkDeleteAllUserDataApiV1UserDataAllDelete>>,
{ params?: BulkDeleteAllUserDataApiV1UserDataAllDeleteParams }
> = (props) => {
const { params } = props ?? {};
return bulkDeleteAllUserDataApiV1UserDataAllDelete(params, requestOptions);
};
return { mutationFn, ...mutationOptions };
};
export type BulkDeleteAllUserDataApiV1UserDataAllDeleteMutationResult =
NonNullable<
Awaited<ReturnType<typeof bulkDeleteAllUserDataApiV1UserDataAllDelete>>
>;
export type BulkDeleteAllUserDataApiV1UserDataAllDeleteMutationError =
HTTPValidationError;
/**
* @summary 批量删除所有用户数据
*/
export const useBulkDeleteAllUserDataApiV1UserDataAllDelete = <
TError = HTTPValidationError,
TContext = unknown,
>(
options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof bulkDeleteAllUserDataApiV1UserDataAllDelete>>,
TError,
{ params?: BulkDeleteAllUserDataApiV1UserDataAllDeleteParams },
TContext
>;
request?: SecondParameter<typeof axiosClient>;
},
queryClient?: QueryClient,
): UseMutationResult<
Awaited<ReturnType<typeof bulkDeleteAllUserDataApiV1UserDataAllDelete>>,
TError,
{ params?: BulkDeleteAllUserDataApiV1UserDataAllDeleteParams },
TContext
> => {
const mutationOptions =
getBulkDeleteAllUserDataApiV1UserDataAllDeleteMutationOptions(options);
return useMutation(mutationOptions, queryClient);
};