Compare commits

...

4 Commits

Author SHA1 Message Date
Your Name
8ab2d869fc feat: 阿里云 OSS 迁移至腾讯云 COS + 完善部署配置
COS 迁移:
- 后端签名服务改为 COS HMAC-SHA1 表单直传签名
- config.py: OSS_* 配置项替换为 COS_SECRET_ID/KEY/REGION/BUCKET_NAME/CDN_DOMAIN
- upload.py: UploadPolicyResponse 改为 COS 字段
- 前端 useOSSUpload hook: FormData 字段改为 COS 格式
- 前端 api.ts: UploadPolicyResponse 类型对齐

部署配置:
- docker-compose.yml: 新增 Nginx + 前端容器,数据卷宿主机持久化
- Nginx: HTTPS + HTTP/2 + SSE 长连接 + API/前端反向代理
- backup.sh: PostgreSQL 每日备份 → 本地 + COS
- .env.example: 更新为 COS 配置模板

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:28:13 +08:00
Your Name
f02b3f4098 feat: 前端对接 Profile/Messages/Settings 页面 API
- 3 消息页 + 2 资料编辑页 + 3 设置页 + 2 资料展示页
- api.ts 新增 Profile/Messages/ChangePassword 等类型和方法
- SSEContext 事件映射修复 + 断线重连修复
- 剩余页面加 USE_MOCK 双模式,52/55 页面已完成

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:27:59 +08:00
Your Name
a76c302d7a feat: 添加种子数据和一键初始化脚本
- demo 账号: brand/agency/creator@demo.com
- 组织关系 + 项目/Brief + 4种阶段任务 + 规则数据 + 示例消息
- entrypoint.sh (Docker) + init_db.sh (手动) + start-dev.sh 更新

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:27:47 +08:00
Your Name
ea807974cf feat: 添加 Profile/Messages API 及 SSE 推送集成
- Profile API: GET/PUT /profile + PUT /profile/password
- Messages API: 模型/迁移(005)/服务/路由 + 任务操作自动创建消息
- SSE 推送集成: tasks.py 中 6 个操作触发 SSE 通知
- Alembic 迁移: 004 audit_logs + 005 messages
- env.py 导入所有模型确保迁移正确

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 10:27:37 +08:00
44 changed files with 2047 additions and 171 deletions

View File

@ -106,8 +106,9 @@ useEffect(() => { loadData() }, [loadData])
- 配置项:`AI_PROVIDER`, `AI_API_KEY`, `AI_API_BASE_URL`
### 文件上传
- 阿里云 OSS 直传,前端通过 `useOSSUpload` hook 处理
- 流程:`api.getUploadPolicy()` → POST 到 OSS → `api.fileUploaded()` 回调
- 腾讯云 COS 直传,前端通过 `useOSSUpload` hook 处理
- 流程:`api.getUploadPolicy()` → POST 到 COS → `api.fileUploaded()` 回调
- COS 签名HMAC-SHA1字段包括 `q-sign-algorithm``q-ak``q-key-time``q-signature``policy`
### 实时推送
- SSE (Server-Sent Events),端点 `/api/v1/sse/events`

View File

@ -8,12 +8,16 @@
APP_NAME=秒思智能审核平台
APP_VERSION=1.0.0
DEBUG=false
ENVIRONMENT=production
# --- 数据库 ---
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/miaosi
POSTGRES_USER=miaosi
POSTGRES_PASSWORD=change-me-in-production
POSTGRES_DB=miaosi
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
# --- Redis ---
REDIS_URL=redis://localhost:6379/0
REDIS_URL=redis://redis:6379/0
# --- JWT ---
# 生产环境务必更换为随机密钥: python -c "import secrets; print(secrets.token_urlsafe(64))"
@ -26,12 +30,20 @@ AI_PROVIDER=oneapi
AI_API_KEY=
AI_API_BASE_URL=
# --- 阿里云 OSS ---
OSS_ACCESS_KEY_ID=
OSS_ACCESS_KEY_SECRET=
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
OSS_BUCKET_NAME=miaosi-files
OSS_BUCKET_DOMAIN=
# --- 腾讯云 COS ---
COS_SECRET_ID=
COS_SECRET_KEY=
COS_REGION=ap-guangzhou
COS_BUCKET_NAME=miaosi-files-1250000000
COS_CDN_DOMAIN=
# --- 邮件 SMTP ---
SMTP_HOST=
SMTP_PORT=465
SMTP_USER=
SMTP_PASSWORD=
SMTP_FROM_NAME=秒思智能审核平台
SMTP_USE_SSL=true
# --- 加密密钥 ---
# 用于加密存储 API 密钥等敏感数据

View File

