mirror of
https://github.com/Cccc-owo/CheckInApp.git
synced 2026-06-17 14:06:28 +00:00
refactor: v2
backend & frontend
This commit is contained in:
@@ -0,0 +1,71 @@
|
||||
from backend.schemas.user import (
|
||||
UserBase,
|
||||
UserCreate,
|
||||
UserUpdate,
|
||||
UserResponse,
|
||||
UserWithToken,
|
||||
TokenStatus,
|
||||
)
|
||||
from backend.schemas.auth import (
|
||||
QRCodeRequest,
|
||||
QRCodeResponse,
|
||||
QRCodeStatusResponse,
|
||||
TokenVerifyRequest,
|
||||
TokenVerifyResponse,
|
||||
)
|
||||
from backend.schemas.check_in import (
|
||||
ManualCheckInRequest,
|
||||
BatchCheckInRequest,
|
||||
CheckInRecordResponse,
|
||||
CheckInRecordWithTaskInfo,
|
||||
CheckInResultResponse,
|
||||
)
|
||||
from backend.schemas.task import (
|
||||
TaskBase,
|
||||
TaskCreate,
|
||||
TaskUpdate,
|
||||
TaskResponse,
|
||||
)
|
||||
from backend.schemas.template import (
|
||||
FieldOption,
|
||||
FieldConfigItem,
|
||||
FieldConfig,
|
||||
TemplateBase,
|
||||
TemplateCreate,
|
||||
TemplateUpdate,
|
||||
TemplateResponse,
|
||||
TaskFromTemplateRequest,
|
||||
TemplatePreviewResponse,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"UserBase",
|
||||
"UserCreate",
|
||||
"UserUpdate",
|
||||
"UserResponse",
|
||||
"UserWithToken",
|
||||
"TokenStatus",
|
||||
"QRCodeRequest",
|
||||
"QRCodeResponse",
|
||||
"QRCodeStatusResponse",
|
||||
"TokenVerifyRequest",
|
||||
"TokenVerifyResponse",
|
||||
"ManualCheckInRequest",
|
||||
"BatchCheckInRequest",
|
||||
"CheckInRecordResponse",
|
||||
"CheckInRecordWithTaskInfo",
|
||||
"CheckInResultResponse",
|
||||
"TaskBase",
|
||||
"TaskCreate",
|
||||
"TaskUpdate",
|
||||
"TaskResponse",
|
||||
"FieldOption",
|
||||
"FieldConfigItem",
|
||||
"FieldConfig",
|
||||
"TemplateBase",
|
||||
"TemplateCreate",
|
||||
"TemplateUpdate",
|
||||
"TemplateResponse",
|
||||
"TaskFromTemplateRequest",
|
||||
"TemplatePreviewResponse",
|
||||
]
|
||||
@@ -0,0 +1,49 @@
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class QRCodeRequest(BaseModel):
|
||||
"""请求二维码 Schema"""
|
||||
alias: str = Field(..., description="用户别名")
|
||||
|
||||
|
||||
class QRCodeResponse(BaseModel):
|
||||
"""二维码响应 Schema"""
|
||||
session_id: str = Field(..., description="会话 ID")
|
||||
qrcode_image: str = Field(..., description="二维码 Base64 图片")
|
||||
|
||||
|
||||
class QRCodeStatusResponse(BaseModel):
|
||||
"""二维码状态响应 Schema"""
|
||||
status: str = Field(..., description="状态: pending/waiting_scan/success/error")
|
||||
message: Optional[str] = Field(None, description="状态消息")
|
||||
user_id: Optional[int] = Field(None, description="用户 ID (扫码成功时返回)")
|
||||
authorization: Optional[str] = Field(None, description="Token (扫码成功时返回)")
|
||||
qrcode_image: Optional[str] = Field(None, description="二维码 Base64 图片(等待扫描时返回)")
|
||||
|
||||
|
||||
class TokenVerifyRequest(BaseModel):
|
||||
"""Token 验证请求 Schema"""
|
||||
authorization: str = Field(..., description="Token")
|
||||
|
||||
|
||||
class TokenVerifyResponse(BaseModel):
|
||||
"""Token 验证响应 Schema"""
|
||||
is_valid: bool = Field(..., description="Token 是否有效")
|
||||
message: str = Field(..., description="验证消息")
|
||||
user_id: Optional[int] = Field(None, description="用户 ID")
|
||||
|
||||
|
||||
class AliasLoginRequest(BaseModel):
|
||||
"""别名+密码登录请求 Schema"""
|
||||
alias: str = Field(..., min_length=2, max_length=50, description="用户别名")
|
||||
password: str = Field(..., min_length=6, description="密码")
|
||||
|
||||
|
||||
class AliasLoginResponse(BaseModel):
|
||||
"""别名+密码登录响应 Schema"""
|
||||
success: bool = Field(..., description="登录是否成功")
|
||||
message: str = Field(..., description="登录消息")
|
||||
user_id: Optional[int] = Field(None, description="用户 ID")
|
||||
authorization: Optional[str] = Field(None, description="Token")
|
||||
alias: Optional[str] = Field(None, description="用户别名")
|
||||
@@ -0,0 +1,48 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
||||
|
||||
class ManualCheckInRequest(BaseModel):
|
||||
"""手动打卡请求 Schema(已废弃,现在使用路径参数 task_id)"""
|
||||
task_id: Optional[int] = Field(None, description="任务 ID")
|
||||
|
||||
|
||||
class BatchCheckInRequest(BaseModel):
|
||||
"""批量打卡请求 Schema"""
|
||||
task_ids: list[int] = Field(..., description="任务 ID 列表")
|
||||
|
||||
|
||||
class CheckInRecordResponse(BaseModel):
|
||||
"""打卡记录响应 Schema"""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
task_id: int
|
||||
status: str
|
||||
response_text: str
|
||||
error_message: str
|
||||
location: str
|
||||
trigger_type: str
|
||||
check_in_time: datetime # Pydantic v2 自动序列化为 ISO 8601 格式
|
||||
|
||||
# 新增字段:用户和任务信息(用于管理员查看)
|
||||
user_id: Optional[int] = Field(None, description="用户 ID")
|
||||
user_email: Optional[str] = Field(None, description="用户邮箱")
|
||||
task_name: Optional[str] = Field(None, description="任务名称")
|
||||
thread_id: Optional[str] = Field(None, description="接龙 ID")
|
||||
|
||||
|
||||
class CheckInRecordWithTaskInfo(CheckInRecordResponse):
|
||||
"""带任务信息的打卡记录响应 Schema"""
|
||||
task_name: str
|
||||
task_signature: str
|
||||
user_alias: str
|
||||
|
||||
|
||||
class CheckInResultResponse(BaseModel):
|
||||
"""打卡结果响应 Schema"""
|
||||
success: bool
|
||||
message: str
|
||||
record_id: Optional[int] = None
|
||||
error: Optional[str] = None
|
||||
@@ -0,0 +1,153 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import json
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class TaskBase(BaseModel):
|
||||
"""打卡任务基础 Schema"""
|
||||
payload_config: str = Field(..., description="完整的 payload 配置 JSON(包含 ThreadId 和所有字段)")
|
||||
name: Optional[str] = Field("", max_length=100, description="任务名称(用户自定义)")
|
||||
is_active: Optional[bool] = Field(True, description="是否启用自动打卡")
|
||||
|
||||
@field_validator('payload_config')
|
||||
@classmethod
|
||||
def validate_payload_config(cls, v: str) -> str:
|
||||
"""
|
||||
验证 payload_config 是否为有效的 JSON,并且包含必需的 ThreadId 字段
|
||||
"""
|
||||
if not v or not v.strip():
|
||||
raise ValueError("payload_config 不能为空")
|
||||
|
||||
try:
|
||||
payload = json.loads(v)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"payload_config 必须是有效的 JSON 格式: {str(e)}")
|
||||
|
||||
# 检查是否为字典类型
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload_config 必须是 JSON 对象(字典)")
|
||||
|
||||
# 检查必需字段 ThreadId
|
||||
if 'ThreadId' not in payload:
|
||||
raise ValueError("payload_config 必须包含 ThreadId 字段")
|
||||
|
||||
thread_id = payload.get('ThreadId')
|
||||
if not thread_id or not str(thread_id).strip():
|
||||
raise ValueError("ThreadId 不能为空")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class TaskCreate(TaskBase):
|
||||
"""创建打卡任务 Schema"""
|
||||
cron_expression: Optional[str] = Field(
|
||||
None,
|
||||
max_length=100,
|
||||
description="Crontab 表达式(例如 '0 20 * * *' 表示每天 20:00)。NULL 表示禁用定时打卡"
|
||||
)
|
||||
|
||||
@field_validator('cron_expression')
|
||||
@classmethod
|
||||
def validate_cron_expression(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""验证 Crontab 表达式格式"""
|
||||
if v is None:
|
||||
return v # NULL 允许(表示禁用定时打卡)
|
||||
|
||||
if not v.strip():
|
||||
raise ValueError("cron_expression 不能为空字符串,应该使用 NULL")
|
||||
|
||||
try:
|
||||
from croniter import croniter
|
||||
if not croniter.is_valid(v):
|
||||
raise ValueError(f"无效的 Crontab 表达式: '{v}'")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Crontab 表达式验证失败: {str(e)}")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class TaskUpdate(BaseModel):
|
||||
"""更新打卡任务 Schema"""
|
||||
payload_config: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
is_active: Optional[bool] = None
|
||||
cron_expression: Optional[str] = Field(
|
||||
None,
|
||||
max_length=100,
|
||||
description="Crontab 表达式。NULL 表示禁用定时打卡"
|
||||
)
|
||||
|
||||
@field_validator('payload_config')
|
||||
@classmethod
|
||||
def validate_payload_config(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""
|
||||
验证 payload_config 是否为有效的 JSON(如果提供的话)
|
||||
"""
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
if not v.strip():
|
||||
raise ValueError("payload_config 不能为空字符串")
|
||||
|
||||
try:
|
||||
payload = json.loads(v)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"payload_config 必须是有效的 JSON 格式: {str(e)}")
|
||||
|
||||
# 检查是否为字典类型
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("payload_config 必须是 JSON 对象(字典)")
|
||||
|
||||
# 检查必需字段 ThreadId
|
||||
if 'ThreadId' not in payload:
|
||||
raise ValueError("payload_config 必须包含 ThreadId 字段")
|
||||
|
||||
thread_id = payload.get('ThreadId')
|
||||
if not thread_id or not str(thread_id).strip():
|
||||
raise ValueError("ThreadId 不能为空")
|
||||
|
||||
return v
|
||||
|
||||
@field_validator('cron_expression')
|
||||
@classmethod
|
||||
def validate_cron_expression(cls, v: Optional[str]) -> Optional[str]:
|
||||
"""验证 Crontab 表达式(与 TaskCreate 相同)"""
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
if not v.strip():
|
||||
raise ValueError("cron_expression 不能为空字符串,应该使用 NULL")
|
||||
|
||||
try:
|
||||
from croniter import croniter
|
||||
if not croniter.is_valid(v):
|
||||
raise ValueError(f"无效的 Crontab 表达式: '{v}'")
|
||||
except Exception as e:
|
||||
raise ValueError(f"Crontab 表达式验证失败: {str(e)}")
|
||||
|
||||
return v
|
||||
|
||||
|
||||
class TaskResponse(TaskBase):
|
||||
"""打卡任务响应 Schema"""
|
||||
id: int
|
||||
user_id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
cron_expression: Optional[str] = Field(
|
||||
None,
|
||||
description="当前 Crontab 表达式(NULL = 禁用定时打卡)"
|
||||
)
|
||||
is_scheduled_enabled: Optional[bool] = Field(
|
||||
None,
|
||||
description="是否启用了定时打卡"
|
||||
)
|
||||
|
||||
# 新增字段:最后一次打卡信息
|
||||
last_check_in_time: Optional[datetime] = Field(None, description="最后一次打卡时间")
|
||||
last_check_in_status: Optional[str] = Field(None, description="最后一次打卡状态")
|
||||
thread_id: Optional[str] = Field(None, description="接龙 ID(从 payload_config 中提取)")
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -0,0 +1,147 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, List, Union
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
import json
|
||||
|
||||
|
||||
class FieldOption(BaseModel):
|
||||
"""字段选项(用于 select 类型)"""
|
||||
label: str = Field(..., description="选项显示文本")
|
||||
value: str = Field(..., description="选项值")
|
||||
|
||||
|
||||
class FieldConfigItem(BaseModel):
|
||||
"""单个字段配置项"""
|
||||
display_name: str = Field(..., description="字段显示名称")
|
||||
field_type: str = Field(..., description="字段输入类型:text, textarea, number, select")
|
||||
default_value: str = Field(default="", description="默认值")
|
||||
required: bool = Field(default=True, description="是否必填")
|
||||
hidden: bool = Field(default=False, description="是否隐藏(直接使用默认值)")
|
||||
placeholder: Optional[str] = Field(None, description="输入提示")
|
||||
value_type: str = Field(default="string", description="值类型:string, int, double")
|
||||
options: Optional[List[FieldOption]] = Field(None, description="选项列表(仅 select 类型)")
|
||||
|
||||
@field_validator('field_type')
|
||||
@classmethod
|
||||
def validate_field_type(cls, v):
|
||||
allowed_types = ['text', 'textarea', 'number', 'select']
|
||||
if v not in allowed_types:
|
||||
raise ValueError(f'field_type must be one of {allowed_types}')
|
||||
return v
|
||||
|
||||
@field_validator('value_type')
|
||||
@classmethod
|
||||
def validate_value_type(cls, v):
|
||||
allowed_types = ['string', 'int', 'double']
|
||||
if v not in allowed_types:
|
||||
raise ValueError(f'value_type must be one of {allowed_types}')
|
||||
return v
|
||||
|
||||
|
||||
class FieldConfigValues(BaseModel):
|
||||
"""Values 字段的嵌套配置(如 location, temperature 等)"""
|
||||
pass
|
||||
|
||||
class Config:
|
||||
extra = 'allow' # 允许任意字段
|
||||
|
||||
|
||||
class FieldConfig(BaseModel):
|
||||
"""完整的字段配置"""
|
||||
signature: Optional[FieldConfigItem] = None
|
||||
texts: Optional[FieldConfigItem] = None
|
||||
values: Optional[Dict[str, FieldConfigItem]] = Field(None, description="Values 字段的嵌套配置")
|
||||
|
||||
|
||||
class TemplateBase(BaseModel):
|
||||
"""模板基础 Schema"""
|
||||
name: str = Field(..., min_length=1, max_length=100, description="模板名称")
|
||||
description: Optional[str] = Field(None, description="模板描述")
|
||||
parent_id: Optional[int] = Field(None, description="父模板 ID(用于继承)")
|
||||
field_config: Union[str, FieldConfig] = Field(..., description="字段配置(JSON 字符串或对象)")
|
||||
is_active: bool = Field(default=True, description="是否启用")
|
||||
|
||||
@field_validator('field_config')
|
||||
@classmethod
|
||||
def validate_field_config(cls, v):
|
||||
"""验证并转换 field_config"""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
# 尝试解析 JSON 字符串
|
||||
config_dict = json.loads(v)
|
||||
return json.dumps(config_dict) # 返回格式化的 JSON 字符串
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError('field_config must be valid JSON string')
|
||||
elif isinstance(v, dict):
|
||||
# 如果是字典,转换为 JSON 字符串
|
||||
return json.dumps(v)
|
||||
elif isinstance(v, FieldConfig):
|
||||
# 如果是 FieldConfig 对象,转换为 JSON 字符串
|
||||
return v.model_dump_json(exclude_none=True)
|
||||
else:
|
||||
raise ValueError('field_config must be JSON string, dict, or FieldConfig object')
|
||||
|
||||
|
||||
class TemplateCreate(TemplateBase):
|
||||
"""创建模板 Schema"""
|
||||
pass
|
||||
|
||||
|
||||
class TemplateUpdate(BaseModel):
|
||||
"""更新模板 Schema"""
|
||||
name: Optional[str] = Field(None, min_length=1, max_length=100, description="模板名称")
|
||||
description: Optional[str] = Field(None, description="模板描述")
|
||||
parent_id: Optional[int] = Field(None, description="父模板 ID(用于继承)")
|
||||
field_config: Optional[Union[str, FieldConfig]] = Field(None, description="字段配置(JSON 字符串或对象)")
|
||||
is_active: Optional[bool] = Field(None, description="是否启用")
|
||||
|
||||
@field_validator('field_config')
|
||||
@classmethod
|
||||
def validate_field_config(cls, v):
|
||||
"""验证并转换 field_config"""
|
||||
if v is None:
|
||||
return v
|
||||
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
config_dict = json.loads(v)
|
||||
return json.dumps(config_dict)
|
||||
except json.JSONDecodeError:
|
||||
raise ValueError('field_config must be valid JSON string')
|
||||
elif isinstance(v, dict):
|
||||
return json.dumps(v)
|
||||
elif isinstance(v, FieldConfig):
|
||||
return v.model_dump_json(exclude_none=True)
|
||||
else:
|
||||
raise ValueError('field_config must be JSON string, dict, or FieldConfig object')
|
||||
|
||||
|
||||
class TemplateResponse(BaseModel):
|
||||
"""模板响应 Schema"""
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
parent_id: Optional[int]
|
||||
field_config: str # JSON 字符串
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class TaskFromTemplateRequest(BaseModel):
|
||||
"""从模板创建任务的请求 Schema"""
|
||||
template_id: int = Field(..., description="模板 ID")
|
||||
thread_id: str = Field(..., min_length=1, description="接龙项目 ID")
|
||||
field_values: Dict[str, Any] = Field(default_factory=dict, description="用户填写的字段值")
|
||||
task_name: Optional[str] = Field(None, max_length=100, description="任务名称(可选)")
|
||||
|
||||
|
||||
class TemplatePreviewResponse(BaseModel):
|
||||
"""模板预览响应 Schema"""
|
||||
template_id: int
|
||||
template_name: str
|
||||
preview_payload: Dict[str, Any] = Field(..., description="预览生成的 payload(使用默认值)")
|
||||
field_config: Dict[str, Any] = Field(..., description="字段配置(用于前端渲染表单)")
|
||||
@@ -0,0 +1,64 @@
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class UserBase(BaseModel):
|
||||
"""用户基础 Schema"""
|
||||
alias: str = Field(..., min_length=2, max_length=50, description="用户别名(用于登录)")
|
||||
|
||||
|
||||
class UserCreate(UserBase):
|
||||
"""创建用户 Schema(管理员手动创建,只需要别名)"""
|
||||
role: Optional[str] = Field("user", description="角色: user/admin")
|
||||
email: Optional[str] = Field(None, description="邮箱地址")
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
"""更新用户 Schema(管理员编辑用户)"""
|
||||
alias: Optional[str] = Field(None, min_length=2, max_length=50, description="用户别名")
|
||||
role: Optional[str] = None
|
||||
is_approved: Optional[bool] = None
|
||||
email: Optional[str] = None
|
||||
password: Optional[str] = Field(None, min_length=6, description="新密码(可选,留空表示不修改)")
|
||||
reset_password: Optional[bool] = Field(False, description="是否清空密码")
|
||||
|
||||
|
||||
class UserUpdateProfile(BaseModel):
|
||||
"""用户更新个人信息 Schema"""
|
||||
alias: Optional[str] = Field(None, min_length=2, max_length=50, description="新别名")
|
||||
email: Optional[str] = Field(None, description="邮箱地址")
|
||||
current_password: Optional[str] = Field(None, min_length=6, description="当前密码(修改密码时必填)")
|
||||
new_password: Optional[str] = Field(None, min_length=6, description="新密码")
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
"""用户响应 Schema"""
|
||||
id: int
|
||||
alias: str
|
||||
jwt_sub: str
|
||||
role: str
|
||||
is_approved: bool
|
||||
jwt_exp: str
|
||||
email: Optional[str] = None
|
||||
has_password: bool = False # 是否已设置密码
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class UserWithToken(UserResponse):
|
||||
"""带 Token 的用户响应 Schema"""
|
||||
authorization: Optional[str] = None
|
||||
|
||||
|
||||
class TokenStatus(BaseModel):
|
||||
"""Token 状态 Schema"""
|
||||
is_valid: bool
|
||||
jwt_exp: str
|
||||
jwt_sub: str
|
||||
expires_at: Optional[int] = None # Unix 时间戳(秒)
|
||||
days_until_expiry: Optional[int] = None
|
||||
expiring_soon: bool = False # 是否即将过期(30分钟内)
|
||||
Reference in New Issue
Block a user