@ -40,6 +40,7 @@ COPY app/ ./app/
COPY alembic/ ./alembic/
COPY alembic.ini .
COPY pyproject.toml .
COPY scripts/ ./scripts/
# 创建非 root 用户
RUN groupadd -r miaosi && useradd -r -g miaosi -d /app -s /sbin/nologin miaosi \
@ -53,4 +54,5 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:8000/health || exit 1
ENTRYPOINT ["./scripts/entrypoint.sh"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -14,14 +14,8 @@ from alembic import context
# 导入配置和模型
from app.config import settings
from app.models.base import Base
from app.models import (
Tenant,
AIConfig,
ReviewTask,
ForbiddenWord,
WhitelistItem,
Competitor,
)
# 导入所有模型,确保 autogenerate 能检测到全部表
from app.models import * # noqa: F401,F403
# Alembic Config 对象
config = context.config

View File

@ -0,0 +1,37 @@
"""添加审计日志表
Revision ID: 004
Revises: 003
Create Date: 2026-02-09
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '004'
down_revision: Union[str, None] = '003'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'audit_logs',
sa.Column('id', sa.Integer(), primary_key=True, autoincrement=True),
sa.Column('action', sa.String(50), nullable=False, index=True),
sa.Column('resource_type', sa.String(50), nullable=False, index=True),
sa.Column('resource_id', sa.String(64), nullable=True, index=True),
sa.Column('user_id', sa.String(64), nullable=True, index=True),
sa.Column('user_name', sa.String(255), nullable=True),
sa.Column('user_role', sa.String(20), nullable=True),
sa.Column('detail', sa.Text(), nullable=True),
sa.Column('ip_address', sa.String(45), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True),
)
def downgrade() -> None:
op.drop_table('audit_logs')

View File

@ -0,0 +1,42 @@
"""添加消息表
Revision ID: 005
Revises: 004
Create Date: 2026-02-09
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '005'
down_revision: Union[str, None] = '004'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'messages',
sa.Column('id', sa.String(64), primary_key=True),
sa.Column('user_id', sa.String(64), sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=False),
sa.Column('type', sa.String(50), nullable=False),
sa.Column('title', sa.String(255), nullable=False),
sa.Column('content', sa.Text(), nullable=False),
sa.Column('is_read', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('related_task_id', sa.String(64), nullable=True),
sa.Column('related_project_id', sa.String(64), nullable=True),
sa.Column('sender_name', sa.String(100), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index('idx_messages_user_id', 'messages', ['user_id'])
op.create_index('idx_messages_user_read', 'messages', ['user_id', 'is_read'])
def downgrade() -> None:
op.drop_index('idx_messages_user_read', table_name='messages')
op.drop_index('idx_messages_user_id', table_name='messages')
op.drop_table('messages')

View File

@ -0,0 +1,97 @@
"""
消息/通知 API
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models.user import User
from app.api.deps import get_current_user
from app.schemas.message import MessageResponse, MessageListResponse, UnreadCountResponse
from app.services.message_service import (
list_messages,
get_unread_count,
mark_as_read,
mark_all_as_read,
)
router = APIRouter(prefix="/messages", tags=["消息"])
@router.get("", response_model=MessageListResponse)
async def get_messages(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
is_read: Optional[bool] = Query(None),
type: Optional[str] = Query(None),
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取消息列表"""
messages, total = await list_messages(
db=db,
user_id=current_user.id,
page=page,
page_size=page_size,
is_read=is_read,
type=type,
)
return MessageListResponse(
items=[
MessageResponse(
id=m.id,
type=m.type,
title=m.title,
content=m.content,
is_read=m.is_read,
related_task_id=m.related_task_id,
related_project_id=m.related_project_id,
sender_name=m.sender_name,
created_at=m.created_at,
)
for m in messages
],
total=total,
page=page,
page_size=page_size,
)
@router.get("/unread-count", response_model=UnreadCountResponse)
async def get_message_unread_count(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取未读消息数"""
count = await get_unread_count(db, current_user.id)
return UnreadCountResponse(count=count)
@router.put("/{message_id}/read")
async def mark_message_as_read(
message_id: str,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""标记消息已读"""
success = await mark_as_read(db, message_id, current_user.id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="消息不存在",
)
await db.commit()
return {"message": "已标记为已读"}
@router.put("/read-all")
async def mark_all_messages_as_read(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""标记所有消息已读"""
count = await mark_all_as_read(db, current_user.id)
await db.commit()
return {"message": f"已标记 {count} 条消息为已读", "count": count}

173
backend/app/api/profile.py Normal file
View File

@ -0,0 +1,173 @@
"""
用户资料 API
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.database import get_db
from app.models.user import User, UserRole
from app.models.organization import Brand, Agency, Creator
from app.api.deps import get_current_user
from app.services.auth import verify_password, hash_password
from app.schemas.profile import (
ProfileResponse,
ProfileUpdateRequest,
ChangePasswordRequest,
BrandProfile,
AgencyProfile,
CreatorProfile,
)
router = APIRouter(prefix="/profile", tags=["用户资料"])
def _build_profile_response(user: User, brand=None, agency=None, creator=None) -> ProfileResponse:
"""构建资料响应"""
resp = ProfileResponse(
id=user.id,
email=user.email,
phone=user.phone,
name=user.name,
avatar=user.avatar,
role=user.role.value,
is_verified=user.is_verified,
created_at=user.created_at,
)
if brand:
resp.brand = BrandProfile(
id=brand.id,
name=brand.name,
logo=brand.logo,
description=brand.description,
contact_name=brand.contact_name,
contact_phone=brand.contact_phone,
contact_email=brand.contact_email,
)
if agency:
resp.agency = AgencyProfile(
id=agency.id,
name=agency.name,
logo=agency.logo,
description=agency.description,
contact_name=agency.contact_name,
contact_phone=agency.contact_phone,
contact_email=agency.contact_email,
)
if creator:
resp.creator = CreatorProfile(
id=creator.id,
name=creator.name,
avatar=creator.avatar,
bio=creator.bio,
douyin_account=creator.douyin_account,
xiaohongshu_account=creator.xiaohongshu_account,
bilibili_account=creator.bilibili_account,
)
return resp
async def _get_role_entity(db: AsyncSession, user: User):
"""根据角色获取对应实体"""
if user.role == UserRole.BRAND:
result = await db.execute(select(Brand).where(Brand.user_id == user.id))
return result.scalar_one_or_none(), None, None
elif user.role == UserRole.AGENCY:
result = await db.execute(select(Agency).where(Agency.user_id == user.id))
return None, result.scalar_one_or_none(), None
elif user.role == UserRole.CREATOR:
result = await db.execute(select(Creator).where(Creator.user_id == user.id))
return None, None, result.scalar_one_or_none()
return None, None, None
@router.get("", response_model=ProfileResponse)
async def get_profile(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""获取当前用户资料"""
brand, agency, creator = await _get_role_entity(db, current_user)
return _build_profile_response(current_user, brand, agency, creator)
@router.put("", response_model=ProfileResponse)
async def update_profile(
request: ProfileUpdateRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""更新当前用户资料"""
# 更新 User 表通用字段
if request.name is not None:
current_user.name = request.name
if request.avatar is not None:
current_user.avatar = request.avatar
if request.phone is not None:
current_user.phone = request.phone
# 更新角色表字段
brand, agency, creator = await _get_role_entity(db, current_user)
if current_user.role == UserRole.BRAND and brand:
if request.name is not None:
brand.name = request.name
if request.description is not None:
brand.description = request.description
if request.contact_name is not None:
brand.contact_name = request.contact_name
if request.contact_phone is not None:
brand.contact_phone = request.contact_phone
if request.contact_email is not None:
brand.contact_email = request.contact_email
elif current_user.role == UserRole.AGENCY and agency:
if request.name is not None:
agency.name = request.name
if request.description is not None:
agency.description = request.description
if request.contact_name is not None:
agency.contact_name = request.contact_name
if request.contact_phone is not None:
agency.contact_phone = request.contact_phone
if request.contact_email is not None:
agency.contact_email = request.contact_email
elif current_user.role == UserRole.CREATOR and creator:
if request.name is not None:
creator.name = request.name
if request.avatar is not None:
creator.avatar = request.avatar
if request.bio is not None:
creator.bio = request.bio
if request.douyin_account is not None:
creator.douyin_account = request.douyin_account
if request.xiaohongshu_account is not None:
creator.xiaohongshu_account = request.xiaohongshu_account
if request.bilibili_account is not None:
creator.bilibili_account = request.bilibili_account
await db.commit()
# 重新查询返回最新数据
brand, agency, creator = await _get_role_entity(db, current_user)
return _build_profile_response(current_user, brand, agency, creator)
@router.put("/password")
async def change_password(
request: ChangePasswordRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""修改密码"""
if not verify_password(request.old_password, current_user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="原密码不正确",
)
current_user.password_hash = hash_password(request.new_password)
await db.commit()
return {"message": "密码修改成功"}

View File

@ -51,6 +51,8 @@ from app.services.task_service import (
list_pending_reviews_for_agency,
list_pending_reviews_for_brand,
)
from app.api.sse import notify_new_task, notify_task_updated, notify_review_decision
from app.services.message_service import create_message
router = APIRouter(prefix="/tasks", tags=["任务"])
@ -172,6 +174,31 @@ async def create_new_task(
# 重新加载关联
task = await get_task_by_id(db, task.id)
# 创建消息 + SSE 通知达人有新任务
try:
await create_message(
db=db,
user_id=creator.user_id,
type="new_task",
title="新任务分配",
content=f"您有新的任务「{task.name}」,来自项目「{task.project.name}",
related_task_id=task.id,
related_project_id=task.project.id,
sender_name=agency.name,
)
await db.commit()
except Exception:
pass
try:
await notify_new_task(
task_id=task.id,
creator_user_id=creator.user_id,
task_name=task.name,
project_name=task.project.name,
)
except Exception:
pass
return _task_to_response(task)
@ -367,6 +394,21 @@ async def upload_task_script(
# 重新加载关联
task = await get_task_by_id(db, task.id)
# SSE 通知代理商脚本已上传
try:
result = await db.execute(
select(Agency).where(Agency.id == task.agency_id)
)
agency_obj = result.scalar_one_or_none()
if agency_obj:
await notify_task_updated(
task_id=task.id,
user_ids=[agency_obj.user_id],
data={"action": "script_uploaded", "stage": task.stage.value},
)
except Exception:
pass
return _task_to_response(task)
@ -415,6 +457,21 @@ async def upload_task_video(
# 重新加载关联
task = await get_task_by_id(db, task.id)
# SSE 通知代理商视频已上传
try:
result = await db.execute(
select(Agency).where(Agency.id == task.agency_id)
)
agency_obj = result.scalar_one_or_none()
if agency_obj:
await notify_task_updated(
task_id=task.id,
user_ids=[agency_obj.user_id],
data={"action": "video_uploaded", "stage": task.stage.value},
)
except Exception:
pass
return _task_to_response(task)
@ -523,6 +580,41 @@ async def review_script(
# 重新加载关联
task = await get_task_by_id(db, task.id)
# 创建消息 + SSE 通知达人脚本审核结果
try:
result = await db.execute(
select(Creator).where(Creator.id == task.creator_id)
)
creator_obj = result.scalar_one_or_none()
if creator_obj:
reviewer_type = "agency" if current_user.role == UserRole.AGENCY else "brand"
action_text = {"pass": "通过", "reject": "驳回", "force_pass": "强制通过"}.get(request.action, request.action)
await create_message(
db=db,
user_id=creator_obj.user_id,
type=request.action,
title=f"脚本审核{action_text}",
content=f"您的任务「{task.name}」脚本已被{action_text}" + (f",评语:{request.comment}" if request.comment else ""),
related_task_id=task.id,
sender_name=current_user.name,
)
await db.commit()
await notify_review_decision(
task_id=task.id,
creator_user_id=creator_obj.user_id,
review_type="script",
reviewer_type=reviewer_type,
action=request.action,
comment=request.comment,
)
await notify_task_updated(
task_id=task.id,
user_ids=[creator_obj.user_id],
data={"action": f"script_{request.action}", "stage": task.stage.value},
)
except Exception:
pass
return _task_to_response(task)
@ -628,6 +720,41 @@ async def review_video(
# 重新加载关联
task = await get_task_by_id(db, task.id)
# 创建消息 + SSE 通知达人视频审核结果
try:
result = await db.execute(
select(Creator).where(Creator.id == task.creator_id)
)
creator_obj = result.scalar_one_or_none()
if creator_obj:
reviewer_type = "agency" if current_user.role == UserRole.AGENCY else "brand"
action_text = {"pass": "通过", "reject": "驳回", "force_pass": "强制通过"}.get(request.action, request.action)
await create_message(
db=db,
user_id=creator_obj.user_id,
type=request.action,
title=f"视频审核{action_text}",
content=f"您的任务「{task.name}」视频已被{action_text}" + (f",评语:{request.comment}" if request.comment else ""),
related_task_id=task.id,
sender_name=current_user.name,
)
await db.commit()
await notify_review_decision(
task_id=task.id,
creator_user_id=creator_obj.user_id,
review_type="video",
reviewer_type=reviewer_type,
action=request.action,
comment=request.comment,
)
await notify_task_updated(
task_id=task.id,
user_ids=[creator_obj.user_id],
data={"action": f"video_{request.action}", "stage": task.stage.value},
)
except Exception:
pass
return _task_to_response(task)
@ -676,6 +803,21 @@ async def submit_task_appeal(
# 重新加载关联
task = await get_task_by_id(db, task.id)
# SSE 通知代理商有新申诉
try:
result = await db.execute(
select(Agency).where(Agency.id == task.agency_id)
)
agency_obj = result.scalar_one_or_none()
if agency_obj:
await notify_task_updated(
task_id=task.id,
user_ids=[agency_obj.user_id],
data={"action": "appeal_submitted", "stage": task.stage.value},
)
except Exception:
pass
return _task_to_response(task)

View File

@ -1,13 +1,15 @@
"""
文件上传 API
"""
from fastapi import APIRouter, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from typing import Optional
from datetime import datetime
from app.services.oss import generate_upload_policy, get_file_url
from app.config import settings
from app.models.user import User
from app.api.deps import get_current_user
router = APIRouter(prefix="/upload", tags=["文件上传"])
@ -19,10 +21,12 @@ class UploadPolicyRequest(BaseModel):
class UploadPolicyResponse(BaseModel):
"""上传凭证响应"""
access_key_id: str
"""COS 直传凭证响应"""
q_sign_algorithm: str
q_ak: str
q_key_time: str
q_signature: str
policy: str
signature: str
host: str
dir: str
expire: int
@ -49,11 +53,12 @@ class FileUploadedResponse(BaseModel):
@router.post("/policy", response_model=UploadPolicyResponse)
async def get_upload_policy(
request: UploadPolicyRequest,
current_user: User = Depends(get_current_user),
):
"""
获取 OSS 直传凭证
获取 COS 直传凭证
前端使用此凭证直接上传文件到阿里云 OSS无需经过后端
前端使用此凭证直接上传文件到腾讯云 COS无需经过后端
文件类型说明
- script: 脚本文档 (docx, pdf, xlsx, txt, pptx)
@ -87,9 +92,11 @@ async def get_upload_policy(
)
return UploadPolicyResponse(
access_key_id=policy["accessKeyId"],
q_sign_algorithm=policy["q_sign_algorithm"],
q_ak=policy["q_ak"],
q_key_time=policy["q_key_time"],
q_signature=policy["q_signature"],
policy=policy["policy"],
signature=policy["signature"],
host=policy["host"],
dir=policy["dir"],
expire=policy["expire"],
@ -100,6 +107,7 @@ async def get_upload_policy(
@router.post("/complete", response_model=FileUploadedResponse)
async def file_uploaded(
request: FileUploadedRequest,
current_user: User = Depends(get_current_user),
):
"""
文件上传完成回调

View File

@ -32,12 +32,12 @@ class Settings(BaseSettings):
AI_API_KEY: str = "" # 中转服务商的 API Key
AI_API_BASE_URL: str = "" # 中转服务商的 Base URL如 https://api.oneinall.ai/v1
# 阿里云 OSS 配置
OSS_ACCESS_KEY_ID: str = ""
OSS_ACCESS_KEY_SECRET: str = ""
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com"
OSS_BUCKET_NAME: str = "miaosi-files"
OSS_BUCKET_DOMAIN: str = "" # 公开访问域名,如 https://miaosi-files.oss-cn-hangzhou.aliyuncs.com
# 腾讯云 COS 配置
COS_SECRET_ID: str = ""
COS_SECRET_KEY: str = ""
COS_REGION: str = "ap-guangzhou"
COS_BUCKET_NAME: str = "miaosi-files-1250000000"
COS_CDN_DOMAIN: str = "" # CDN 自定义域名,空则用 COS 源站
# 邮件 SMTP
SMTP_HOST: str = ""

View File

@ -5,7 +5,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from app.config import settings
from app.logging_config import setup_logging
from app.middleware.rate_limit import RateLimitMiddleware
from app.api import health, auth, upload, scripts, videos, tasks, rules, ai_config, sse, projects, briefs, organizations, dashboard, export
from app.api import health, auth, upload, scripts, videos, tasks, rules, ai_config, sse, projects, briefs, organizations, dashboard, export, profile, messages
# Initialize logging
logger = setup_logging()
@ -72,6 +72,8 @@ app.include_router(briefs.router, prefix="/api/v1")
app.include_router(organizations.router, prefix="/api/v1")
app.include_router(dashboard.router, prefix="/api/v1")
app.include_router(export.router, prefix="/api/v1")
app.include_router(profile.router, prefix="/api/v1")
app.include_router(messages.router, prefix="/api/v1")
@app.on_event("startup")

View File

@ -12,6 +12,7 @@ from app.models.ai_config import AIConfig
from app.models.review import ReviewTask, Platform
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
from app.models.audit_log import AuditLog
from app.models.message import Message
# 保留 Tenant 兼容旧代码,但新代码应使用 Brand
from app.models.tenant import Tenant
@ -45,6 +46,8 @@ __all__ = [
"Competitor",
# 审计日志
"AuditLog",
# 消息
"Message",
# 兼容
"Tenant",
]

View File

@ -0,0 +1,45 @@
"""
消息/通知模型
"""
from typing import Optional
from sqlalchemy import String, Boolean, Text, ForeignKey, Index
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import Base, TimestampMixin
class Message(Base, TimestampMixin):
"""消息表"""
__tablename__ = "messages"
id: Mapped[str] = mapped_column(String(64), primary_key=True)
# 接收者
user_id: Mapped[str] = mapped_column(
String(64),
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
)
# 消息类型: invite, new_task, pass, reject, appeal, system 等
type: Mapped[str] = mapped_column(String(50), nullable=False)
# 消息内容
title: Mapped[str] = mapped_column(String(255), nullable=False)
content: Mapped[str] = mapped_column(Text, nullable=False)
# 已读状态
is_read: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
# 关联信息(可选)
related_task_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
related_project_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
sender_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
__table_args__ = (
Index("idx_messages_user_id", "user_id"),
Index("idx_messages_user_read", "user_id", "is_read"),
)
def __repr__(self) -> str:
return f"<Message(id={self.id}, user_id={self.user_id}, type={self.type})>"

View File

@ -0,0 +1,29 @@
"""
消息相关 Schema
"""
from typing import Optional, List
from datetime import datetime
from pydantic import BaseModel
class MessageResponse(BaseModel):
id: str
type: str
title: str
content: str
is_read: bool
related_task_id: Optional[str] = None
related_project_id: Optional[str] = None
sender_name: Optional[str] = None
created_at: Optional[datetime] = None
class MessageListResponse(BaseModel):
items: List[MessageResponse]
total: int
page: int
page_size: int
class UnreadCountResponse(BaseModel):
count: int

View File

@ -0,0 +1,77 @@
"""
用户资料相关 Schema
"""
from typing import Optional
from datetime import datetime
from pydantic import BaseModel, Field
# ===== 角色附加信息 =====
class BrandProfile(BaseModel):
id: str
name: str
logo: Optional[str] = None
description: Optional[str] = None
contact_name: Optional[str] = None
contact_phone: Optional[str] = None
contact_email: Optional[str] = None
class AgencyProfile(BaseModel):
id: str
name: str
logo: Optional[str] = None
description: Optional[str] = None
contact_name: Optional[str] = None
contact_phone: Optional[str] = None
contact_email: Optional[str] = None
class CreatorProfile(BaseModel):
id: str
name: str
avatar: Optional[str] = None
bio: Optional[str] = None
douyin_account: Optional[str] = None
xiaohongshu_account: Optional[str] = None
bilibili_account: Optional[str] = None
# ===== 响应 =====
class ProfileResponse(BaseModel):
id: str
email: Optional[str] = None
phone: Optional[str] = None
name: str
avatar: Optional[str] = None
role: str
is_verified: bool = False
created_at: Optional[datetime] = None
brand: Optional[BrandProfile] = None
agency: Optional[AgencyProfile] = None
creator: Optional[CreatorProfile] = None
# ===== 请求 =====
class ProfileUpdateRequest(BaseModel):
name: Optional[str] = Field(None, max_length=100)
avatar: Optional[str] = Field(None, max_length=2048)
phone: Optional[str] = Field(None, max_length=20)
# 品牌方/代理商字段
description: Optional[str] = None
contact_name: Optional[str] = Field(None, max_length=100)
contact_phone: Optional[str] = Field(None, max_length=20)
contact_email: Optional[str] = Field(None, max_length=255)
# 达人字段
bio: Optional[str] = None
douyin_account: Optional[str] = Field(None, max_length=100)
xiaohongshu_account: Optional[str] = Field(None, max_length=100)
bilibili_account: Optional[str] = Field(None, max_length=100)
class ChangePasswordRequest(BaseModel):
old_password: str = Field(..., min_length=6)
new_password: str = Field(..., min_length=6)

View File

@ -0,0 +1,116 @@
"""
消息服务
"""
import secrets
from typing import Optional, Tuple, List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, update
from app.models.message import Message
def _generate_message_id() -> str:
"""生成消息 ID"""
random_part = secrets.randbelow(900000) + 100000
return f"MSG{random_part}"
async def create_message(
db: AsyncSession,
user_id: str,
type: str,
title: str,
content: str,
related_task_id: Optional[str] = None,
related_project_id: Optional[str] = None,
sender_name: Optional[str] = None,
) -> Message:
"""创建消息"""
message = Message(
id=_generate_message_id(),
user_id=user_id,
type=type,
title=title,
content=content,
is_read=False,
related_task_id=related_task_id,
related_project_id=related_project_id,
sender_name=sender_name,
)
db.add(message)
await db.flush()
return message
async def list_messages(
db: AsyncSession,
user_id: str,
page: int = 1,
page_size: int = 20,
is_read: Optional[bool] = None,
type: Optional[str] = None,
) -> Tuple[List[Message], int]:
"""查询消息列表"""
query = select(Message).where(Message.user_id == user_id)
count_query = select(func.count()).select_from(Message).where(Message.user_id == user_id)
if is_read is not None:
query = query.where(Message.is_read == is_read)
count_query = count_query.where(Message.is_read == is_read)
if type is not None:
query = query.where(Message.type == type)
count_query = count_query.where(Message.type == type)
# 总数
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
# 分页
query = query.order_by(Message.created_at.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await db.execute(query)
messages = list(result.scalars().all())
return messages, total
async def get_unread_count(db: AsyncSession, user_id: str) -> int:
"""获取未读消息数"""
result = await db.execute(
select(func.count()).select_from(Message).where(
Message.user_id == user_id,
Message.is_read == False,
)
)
return result.scalar() or 0
async def mark_as_read(db: AsyncSession, message_id: str, user_id: str) -> bool:
"""标记单条消息已读"""
result = await db.execute(
select(Message).where(
Message.id == message_id,
Message.user_id == user_id,
)
)
message = result.scalar_one_or_none()
if not message:
return False
message.is_read = True
await db.flush()
return True
async def mark_all_as_read(db: AsyncSession, user_id: str) -> int:
"""标记所有消息已读,返回更新数量"""
result = await db.execute(
update(Message)
.where(Message.user_id == user_id, Message.is_read == False)
.values(is_read=True)
)
await db.flush()
return result.rowcount

View File

@ -1,5 +1,5 @@
"""
阿里云 OSS 服务
腾讯云 COS 服务 表单直传签名
"""
import time
import hmac
@ -17,99 +17,109 @@ def generate_upload_policy(
upload_dir: Optional[str] = None,
) -> dict:
"""
生成前端直传 OSS 所需的 Policy 和签名
生成前端直传 COS 所需的 Policy 和签名
COS 表单直传签名流程:
1. key_time = "{start_time};{end_time}"
2. sign_key = HMAC-SHA1(secret_key, key_time)
3. policy JSON Base64 编码
4. string_to_sign = SHA1(policy_base64)
5. signature = HMAC-SHA1(sign_key, string_to_sign)
Returns:
{
"accessKeyId": "...",
"q_sign_algorithm": "sha1",
"q_ak": "SecretId",
"q_key_time": "{start};{end}",
"q_signature": "...",
"policy": "base64 encoded policy",
"signature": "...",
"host": "https://bucket.oss-cn-hangzhou.aliyuncs.com",
"host": "https://bucket.cos.region.myqcloud.com",
"dir": "uploads/2026/02/",
"expire": 1234567890
"expire": 1234567890,
}
"""
if not settings.OSS_ACCESS_KEY_ID or not settings.OSS_ACCESS_KEY_SECRET:
raise ValueError("OSS 配置未设置")
if not settings.COS_SECRET_ID or not settings.COS_SECRET_KEY:
raise ValueError("COS 配置未设置")
# 计算过期时间
expire_time = int(time.time()) + expire_seconds
expire_date = datetime.utcfromtimestamp(expire_time).strftime("%Y-%m-%dT%H:%M:%SZ")
# 计算时间范围
start_time = int(time.time())
end_time = start_time + expire_seconds
# key_time: "{start};{end}"
key_time = f"{start_time};{end_time}"
# 默认上传目录uploads/年/月/
if upload_dir is None:
now = datetime.now()
upload_dir = f"uploads/{now.year}/{now.month:02d}/"
# 构建 Policy
# 1. sign_key = HMAC-SHA1(secret_key, key_time)
sign_key = hmac.new(
settings.COS_SECRET_KEY.encode(),
key_time.encode(),
hashlib.sha1,
).hexdigest()
# 2. 构建 PolicyCOS 表单上传 Policy 格式)
policy_dict = {
"expiration": expire_date,
"expiration": datetime.utcfromtimestamp(end_time).strftime(
"%Y-%m-%dT%H:%M:%S.000Z"
),
"conditions": [
{"bucket": settings.OSS_BUCKET_NAME},
{"bucket": settings.COS_BUCKET_NAME},
["starts-with", "$key", upload_dir],
{"q-sign-algorithm": "sha1"},
{"q-ak": settings.COS_SECRET_ID},
{"q-sign-time": key_time},
["content-length-range", 0, max_size_mb * 1024 * 1024],
]
],
}
# Base64 编码 Policy
# 3. Base64 编码 Policy
policy_json = json.dumps(policy_dict)
policy_base64 = base64.b64encode(policy_json.encode()).decode()
# 计算签名
signature = base64.b64encode(
hmac.new(
settings.OSS_ACCESS_KEY_SECRET.encode(),
policy_base64.encode(),
hashlib.sha1
).digest()
).decode()
# 4. string_to_sign = SHA1(policy_base64)
string_to_sign = hashlib.sha1(policy_base64.encode()).hexdigest()
# 5. signature = HMAC-SHA1(sign_key, string_to_sign)
signature = hmac.new(
sign_key.encode(),
string_to_sign.encode(),
hashlib.sha1,
).hexdigest()
# 构建 Host
host = settings.OSS_BUCKET_DOMAIN
if not host:
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
host = f"https://{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com"
return {
"accessKeyId": settings.OSS_ACCESS_KEY_ID,
"q_sign_algorithm": "sha1",
"q_ak": settings.COS_SECRET_ID,
"q_key_time": key_time,
"q_signature": signature,
"policy": policy_base64,
"signature": signature,
"host": host,
"dir": upload_dir,
"expire": expire_time,
"expire": end_time,
}
def generate_sts_token(
role_arn: str,
session_name: str = "miaosi-upload",
duration_seconds: int = 3600,
) -> dict:
"""
生成 STS 临时凭证需要配置 RAM 角色
当前使用 Policy 签名方式STS 方式为可选增强
如需启用 STS请安装 aliyun-python-sdk-sts 并配置 RAM 角色
"""
# 回退到 Policy 签名方式
return generate_upload_policy(
max_size_mb=settings.MAX_FILE_SIZE_MB,
expire_seconds=duration_seconds,
)
def get_file_url(file_key: str) -> str:
"""
获取文件的公开访问 URL
获取文件的访问 URL
优先使用 CDN 域名否则用 COS 源站域名
Args:
file_key: 文件在 OSS 中的 key "uploads/2026/02/video.mp4"
file_key: 文件在 COS 中的 key "uploads/2026/02/video.mp4"
Returns:
完整的访问 URL
"""
host = settings.OSS_BUCKET_DOMAIN
if not host:
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
if settings.COS_CDN_DOMAIN:
host = settings.COS_CDN_DOMAIN
else:
host = f"https://{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com"
# 确保 host 以 https:// 开头
if not host.startswith("http"):
@ -129,26 +139,22 @@ def parse_file_key_from_url(url: str) -> str:
从完整 URL 解析出文件 key
Args:
url: 完整的 OSS URL
url: 完整的 COS URL
Returns:
文件 key
"""
host = settings.OSS_BUCKET_DOMAIN
if not host:
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
# 尝试移除 CDN 域名
if settings.COS_CDN_DOMAIN:
cdn = settings.COS_CDN_DOMAIN.rstrip("/")
if not cdn.startswith("http"):
cdn = f"https://{cdn}"
if url.startswith(cdn):
return url[len(cdn):].lstrip("/")
# 移除 host 前缀
if url.startswith(host):
return url[len(host):].lstrip("/")
# 尝试其他格式
if settings.OSS_BUCKET_NAME in url:
# 格式: https://bucket.endpoint/key
parts = url.split(settings.OSS_BUCKET_NAME + ".")
if len(parts) > 1:
key_part = parts[1].split("/", 1)
if len(key_part) > 1:
return key_part[1]
# 尝试移除 COS 源站域名
cos_host = f"https://{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com"
if url.startswith(cos_host):
return url[len(cos_host):].lstrip("/")
return url

View File

@ -6,13 +6,11 @@ services:
image: postgres:16-alpine
container_name: miaosi-postgres
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: miaosi
ports:
- "5432:5432"
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
POSTGRES_DB: ${POSTGRES_DB:-miaosi}
volumes:
- postgres_data:/var/lib/postgresql/data
- ./data/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
@ -23,10 +21,8 @@ services:
redis:
image: redis:7-alpine
container_name: miaosi-redis
ports:
- "6379:6379"
volumes:
- redis_data:/data
- ./data/redis:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
@ -39,21 +35,18 @@ services:
context: .
dockerfile: Dockerfile
container_name: miaosi-api
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-miaosi}
REDIS_URL: redis://redis:6379/0
DEBUG: "true"
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./app:/app/app
- video_temp:/tmp/videos
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
# Celery Worker
celery-worker:
@ -62,15 +55,16 @@ services:
dockerfile: Dockerfile
container_name: miaosi-celery-worker
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-miaosi}
REDIS_URL: redis://redis:6379/0
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
volumes:
- ./app:/app/app
- video_temp:/tmp/videos
command: celery -A app.celery_app worker -l info -Q default,review -c 2
@ -81,15 +75,42 @@ services:
dockerfile: Dockerfile
container_name: miaosi-celery-beat
environment:
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-miaosi}
REDIS_URL: redis://redis:6379/0
env_file:
- .env
depends_on:
- celery-worker
volumes:
- ./app:/app/app
redis:
condition: service_healthy
celery-worker: {}
command: celery -A app.celery_app beat -l info
# Next.js 前端
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL:-https://your-domain.com}
NEXT_PUBLIC_USE_MOCK: "false"
container_name: miaosi-frontend
depends_on:
- api
# Nginx 反向代理
nginx:
image: nginx:alpine
container_name: miaosi-nginx
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./nginx/conf.d:/etc/nginx/conf.d:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
depends_on:
- api
- frontend
volumes:
postgres_data:
redis_data:
video_temp:

View File

@ -0,0 +1,50 @@
server {
listen 80;
server_name your-domain.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
# SSE 代理(长连接,必须在 /api/ 之前匹配)
location /api/v1/sse/ {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_cache off;
chunked_transfer_encoding off;
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
}
# API 代理
location /api/ {
proxy_pass http://api:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 前端代理
location / {
proxy_pass http://frontend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

43
backend/nginx/nginx.conf Normal file
View File

@ -0,0 +1,43 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
# Gzip 压缩
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
gzip_min_length 1000;
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# 上传大小限制(与后端 MAX_FILE_SIZE_MB 对齐)
client_max_body_size 500m;
include /etc/nginx/conf.d/*.conf;
}

View File

@ -20,6 +20,7 @@ dependencies = [
"cryptography>=42.0.0",
"openai>=1.12.0",
"cachetools>=5.3.0",
"sse-starlette>=2.0.0",
]
[project.optional-dependencies]

View File

49
backend/scripts/backup.sh Executable file
View File

@ -0,0 +1,49 @@
#!/bin/bash
# ===========================
# PostgreSQL 每日备份脚本
# 备份到本地 + 上传到腾讯云 COS
# ===========================
# 配合 crontab 使用:
# 0 3 * * * /path/to/backup.sh >> /var/log/miaosi-backup.log 2>&1
set -euo pipefail
# ---- 配置 ----
BACKUP_DIR="${BACKUP_DIR:-/var/backups/miaosi}"
POSTGRES_CONTAINER="${POSTGRES_CONTAINER:-miaosi-postgres}"
POSTGRES_USER="${POSTGRES_USER:-postgres}"
POSTGRES_DB="${POSTGRES_DB:-miaosi}"
RETAIN_DAYS="${RETAIN_DAYS:-7}"
# COS 备份桶(需要先安装 coscli 并配置好凭证)
COS_BACKUP_BUCKET="${COS_BACKUP_BUCKET:-}"
COS_BACKUP_PREFIX="${COS_BACKUP_PREFIX:-backups/postgres}"
# ---- 执行 ----
DATE=$(date +%Y%m%d_%H%M%S)
FILENAME="miaosi_${DATE}.sql.gz"
mkdir -p "$BACKUP_DIR"
echo "[$(date)] 开始备份数据库 ${POSTGRES_DB}..."
# 1. pg_dump 导出并压缩
docker exec "$POSTGRES_CONTAINER" pg_dump -U "$POSTGRES_USER" "$POSTGRES_DB" | gzip > "${BACKUP_DIR}/${FILENAME}"
echo "[$(date)] 本地备份完成: ${BACKUP_DIR}/${FILENAME}"
# 2. 上传到 COS如果配置了备份桶
if [ -n "$COS_BACKUP_BUCKET" ]; then
if command -v coscli &> /dev/null; then
coscli cp "${BACKUP_DIR}/${FILENAME}" "cos://${COS_BACKUP_BUCKET}/${COS_BACKUP_PREFIX}/${FILENAME}"
echo "[$(date)] 已上传到 COS: ${COS_BACKUP_BUCKET}/${COS_BACKUP_PREFIX}/${FILENAME}"
else
echo "[$(date)] 警告: coscli 未安装,跳过 COS 上传"
fi
fi
# 3. 清理过期的本地备份
find "$BACKUP_DIR" -name "miaosi_*.sql.gz" -mtime +"$RETAIN_DAYS" -delete
echo "[$(date)] 已清理 ${RETAIN_DAYS} 天前的本地备份"
echo "[$(date)] 备份完成"

19
backend/scripts/entrypoint.sh Executable file
View File

@ -0,0 +1,19 @@
#!/bin/bash
# Docker 容器入口脚本
# 先初始化数据库,再启动应用
set -e
echo "=== 秒思智能审核平台 - 启动中 ==="
# 运行数据库迁移
echo "运行数据库迁移..."
alembic upgrade head
# 填充种子数据
echo "填充种子数据..."
python -m scripts.seed
# 启动应用
echo "启动应用..."
exec "$@"

15
backend/scripts/init_db.sh Executable file
View File

@ -0,0 +1,15 @@
#!/bin/bash
# 数据库初始化脚本
# 运行 Alembic 迁移 + 填充种子数据
set -e
echo "=== 数据库初始化 ==="
echo "1. 运行 Alembic 迁移..."
alembic upgrade head
echo "2. 填充种子数据..."
python -m scripts.seed
echo "=== 数据库初始化完成 ==="

454
backend/scripts/seed.py Normal file
View File

@ -0,0 +1,454 @@
"""
种子数据脚本
创建 demo 用户组织关系项目Brief任务规则数据
支持幂等运行已存在则跳过
用法
cd backend && python -m scripts.seed
"""
import asyncio
import sys
from datetime import datetime, timedelta, timezone
from sqlalchemy import select, insert, text
from sqlalchemy.ext.asyncio import AsyncSession
# 确保能找到 app 模块
sys.path.insert(0, ".")
from app.database import AsyncSessionLocal
from app.models import (
User, UserRole, Brand, Agency, Creator,
Project, Task, TaskStage, TaskStatus, Brief,
ForbiddenWord, WhitelistItem, Competitor, AIConfig, Tenant,
Message,
brand_agency_association, agency_creator_association,
project_agency_association,
)
from app.services.auth import hash_password
# ============================================================
# 固定 ID方便前端 mock 数据对齐和反复运行幂等检查
# ============================================================
BRAND_USER_ID = "U100001"
AGENCY_USER_ID = "U100002"
CREATOR_USER_ID = "U100003"
BRAND_ID = "BR100001"
AGENCY_ID = "AG100001"
CREATOR_ID = "CR100001"
TENANT_ID = BRAND_ID # 品牌方 = 租户
PROJECT_ID = "PJ100001"
BRIEF_ID = "BF100001"
TASK_IDS = ["TK100001", "TK100002", "TK100003", "TK100004"]
PASSWORD_HASH = hash_password("demo123")
NOW = datetime.now(timezone.utc)
async def seed_data() -> None:
async with AsyncSessionLocal() as db:
# ========== 幂等检查 ==========
result = await db.execute(
select(User).where(User.email == "brand@demo.com")
)
if result.scalar_one_or_none():
print("✅ 种子数据已存在,跳过创建")
return
print("🌱 开始创建种子数据...")
# ========== 1. Demo 用户 ==========
brand_user = User(
id=BRAND_USER_ID,
email="brand@demo.com",
password_hash=PASSWORD_HASH,
name="秒思科技",
role=UserRole.BRAND,
is_active=True,
is_verified=True,
)
agency_user = User(
id=AGENCY_USER_ID,
email="agency@demo.com",
password_hash=PASSWORD_HASH,
name="星辰传媒",
role=UserRole.AGENCY,
is_active=True,
is_verified=True,
)
creator_user = User(
id=CREATOR_USER_ID,
email="creator@demo.com",
password_hash=PASSWORD_HASH,
name="李小红",
role=UserRole.CREATOR,
is_active=True,
is_verified=True,
)
db.add_all([brand_user, agency_user, creator_user])
await db.flush()
print(" ✓ 用户已创建: brand@demo.com / agency@demo.com / creator@demo.com")
# ========== 2. 组织实体 ==========
brand = Brand(
id=BRAND_ID,
user_id=BRAND_USER_ID,
name="秒思科技",
description="秒思科技是一家专注于 AI 内容合规的科技公司",
contact_name="张经理",
contact_phone="13800138000",
contact_email="brand@demo.com",
final_review_enabled=True,
is_active=True,
)
agency = Agency(
id=AGENCY_ID,
user_id=AGENCY_USER_ID,
name="星辰传媒",
description="星辰传媒是一家专业的内容营销代理商",
contact_name="王总监",
contact_phone="13900139000",
contact_email="agency@demo.com",
force_pass_enabled=True,
is_active=True,
)
creator = Creator(
id=CREATOR_ID,
user_id=CREATOR_USER_ID,
name="李小红",
bio="美妆博主,专注护肤分享,全网粉丝 50 万+",
douyin_account="lixiaohong_dy",
xiaohongshu_account="lixiaohong_xhs",
is_active=True,
)
db.add_all([brand, agency, creator])
await db.flush()
print(" ✓ 组织已创建: 秒思科技 / 星辰传媒 / 李小红")
# ========== 3. 租户(兼容旧表) ==========
tenant = Tenant(
id=TENANT_ID,
name="秒思科技",
is_active=True,
)
db.add(tenant)
await db.flush()
print(" ✓ 租户已创建: 秒思科技")
# ========== 4. 组织关联关系 ==========
await db.execute(
insert(brand_agency_association).values(
brand_id=BRAND_ID,
agency_id=AGENCY_ID,
is_active=True,
)
)
await db.execute(
insert(agency_creator_association).values(
agency_id=AGENCY_ID,
creator_id=CREATOR_ID,
is_active=True,
)
)
await db.flush()
print(" ✓ 组织关系已建立: 品牌方 → 代理商 → 达人")
# ========== 5. 项目 ==========
project = Project(
id=PROJECT_ID,
brand_id=BRAND_ID,
name="2026春季新品推广",
description="春季新品防晒霜推广活动,面向 18-35 岁女性用户,重点投放抖音和小红书平台",
start_date=NOW,
deadline=NOW + timedelta(days=30),
status="active",
)
db.add(project)
await db.flush()
# 项目 → 代理商关联
await db.execute(
insert(project_agency_association).values(
project_id=PROJECT_ID,
agency_id=AGENCY_ID,
is_active=True,
)
)
await db.flush()
print(" ✓ 项目已创建: 2026春季新品推广")
# ========== 6. Brief ==========
brief = Brief(
id=BRIEF_ID,
project_id=PROJECT_ID,
selling_points=[
{"content": "SPF50+ PA++++,超强防晒", "required": True},
{"content": "轻薄不油腻,适合日常通勤", "required": True},
{"content": "添加玻尿酸成分,防晒同时保湿", "required": False},
],
blacklist_words=[
{"word": "最好", "reason": "绝对化用语"},
{"word": "第一", "reason": "绝对化用语"},
{"word": "纯天然", "reason": "虚假宣传"},
],
competitors=["安耐晒", "怡思丁", "薇诺娜"],
brand_tone="年轻、活力、专业、可信赖",
min_duration=30,
max_duration=60,
other_requirements="请在视频中展示产品实际使用效果,包含户外场景拍摄",
)
db.add(brief)
await db.flush()
print(" ✓ Brief 已创建")
# ========== 7. 示例任务4 种阶段) ==========
tasks = [
# TK-001: 等待上传脚本
Task(
id=TASK_IDS[0],
project_id=PROJECT_ID,
agency_id=AGENCY_ID,
creator_id=CREATOR_ID,
name="春季防晒霜种草视频(1)",
sequence=1,
stage=TaskStage.SCRIPT_UPLOAD,
),
# TK-002: 脚本等待代理商审核
Task(
id=TASK_IDS[1],
project_id=PROJECT_ID,
agency_id=AGENCY_ID,
creator_id=CREATOR_ID,
name="春季防晒霜种草视频(2)",
sequence=2,
stage=TaskStage.SCRIPT_AGENCY_REVIEW,
script_file_url="https://example.com/scripts/demo-script.pdf",
script_file_name="防晒霜种草脚本v2.pdf",
script_uploaded_at=NOW - timedelta(hours=2),
script_ai_score=85,
script_ai_result={
"score": 85,
"summary": "脚本整体符合要求,卖点覆盖充分",
"issues": [
{"type": "soft_warning", "content": "建议增加产品成分说明"},
],
},
script_ai_reviewed_at=NOW - timedelta(hours=1),
),
# TK-003: 脚本已通过,等待上传视频
Task(
id=TASK_IDS[2],
project_id=PROJECT_ID,
agency_id=AGENCY_ID,
creator_id=CREATOR_ID,
name="春季防晒霜种草视频(3)",
sequence=3,
stage=TaskStage.VIDEO_UPLOAD,
script_file_url="https://example.com/scripts/demo-script-3.pdf",
script_file_name="防晒霜种草脚本v3.pdf",
script_uploaded_at=NOW - timedelta(days=2),
script_ai_score=92,
script_ai_result={
"score": 92,
"summary": "脚本质量优秀,完全符合 Brief 要求",
"issues": [],
},
script_ai_reviewed_at=NOW - timedelta(days=2),
script_agency_status=TaskStatus.PASSED,
script_agency_comment="脚本内容不错,可以进入拍摄",
script_agency_reviewer_id=AGENCY_USER_ID,
script_agency_reviewed_at=NOW - timedelta(days=1),
script_brand_status=TaskStatus.PASSED,
script_brand_comment="同意",
script_brand_reviewer_id=BRAND_USER_ID,
script_brand_reviewed_at=NOW - timedelta(days=1),
),
# TK-004: 已完成
Task(
id=TASK_IDS[3],
project_id=PROJECT_ID,
agency_id=AGENCY_ID,
creator_id=CREATOR_ID,
name="春季防晒霜种草视频(4)",
sequence=4,
stage=TaskStage.COMPLETED,
script_file_url="https://example.com/scripts/demo-script-4.pdf",
script_file_name="防晒霜种草脚本v4.pdf",
script_uploaded_at=NOW - timedelta(days=7),
script_ai_score=90,
script_ai_result={"score": 90, "summary": "符合要求", "issues": []},
script_ai_reviewed_at=NOW - timedelta(days=7),
script_agency_status=TaskStatus.PASSED,
script_agency_comment="通过",
script_agency_reviewer_id=AGENCY_USER_ID,
script_agency_reviewed_at=NOW - timedelta(days=6),
script_brand_status=TaskStatus.PASSED,
script_brand_comment="通过",
script_brand_reviewer_id=BRAND_USER_ID,
script_brand_reviewed_at=NOW - timedelta(days=6),
video_file_url="https://example.com/videos/demo-video-4.mp4",
video_file_name="防晒霜种草视频v4.mp4",
video_duration=45,
video_uploaded_at=NOW - timedelta(days=5),
video_ai_score=88,
video_ai_result={"score": 88, "summary": "视频质量良好", "issues": []},
video_ai_reviewed_at=NOW - timedelta(days=5),
video_agency_status=TaskStatus.PASSED,
video_agency_comment="视频效果好",
video_agency_reviewer_id=AGENCY_USER_ID,
video_agency_reviewed_at=NOW - timedelta(days=4),
video_brand_status=TaskStatus.PASSED,
video_brand_comment="终审通过",
video_brand_reviewer_id=BRAND_USER_ID,
video_brand_reviewed_at=NOW - timedelta(days=3),
),
]
db.add_all(tasks)
await db.flush()
print(" ✓ 任务已创建: TK100001~TK100004 (4种阶段)")
# ========== 8. 规则数据 ==========
forbidden_words = [
ForbiddenWord(id="FW100001", tenant_id=TENANT_ID, word="假药", category="法规违禁", severity="high"),
ForbiddenWord(id="FW100002", tenant_id=TENANT_ID, word="虚假宣传", category="法规违禁", severity="high"),
ForbiddenWord(id="FW100003", tenant_id=TENANT_ID, word="最好", category="绝对化用语", severity="medium"),
ForbiddenWord(id="FW100004", tenant_id=TENANT_ID, word="第一", category="绝对化用语", severity="medium"),
ForbiddenWord(id="FW100005", tenant_id=TENANT_ID, word="纯天然", category="虚假宣传", severity="medium"),
]
db.add_all(forbidden_words)
await db.flush()
print(" ✓ 违禁词已创建: 5 条")
competitors = [
Competitor(id="CP100001", tenant_id=TENANT_ID, brand_id=BRAND_ID, name="安耐晒", keywords=["安耐晒", "ANESSA", "资生堂防晒"]),
Competitor(id="CP100002", tenant_id=TENANT_ID, brand_id=BRAND_ID, name="怡思丁", keywords=["怡思丁", "ISDIN"]),
Competitor(id="CP100003", tenant_id=TENANT_ID, brand_id=BRAND_ID, name="薇诺娜", keywords=["薇诺娜", "WINONA"]),
]
db.add_all(competitors)
await db.flush()
print(" ✓ 竞品已创建: 3 条")
whitelist_items = [
WhitelistItem(id="WL100001", tenant_id=TENANT_ID, brand_id=BRAND_ID, term="SPF50+", reason="产品实际参数,非夸大宣传"),
WhitelistItem(id="WL100002", tenant_id=TENANT_ID, brand_id=BRAND_ID, term="PA++++", reason="产品实际参数,非夸大宣传"),
]
db.add_all(whitelist_items)
await db.flush()
print(" ✓ 白名单已创建: 2 条")
# ========== 9. AI 配置(模板) ==========
ai_config = AIConfig(
tenant_id=TENANT_ID,
provider="oneapi",
base_url="https://api.example.com/v1",
api_key_encrypted="demo-placeholder-key",
models={"text": "gpt-4o", "vision": "gpt-4o", "audio": "whisper-1"},
temperature=0.7,
max_tokens=2000,
is_configured=False,
)
db.add(ai_config)
await db.flush()
print(" ✓ AI 配置模板已创建")
# ========== 10. 示例消息 ==========
messages = [
# 达人消息
Message(
id="MSG100001",
user_id=CREATOR_USER_ID,
type="new_task",
title="新任务分配",
content="您有新的任务「春季防晒霜种草视频(1)」来自项目「2026春季新品推广」",
is_read=False,
related_task_id=TASK_IDS[0],
related_project_id=PROJECT_ID,
sender_name="星辰传媒",
),
Message(
id="MSG100002",
user_id=CREATOR_USER_ID,
type="pass",
title="脚本审核通过",
content="您的任务「春季防晒霜种草视频(3)」脚本已被通过",
is_read=True,
related_task_id=TASK_IDS[2],
sender_name="星辰传媒",
),
Message(
id="MSG100003",
user_id=CREATOR_USER_ID,
type="system_notice",
title="系统通知",
content="平台违禁词库已更新,请在创作时注意避免使用新增的违禁词",
is_read=True,
),
# 代理商消息
Message(
id="MSG100004",
user_id=AGENCY_USER_ID,
type="new_task",
title="新脚本提交",
content="达人「李小红」提交了「春季防晒霜种草视频(2)」脚本,请及时审核",
is_read=False,
related_task_id=TASK_IDS[1],
sender_name="李小红",
),
Message(
id="MSG100005",
user_id=AGENCY_USER_ID,
type="pass",
title="品牌终审通过",
content="任务「春季防晒霜种草视频(4)」已通过品牌方终审",
is_read=True,
related_task_id=TASK_IDS[3],
sender_name="秒思科技",
),
# 品牌方消息
Message(
id="MSG100006",
user_id=BRAND_USER_ID,
type="new_task",
title="脚本待终审",
content="「星辰传媒」的达人「李小红」脚本已通过代理商审核,请进行终审",
is_read=False,
related_task_id=TASK_IDS[1],
sender_name="星辰传媒",
),
Message(
id="MSG100007",
user_id=BRAND_USER_ID,
type="system_notice",
title="项目创建成功",
content="您的项目「2026春季新品推广」已创建成功",
is_read=True,
related_project_id=PROJECT_ID,
),
]
db.add_all(messages)
await db.flush()
print(" ✓ 示例消息已创建: 7 条 (达人3 + 代理商2 + 品牌方2)")
# ========== 提交 ==========
await db.commit()
print("\n🎉 种子数据创建完成!")
print("=" * 50)
print("Demo 账号:")
print(" 品牌方: brand@demo.com / demo123")
print(" 代理商: agency@demo.com / demo123")
print(" 达人: creator@demo.com / demo123")
print("=" * 50)
def main():
asyncio.run(seed_data())
if __name__ == "__main__":
main()

View File

@ -23,6 +23,10 @@ sleep 5
echo "运行数据库迁移..."
alembic upgrade head
# 填充种子数据
echo "填充种子数据..."
python -m scripts.seed
echo ""
echo "=== 基础服务已启动 ==="
echo "PostgreSQL: localhost:5432"

View File

@ -6,7 +6,7 @@
# --- API 地址 ---
# 后端 API 基础 URL浏览器端访问
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
NEXT_PUBLIC_API_BASE_URL=https://your-domain.com
# --- Mock 模式 ---
# 设为 true 使用前端 mock 数据development 环境下默认开启)

View File

@ -1,7 +1,9 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { USE_MOCK } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
@ -286,9 +288,40 @@ const mockMessages: Message[] = [
export default function AgencyMessagesPage() {
const router = useRouter()
const [messages, setMessages] = useState(mockMessages)
const [messages, setMessages] = useState<Message[]>(mockMessages)
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<'all' | 'unread' | 'pending'>('all')
const loadData = useCallback(async () => {
if (USE_MOCK) {
setLoading(false)
return
}
try {
const res = await api.getMessages({ page: 1, page_size: 50 })
const mapped: Message[] = res.items.map(item => ({
id: item.id,
type: (item.type || 'system_notice') as MessageType,
title: item.title,
content: item.content,
time: item.created_at ? new Date(item.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '',
read: item.is_read,
icon: Bell,
iconColor: 'text-text-secondary',
bgColor: 'bg-bg-elevated',
taskId: item.related_task_id || undefined,
projectId: item.related_project_id || undefined,
}))
setMessages(mapped)
} catch {
// 加载失败保持 mock 数据
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadData() }, [loadData])
const unreadCount = messages.filter(m => !m.read).length
const pendingAppealRequests = messages.filter(m => m.appealRequest?.status === 'pending').length
const pendingReviewCount = messages.filter(m =>
@ -310,12 +343,18 @@ export default function AgencyMessagesPage() {
const filteredMessages = getFilteredMessages()
const markAsRead = (id: string) => {
const markAsRead = async (id: string) => {
setMessages(prev => prev.map(m => m.id === id ? { ...m, read: true } : m))
if (!USE_MOCK) {
try { await api.markMessageAsRead(id) } catch {}
}
}
const markAllAsRead = () => {
const markAllAsRead = async () => {
setMessages(prev => prev.map(m => ({ ...m, read: true })))
if (!USE_MOCK) {
try { await api.markAllMessagesAsRead() } catch {}
}
}
// 处理申诉次数请求

View File

@ -2,6 +2,8 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { USE_MOCK } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
@ -203,7 +205,13 @@ export default function AgencyCompanyPage() {
const handleSave = async () => {
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
if (USE_MOCK) {
// Mock 模式:模拟保存延迟
await new Promise(resolve => setTimeout(resolve, 1000))
} else {
// TODO: 后端企业信息保存 API 待实现,暂时使用 mock 行为
await new Promise(resolve => setTimeout(resolve, 1000))
}
setIsSaving(false)
setIsEditing(false)
toast.success('公司信息已保存')

View File

@ -1,8 +1,10 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { useToast } from '@/components/ui/Toast'
import { USE_MOCK } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
@ -32,6 +34,24 @@ export default function AgencyProfileEditPage() {
const [isSaving, setIsSaving] = useState(false)
const [copied, setCopied] = useState(false)
const loadData = useCallback(async () => {
if (USE_MOCK) return
try {
const profile = await api.getProfile()
setFormData({
avatar: profile.name?.[0] || '?',
name: profile.name || '',
agencyId: profile.agency?.id || '--',
phone: profile.phone || '',
email: profile.email || '',
position: profile.agency?.contact_name || '',
department: '',
})
} catch {}
}, [])
useEffect(() => { loadData() }, [loadData])
const handleCopyId = async () => {
try {
await navigator.clipboard.writeText(formData.agencyId)
@ -44,7 +64,21 @@ export default function AgencyProfileEditPage() {
const handleSave = async () => {
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
} else {
try {
await api.updateProfile({
name: formData.name,
phone: formData.phone,
contact_name: formData.position,
})
} catch (err: any) {
toast.error(err.message || '保存失败')
setIsSaving(false)
return
}
}
setIsSaving(false)
toast.success('个人信息已保存')
router.back()

View File

@ -18,6 +18,8 @@ import {
CheckCircle,
AlertTriangle
} from 'lucide-react'
import { USE_MOCK } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
export default function AgencyAccountSettingsPage() {
const router = useRouter()
@ -53,10 +55,25 @@ export default function AgencyAccountSettingsPage() {
return
}
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
toast.success('密码修改成功')
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' })
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
setIsSaving(false)
toast.success('密码修改成功')
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' })
return
}
try {
await api.changePassword({
old_password: passwordForm.oldPassword,
new_password: passwordForm.newPassword,
})
toast.success('密码修改成功')
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' })
} catch (err: any) {
toast.error(err.message || '密码修改失败')
} finally {
setIsSaving(false)
}
}
return (

View File

@ -2,6 +2,8 @@
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { USE_MOCK } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { useToast } from '@/components/ui/Toast'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
@ -117,7 +119,13 @@ export default function AgencyNotificationSettingsPage() {
const handleSave = async () => {
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1000))
if (USE_MOCK) {
// Mock 模式:模拟保存延迟
await new Promise(resolve => setTimeout(resolve, 1000))
} else {
// TODO: 后端通知设置 API 待实现,暂时使用 mock 行为
await new Promise(resolve => setTimeout(resolve, 1000))
}
setIsSaving(false)
toast.success('通知设置已保存')
}

View File

@ -1,7 +1,9 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { USE_MOCK } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { Card, CardContent } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import {
@ -224,9 +226,37 @@ const mockMessages: Message[] = [
export default function BrandMessagesPage() {
const router = useRouter()
const [messages, setMessages] = useState(mockMessages)
const [messages, setMessages] = useState<Message[]>(mockMessages)
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState<'all' | 'unread' | 'pending'>('all')
const loadData = useCallback(async () => {
if (USE_MOCK) {
setLoading(false)
return
}
try {
const res = await api.getMessages({ page: 1, page_size: 50 })
const mapped: Message[] = res.items.map(item => ({
id: item.id,
type: (item.type || 'system_notice') as MessageType,
title: item.title,
content: item.content,
time: item.created_at ? new Date(item.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '',
read: item.is_read,
taskId: item.related_task_id || undefined,
projectId: item.related_project_id || undefined,
}))
setMessages(mapped)
} catch {
// 加载失败保持 mock 数据
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadData() }, [loadData])
const unreadCount = messages.filter(m => !m.read).length
const pendingReviewCount = messages.filter(m =>
!m.read && (m.type === 'agency_review_pass' || m.type === 'script_pending' || m.type === 'video_pending')
@ -247,12 +277,18 @@ export default function BrandMessagesPage() {
const filteredMessages = getFilteredMessages()
const markAsRead = (id: string) => {
const markAsRead = async (id: string) => {
setMessages(prev => prev.map(m => m.id === id ? { ...m, read: true } : m))
if (!USE_MOCK) {
try { await api.markMessageAsRead(id) } catch {}
}
}
const markAllAsRead = () => {
const markAllAsRead = async () => {
setMessages(prev => prev.map(m => ({ ...m, read: true })))
if (!USE_MOCK) {
try { await api.markAllMessagesAsRead() } catch {}
}
}
const handleMessageClick = (message: Message) => {

View File

@ -1,7 +1,9 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { Download, Calendar, Filter } from 'lucide-react'
import { USE_MOCK } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
@ -43,9 +45,33 @@ const platformOptions = [
export default function ReportsPage() {
const [period, setPeriod] = useState('7d')
const [platform, setPlatform] = useState('all')
const [reportData, setReportData] = useState(mockReportData)
const [reviewRecords, setReviewRecords] = useState(mockReviewRecords)
const [loading, setLoading] = useState(true)
const loadData = useCallback(async () => {
if (USE_MOCK) {
// Mock 模式:直接使用本地 mock 数据
setReportData(mockReportData)
setReviewRecords(mockReviewRecords)
setLoading(false)
return
}
// TODO: 后端报表 API 待实现 (GET /api/v1/reports),暂时使用 mock 数据
try {
setReportData(mockReportData)
setReviewRecords(mockReviewRecords)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadData()
}, [loadData])
// 计算汇总数据
const summary = mockReportData.reduce(
const summary = reportData.reduce(
(acc, day) => ({
totalSubmitted: acc.totalSubmitted + day.submitted,
totalPassed: acc.totalPassed + day.passed,
@ -53,7 +79,7 @@ export default function ReportsPage() {
}),
{ totalSubmitted: 0, totalPassed: 0, totalFailed: 0 }
)
const passRate = Math.round((summary.totalPassed / summary.totalSubmitted) * 100)
const passRate = summary.totalSubmitted > 0 ? Math.round((summary.totalPassed / summary.totalSubmitted) * 100) : 0
return (
<div className="space-y-6">
@ -127,7 +153,7 @@ export default function ReportsPage() {
</tr>
</thead>
<tbody>
{mockReportData.map((row) => (
{reportData.map((row) => (
<tr key={row.id} className="border-b last:border-0">
<td className="py-3 font-medium text-gray-900">{row.date}</td>
<td className="py-3 text-gray-600">{row.submitted}</td>
@ -168,7 +194,7 @@ export default function ReportsPage() {
</tr>
</thead>
<tbody>
{mockReviewRecords.map((record) => (
{reviewRecords.map((record) => (
<tr key={record.id} className="border-b last:border-0 hover:bg-gray-50">
<td className="py-3 font-medium text-gray-900">{record.videoTitle}</td>
<td className="py-3 text-gray-600">{record.creator}</td>

View File

@ -30,6 +30,8 @@ import {
EyeOff,
Phone
} from 'lucide-react'
import { USE_MOCK } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
export default function BrandSettingsPage() {
const router = useRouter()
@ -105,14 +107,32 @@ export default function BrandSettingsPage() {
router.push('/login')
}
const handleChangePassword = () => {
const handleChangePassword = async () => {
if (passwordForm.new !== passwordForm.confirm) {
toast.error('两次输入的密码不一致')
return
}
toast.success('密码修改成功')
setShowPasswordModal(false)
setPasswordForm({ current: '', new: '', confirm: '' })
if (!passwordForm.current || !passwordForm.new) {
toast.error('请填写完整密码信息')
return
}
if (USE_MOCK) {
toast.success('密码修改成功')
setShowPasswordModal(false)
setPasswordForm({ current: '', new: '', confirm: '' })
return
}
try {
await api.changePassword({
old_password: passwordForm.current,
new_password: passwordForm.new,
})
toast.success('密码修改成功')
setShowPasswordModal(false)
setPasswordForm({ current: '', new: '', confirm: '' })
} catch (err: any) {
toast.error(err.message || '密码修改失败')
}
}
const handleEnable2FA = () => {

View File

@ -1,7 +1,9 @@
'use client'
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { USE_MOCK } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import {
UserPlus,
ClipboardList,
@ -466,7 +468,8 @@ function SuccessModal({
export default function CreatorMessagesPage() {
const router = useRouter()
const [messages, setMessages] = useState(mockMessages)
const [messages, setMessages] = useState<Message[]>(mockMessages)
const [loading, setLoading] = useState(true)
const [confirmModal, setConfirmModal] = useState<{ isOpen: boolean; type: 'accept' | 'ignore'; messageId: string }>({
isOpen: false,
type: 'accept',
@ -477,14 +480,47 @@ export default function CreatorMessagesPage() {
message: '',
})
const markAsRead = (id: string) => {
const loadData = useCallback(async () => {
if (USE_MOCK) {
setMessages(mockMessages)
setLoading(false)
return
}
try {
const res = await api.getMessages({ page: 1, page_size: 50 })
const mapped: Message[] = res.items.map(item => ({
id: item.id,
type: (item.type || 'system_notice') as MessageType,
title: item.title,
content: item.content,
time: item.created_at ? new Date(item.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '',
read: item.is_read,
taskId: item.related_task_id || undefined,
}))
setMessages(mapped)
} catch {
// 加载失败保持 mock 数据
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadData() }, [loadData])
const markAsRead = async (id: string) => {
setMessages(prev => prev.map(msg =>
msg.id === id ? { ...msg, read: true } : msg
))
if (!USE_MOCK) {
try { await api.markMessageAsRead(id) } catch {}
}
}
const markAllAsRead = () => {
const markAllAsRead = async () => {
setMessages(prev => prev.map(msg => ({ ...msg, read: true })))
if (!USE_MOCK) {
try { await api.markAllMessagesAsRead() } catch {}
}
}
// 根据消息类型跳转到对应页面

View File

@ -1,6 +1,6 @@
'use client'
import React, { useState } from 'react'
import React, { useState, useEffect, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { ArrowLeft, Camera, Check, Copy } from 'lucide-react'
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
@ -8,6 +8,8 @@ import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/Toast'
import { USE_MOCK } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
// 模拟用户数据
const mockUser = {
@ -32,11 +34,29 @@ export default function ProfileEditPage() {
douyinAccount: mockUser.douyinAccount,
bio: mockUser.bio,
})
const [creatorId, setCreatorId] = useState(mockUser.creatorId)
const loadData = useCallback(async () => {
if (USE_MOCK) return
try {
const profile = await api.getProfile()
setFormData({
name: profile.name || '',
phone: profile.phone || '',
email: profile.email || '',
douyinAccount: profile.creator?.douyin_account || '',
bio: profile.creator?.bio || '',
})
if (profile.creator?.id) setCreatorId(profile.creator.id)
} catch {}
}, [])
useEffect(() => { loadData() }, [loadData])
// 复制达人ID
const handleCopyId = async () => {
try {
await navigator.clipboard.writeText(mockUser.creatorId)
await navigator.clipboard.writeText(creatorId)
setIdCopied(true)
setTimeout(() => setIdCopied(false), 2000)
} catch {
@ -52,8 +72,22 @@ export default function ProfileEditPage() {
// 保存
const handleSave = async () => {
setIsSaving(true)
// 模拟保存
await new Promise(resolve => setTimeout(resolve, 1000))
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1000))
} else {
try {
await api.updateProfile({
name: formData.name,
phone: formData.phone,
bio: formData.bio,
douyin_account: formData.douyinAccount,
})
} catch (err: any) {
toast.error(err.message || '保存失败')
setIsSaving(false)
return
}
}
setIsSaving(false)
router.back()
}
@ -89,7 +123,7 @@ export default function ProfileEditPage() {
background: 'linear-gradient(135deg, #6366F1 0%, #4F46E5 100%)',
}}
>
<span className="text-[40px] font-bold text-white">{mockUser.initial}</span>
<span className="text-[40px] font-bold text-white">{formData.name?.[0] || '?'}</span>
</div>
{/* 相机按钮 */}
<button
@ -118,7 +152,7 @@ export default function ProfileEditPage() {
<label className="text-sm font-medium text-text-primary">ID</label>
<div className="flex gap-3">
<div className="flex-1 px-4 py-3 rounded-xl border border-border-default bg-bg-elevated/50 flex items-center justify-between">
<span className="font-mono font-medium text-accent-indigo">{mockUser.creatorId}</span>
<span className="font-mono font-medium text-accent-indigo">{creatorId}</span>
<button
type="button"
onClick={handleCopyId}

View File

@ -18,6 +18,9 @@ import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Modal } from '@/components/ui/Modal'
import { cn } from '@/lib/utils'
import { USE_MOCK } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import { useToast } from '@/components/ui/Toast'
// 模拟登录设备数据
const mockDevices = [
@ -108,12 +111,31 @@ function ChangePasswordModal({
confirmPassword: '',
})
const [isSaving, setIsSaving] = useState(false)
const toast = useToast()
const handleSubmit = async () => {
if (formData.newPassword !== formData.confirmPassword) {
toast.error('两次输入的密码不一致')
return
}
setIsSaving(true)
await new Promise(resolve => setTimeout(resolve, 1500))
setIsSaving(false)
setStep(2)
if (USE_MOCK) {
await new Promise(resolve => setTimeout(resolve, 1500))
setIsSaving(false)
setStep(2)
return
}
try {
await api.changePassword({
old_password: formData.currentPassword,
new_password: formData.newPassword,
})
setIsSaving(false)
setStep(2)
} catch (err: any) {
setIsSaving(false)
toast.error(err.message || '密码修改失败')
}
}
const handleClose = () => {

View File

@ -2,6 +2,8 @@
import React, { useState } from 'react'
import { useRouter } from 'next/navigation'
import { USE_MOCK } from '@/contexts/AuthContext'
import { api } from '@/lib/api'
import {
ArrowLeft,
Mail,

View File

@ -83,6 +83,10 @@ export function SSEProvider({ children }: { children: ReactNode }) {
}
}
}
// 流正常结束服务器关闭连接5秒后重连
if (!controller.signal.aborted) {
reconnectTimerRef.current = setTimeout(connect, 5000)
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
// 5秒后重连

View File

@ -57,17 +57,19 @@ export function useOSSUpload(fileType: string = 'general'): UseOSSUploadReturn {
setProgress(10)
const policy = await api.getUploadPolicy(fileType)
// 2. 构建 OSS 直传 FormData
// 2. 构建 COS 直传 FormData
const fileKey = `${policy.dir}${Date.now()}_${file.name}`
const formData = new FormData()
formData.append('key', fileKey)
formData.append('q-sign-algorithm', policy.q_sign_algorithm)
formData.append('q-ak', policy.q_ak)
formData.append('q-key-time', policy.q_key_time)
formData.append('q-signature', policy.q_signature)
formData.append('policy', policy.policy)
formData.append('OSSAccessKeyId', policy.access_key_id)
formData.append('signature', policy.signature)
formData.append('success_action_status', '200')
formData.append('file', file)
// 3. 上传到 OSS
// 3. 上传到 COS
setProgress(30)
const xhr = new XMLHttpRequest()
await new Promise<void>((resolve, reject) => {

View File

@ -136,9 +136,11 @@ export interface RefreshTokenResponse {
}
export interface UploadPolicyResponse {
access_key_id: string
q_sign_algorithm: string
q_ak: string
q_key_time: string
q_signature: string
policy: string
signature: string
host: string
dir: string
expire: number
@ -153,6 +155,92 @@ export interface FileUploadedResponse {
file_type: string
}
// ==================== 用户资料类型 ====================
export interface BrandProfileInfo {
id: string
name: string
logo?: string
description?: string
contact_name?: string
contact_phone?: string
contact_email?: string
}
export interface AgencyProfileInfo {
id: string
name: string
logo?: string
description?: string
contact_name?: string
contact_phone?: string
contact_email?: string
}
export interface CreatorProfileInfo {
id: string
name: string
avatar?: string
bio?: string
douyin_account?: string
xiaohongshu_account?: string
bilibili_account?: string
}
export interface ProfileResponse {
id: string
email?: string
phone?: string
name: string
avatar?: string
role: string
is_verified: boolean
created_at?: string
brand?: BrandProfileInfo
agency?: AgencyProfileInfo
creator?: CreatorProfileInfo
}
export interface ProfileUpdateRequest {
name?: string
avatar?: string
phone?: string
description?: string
contact_name?: string
contact_phone?: string
contact_email?: string
bio?: string
douyin_account?: string
xiaohongshu_account?: string
bilibili_account?: string
}
export interface ChangePasswordRequest {
old_password: string
new_password: string
}
// ==================== 消息类型 ====================
export interface MessageItem {
id: string
type: string
title: string
content: string
is_read: boolean
related_task_id?: string
related_project_id?: string
sender_name?: string
created_at?: string
}
export interface MessageListResponse {
items: MessageItem[]
total: number
page: number
page_size: number
}
// ==================== Token 管理 ====================
function getAccessToken(): string | null {
@ -338,7 +426,7 @@ class ApiClient {
// ==================== 文件上传 ====================
/**
* OSS
* COS
*/
async getUploadPolicy(fileType: string = 'general'): Promise<UploadPolicyResponse> {
const response = await this.client.post<UploadPolicyResponse>('/upload/policy', {
@ -803,6 +891,64 @@ class ApiClient {
return response.data
}
// ==================== 用户资料 ====================
/**
*
*/
async getProfile(): Promise<ProfileResponse> {
const response = await this.client.get<ProfileResponse>('/profile')
return response.data
}
/**
*
*/
async updateProfile(data: ProfileUpdateRequest): Promise<ProfileResponse> {
const response = await this.client.put<ProfileResponse>('/profile', data)
return response.data
}
/**
*
*/
async changePassword(data: ChangePasswordRequest): Promise<{ message: string }> {
const response = await this.client.put<{ message: string }>('/profile/password', data)
return response.data
}
// ==================== 消息/通知 ====================
/**
*
*/
async getMessages(params?: { page?: number; page_size?: number; is_read?: boolean; type?: string }): Promise<MessageListResponse> {
const response = await this.client.get<MessageListResponse>('/messages', { params })
return response.data
}
/**
*
*/
async getUnreadCount(): Promise<{ count: number }> {
const response = await this.client.get<{ count: number }>('/messages/unread-count')
return response.data
}
/**
*
*/
async markMessageAsRead(messageId: string): Promise<void> {
await this.client.put(`/messages/${messageId}/read`)
}
/**
*
*/
async markAllMessagesAsRead(): Promise<void> {
await this.client.put('/messages/read-all')
}
// ==================== 健康检查 ====================
/**