Compare commits
No commits in common. "8ab2d869fca0609737d383f1cfe9799accea5ac9" and "68dac332d47e12b06227418eccb21fb3c0e5328e" have entirely different histories.
8ab2d869fc
...
68dac332d4
@ -106,9 +106,8 @@ useEffect(() => { loadData() }, [loadData])
|
|||||||
- 配置项:`AI_PROVIDER`, `AI_API_KEY`, `AI_API_BASE_URL`
|
- 配置项:`AI_PROVIDER`, `AI_API_KEY`, `AI_API_BASE_URL`
|
||||||
|
|
||||||
### 文件上传
|
### 文件上传
|
||||||
- 腾讯云 COS 直传,前端通过 `useOSSUpload` hook 处理
|
- 阿里云 OSS 直传,前端通过 `useOSSUpload` hook 处理
|
||||||
- 流程:`api.getUploadPolicy()` → POST 到 COS → `api.fileUploaded()` 回调
|
- 流程:`api.getUploadPolicy()` → POST 到 OSS → `api.fileUploaded()` 回调
|
||||||
- COS 签名:HMAC-SHA1,字段包括 `q-sign-algorithm`、`q-ak`、`q-key-time`、`q-signature`、`policy`
|
|
||||||
|
|
||||||
### 实时推送
|
### 实时推送
|
||||||
- SSE (Server-Sent Events),端点 `/api/v1/sse/events`
|
- SSE (Server-Sent Events),端点 `/api/v1/sse/events`
|
||||||
|
|||||||
@ -8,16 +8,12 @@
|
|||||||
APP_NAME=秒思智能审核平台
|
APP_NAME=秒思智能审核平台
|
||||||
APP_VERSION=1.0.0
|
APP_VERSION=1.0.0
|
||||||
DEBUG=false
|
DEBUG=false
|
||||||
ENVIRONMENT=production
|
|
||||||
|
|
||||||
# --- 数据库 ---
|
# --- 数据库 ---
|
||||||
POSTGRES_USER=miaosi
|
DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/miaosi
|
||||||
POSTGRES_PASSWORD=change-me-in-production
|
|
||||||
POSTGRES_DB=miaosi
|
|
||||||
DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}
|
|
||||||
|
|
||||||
# --- Redis ---
|
# --- Redis ---
|
||||||
REDIS_URL=redis://redis:6379/0
|
REDIS_URL=redis://localhost:6379/0
|
||||||
|
|
||||||
# --- JWT ---
|
# --- JWT ---
|
||||||
# 生产环境务必更换为随机密钥: python -c "import secrets; print(secrets.token_urlsafe(64))"
|
# 生产环境务必更换为随机密钥: python -c "import secrets; print(secrets.token_urlsafe(64))"
|
||||||
@ -30,20 +26,12 @@ AI_PROVIDER=oneapi
|
|||||||
AI_API_KEY=
|
AI_API_KEY=
|
||||||
AI_API_BASE_URL=
|
AI_API_BASE_URL=
|
||||||
|
|
||||||
# --- 腾讯云 COS ---
|
# --- 阿里云 OSS ---
|
||||||
COS_SECRET_ID=
|
OSS_ACCESS_KEY_ID=
|
||||||
COS_SECRET_KEY=
|
OSS_ACCESS_KEY_SECRET=
|
||||||
COS_REGION=ap-guangzhou
|
OSS_ENDPOINT=oss-cn-hangzhou.aliyuncs.com
|
||||||
COS_BUCKET_NAME=miaosi-files-1250000000
|
OSS_BUCKET_NAME=miaosi-files
|
||||||
COS_CDN_DOMAIN=
|
OSS_BUCKET_DOMAIN=
|
||||||
|
|
||||||
# --- 邮件 SMTP ---
|
|
||||||
SMTP_HOST=
|
|
||||||
SMTP_PORT=465
|
|
||||||
SMTP_USER=
|
|
||||||
SMTP_PASSWORD=
|
|
||||||
SMTP_FROM_NAME=秒思智能审核平台
|
|
||||||
SMTP_USE_SSL=true
|
|
||||||
|
|
||||||
# --- 加密密钥 ---
|
# --- 加密密钥 ---
|
||||||
# 用于加密存储 API 密钥等敏感数据
|
# 用于加密存储 API 密钥等敏感数据
|
||||||
|
|||||||
@ -40,7 +40,6 @@ COPY app/ ./app/
|
|||||||
COPY alembic/ ./alembic/
|
COPY alembic/ ./alembic/
|
||||||
COPY alembic.ini .
|
COPY alembic.ini .
|
||||||
COPY pyproject.toml .
|
COPY pyproject.toml .
|
||||||
COPY scripts/ ./scripts/
|
|
||||||
|
|
||||||
# 创建非 root 用户
|
# 创建非 root 用户
|
||||||
RUN groupadd -r miaosi && useradd -r -g miaosi -d /app -s /sbin/nologin miaosi \
|
RUN groupadd -r miaosi && useradd -r -g miaosi -d /app -s /sbin/nologin miaosi \
|
||||||
@ -54,5 +53,4 @@ EXPOSE 8000
|
|||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||||
CMD curl -f http://localhost:8000/health || exit 1
|
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"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
@ -14,8 +14,14 @@ from alembic import context
|
|||||||
# 导入配置和模型
|
# 导入配置和模型
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models.base import Base
|
from app.models.base import Base
|
||||||
# 导入所有模型,确保 autogenerate 能检测到全部表
|
from app.models import (
|
||||||
from app.models import * # noqa: F401,F403
|
Tenant,
|
||||||
|
AIConfig,
|
||||||
|
ReviewTask,
|
||||||
|
ForbiddenWord,
|
||||||
|
WhitelistItem,
|
||||||
|
Competitor,
|
||||||
|
)
|
||||||
|
|
||||||
# Alembic Config 对象
|
# Alembic Config 对象
|
||||||
config = context.config
|
config = context.config
|
||||||
|
|||||||
@ -1,37 +0,0 @@
|
|||||||
"""添加审计日志表
|
|
||||||
|
|
||||||
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')
|
|
||||||
@ -1,42 +0,0 @@
|
|||||||
"""添加消息表
|
|
||||||
|
|
||||||
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')
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
"""
|
|
||||||
消息/通知 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}
|
|
||||||
@ -1,173 +0,0 @@
|
|||||||
"""
|
|
||||||
用户资料 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": "密码修改成功"}
|
|
||||||
@ -51,8 +51,6 @@ from app.services.task_service import (
|
|||||||
list_pending_reviews_for_agency,
|
list_pending_reviews_for_agency,
|
||||||
list_pending_reviews_for_brand,
|
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=["任务"])
|
router = APIRouter(prefix="/tasks", tags=["任务"])
|
||||||
|
|
||||||
@ -174,31 +172,6 @@ async def create_new_task(
|
|||||||
# 重新加载关联
|
# 重新加载关联
|
||||||
task = await get_task_by_id(db, task.id)
|
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)
|
return _task_to_response(task)
|
||||||
|
|
||||||
|
|
||||||
@ -394,21 +367,6 @@ async def upload_task_script(
|
|||||||
# 重新加载关联
|
# 重新加载关联
|
||||||
task = await get_task_by_id(db, task.id)
|
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)
|
return _task_to_response(task)
|
||||||
|
|
||||||
|
|
||||||
@ -457,21 +415,6 @@ async def upload_task_video(
|
|||||||
# 重新加载关联
|
# 重新加载关联
|
||||||
task = await get_task_by_id(db, task.id)
|
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)
|
return _task_to_response(task)
|
||||||
|
|
||||||
|
|
||||||
@ -580,41 +523,6 @@ async def review_script(
|
|||||||
# 重新加载关联
|
# 重新加载关联
|
||||||
task = await get_task_by_id(db, task.id)
|
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)
|
return _task_to_response(task)
|
||||||
|
|
||||||
|
|
||||||
@ -720,41 +628,6 @@ async def review_video(
|
|||||||
# 重新加载关联
|
# 重新加载关联
|
||||||
task = await get_task_by_id(db, task.id)
|
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)
|
return _task_to_response(task)
|
||||||
|
|
||||||
|
|
||||||
@ -803,21 +676,6 @@ async def submit_task_appeal(
|
|||||||
# 重新加载关联
|
# 重新加载关联
|
||||||
task = await get_task_by_id(db, task.id)
|
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)
|
return _task_to_response(task)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,13 @@
|
|||||||
"""
|
"""
|
||||||
文件上传 API
|
文件上传 API
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, HTTPException, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from app.services.oss import generate_upload_policy, get_file_url
|
from app.services.oss import generate_upload_policy, get_file_url
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models.user import User
|
|
||||||
from app.api.deps import get_current_user
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/upload", tags=["文件上传"])
|
router = APIRouter(prefix="/upload", tags=["文件上传"])
|
||||||
|
|
||||||
@ -21,12 +19,10 @@ class UploadPolicyRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UploadPolicyResponse(BaseModel):
|
class UploadPolicyResponse(BaseModel):
|
||||||
"""COS 直传凭证响应"""
|
"""上传凭证响应"""
|
||||||
q_sign_algorithm: str
|
access_key_id: str
|
||||||
q_ak: str
|
|
||||||
q_key_time: str
|
|
||||||
q_signature: str
|
|
||||||
policy: str
|
policy: str
|
||||||
|
signature: str
|
||||||
host: str
|
host: str
|
||||||
dir: str
|
dir: str
|
||||||
expire: int
|
expire: int
|
||||||
@ -53,12 +49,11 @@ class FileUploadedResponse(BaseModel):
|
|||||||
@router.post("/policy", response_model=UploadPolicyResponse)
|
@router.post("/policy", response_model=UploadPolicyResponse)
|
||||||
async def get_upload_policy(
|
async def get_upload_policy(
|
||||||
request: UploadPolicyRequest,
|
request: UploadPolicyRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
获取 COS 直传凭证
|
获取 OSS 直传凭证
|
||||||
|
|
||||||
前端使用此凭证直接上传文件到腾讯云 COS,无需经过后端。
|
前端使用此凭证直接上传文件到阿里云 OSS,无需经过后端。
|
||||||
|
|
||||||
文件类型说明:
|
文件类型说明:
|
||||||
- script: 脚本文档 (docx, pdf, xlsx, txt, pptx)
|
- script: 脚本文档 (docx, pdf, xlsx, txt, pptx)
|
||||||
@ -92,11 +87,9 @@ async def get_upload_policy(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return UploadPolicyResponse(
|
return UploadPolicyResponse(
|
||||||
q_sign_algorithm=policy["q_sign_algorithm"],
|
access_key_id=policy["accessKeyId"],
|
||||||
q_ak=policy["q_ak"],
|
|
||||||
q_key_time=policy["q_key_time"],
|
|
||||||
q_signature=policy["q_signature"],
|
|
||||||
policy=policy["policy"],
|
policy=policy["policy"],
|
||||||
|
signature=policy["signature"],
|
||||||
host=policy["host"],
|
host=policy["host"],
|
||||||
dir=policy["dir"],
|
dir=policy["dir"],
|
||||||
expire=policy["expire"],
|
expire=policy["expire"],
|
||||||
@ -107,7 +100,6 @@ async def get_upload_policy(
|
|||||||
@router.post("/complete", response_model=FileUploadedResponse)
|
@router.post("/complete", response_model=FileUploadedResponse)
|
||||||
async def file_uploaded(
|
async def file_uploaded(
|
||||||
request: FileUploadedRequest,
|
request: FileUploadedRequest,
|
||||||
current_user: User = Depends(get_current_user),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
文件上传完成回调
|
文件上传完成回调
|
||||||
|
|||||||
@ -32,12 +32,12 @@ class Settings(BaseSettings):
|
|||||||
AI_API_KEY: str = "" # 中转服务商的 API Key
|
AI_API_KEY: str = "" # 中转服务商的 API Key
|
||||||
AI_API_BASE_URL: str = "" # 中转服务商的 Base URL,如 https://api.oneinall.ai/v1
|
AI_API_BASE_URL: str = "" # 中转服务商的 Base URL,如 https://api.oneinall.ai/v1
|
||||||
|
|
||||||
# 腾讯云 COS 配置
|
# 阿里云 OSS 配置
|
||||||
COS_SECRET_ID: str = ""
|
OSS_ACCESS_KEY_ID: str = ""
|
||||||
COS_SECRET_KEY: str = ""
|
OSS_ACCESS_KEY_SECRET: str = ""
|
||||||
COS_REGION: str = "ap-guangzhou"
|
OSS_ENDPOINT: str = "oss-cn-hangzhou.aliyuncs.com"
|
||||||
COS_BUCKET_NAME: str = "miaosi-files-1250000000"
|
OSS_BUCKET_NAME: str = "miaosi-files"
|
||||||
COS_CDN_DOMAIN: str = "" # CDN 自定义域名,空则用 COS 源站
|
OSS_BUCKET_DOMAIN: str = "" # 公开访问域名,如 https://miaosi-files.oss-cn-hangzhou.aliyuncs.com
|
||||||
|
|
||||||
# 邮件 SMTP
|
# 邮件 SMTP
|
||||||
SMTP_HOST: str = ""
|
SMTP_HOST: str = ""
|
||||||
|
|||||||
@ -5,7 +5,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.logging_config import setup_logging
|
from app.logging_config import setup_logging
|
||||||
from app.middleware.rate_limit import RateLimitMiddleware
|
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, profile, messages
|
from app.api import health, auth, upload, scripts, videos, tasks, rules, ai_config, sse, projects, briefs, organizations, dashboard, export
|
||||||
|
|
||||||
# Initialize logging
|
# Initialize logging
|
||||||
logger = setup_logging()
|
logger = setup_logging()
|
||||||
@ -72,8 +72,6 @@ app.include_router(briefs.router, prefix="/api/v1")
|
|||||||
app.include_router(organizations.router, prefix="/api/v1")
|
app.include_router(organizations.router, prefix="/api/v1")
|
||||||
app.include_router(dashboard.router, prefix="/api/v1")
|
app.include_router(dashboard.router, prefix="/api/v1")
|
||||||
app.include_router(export.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")
|
@app.on_event("startup")
|
||||||
|
|||||||
@ -12,7 +12,6 @@ from app.models.ai_config import AIConfig
|
|||||||
from app.models.review import ReviewTask, Platform
|
from app.models.review import ReviewTask, Platform
|
||||||
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
from app.models.rule import ForbiddenWord, WhitelistItem, Competitor
|
||||||
from app.models.audit_log import AuditLog
|
from app.models.audit_log import AuditLog
|
||||||
from app.models.message import Message
|
|
||||||
# 保留 Tenant 兼容旧代码,但新代码应使用 Brand
|
# 保留 Tenant 兼容旧代码,但新代码应使用 Brand
|
||||||
from app.models.tenant import Tenant
|
from app.models.tenant import Tenant
|
||||||
|
|
||||||
@ -46,8 +45,6 @@ __all__ = [
|
|||||||
"Competitor",
|
"Competitor",
|
||||||
# 审计日志
|
# 审计日志
|
||||||
"AuditLog",
|
"AuditLog",
|
||||||
# 消息
|
|
||||||
"Message",
|
|
||||||
# 兼容
|
# 兼容
|
||||||
"Tenant",
|
"Tenant",
|
||||||
]
|
]
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
"""
|
|
||||||
消息/通知模型
|
|
||||||
"""
|
|
||||||
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})>"
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
"""
|
|
||||||
消息相关 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
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
"""
|
|
||||||
用户资料相关 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)
|
|
||||||
@ -1,116 +0,0 @@
|
|||||||
"""
|
|
||||||
消息服务
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
腾讯云 COS 服务 — 表单直传签名
|
阿里云 OSS 服务
|
||||||
"""
|
"""
|
||||||
import time
|
import time
|
||||||
import hmac
|
import hmac
|
||||||
@ -17,109 +17,99 @@ def generate_upload_policy(
|
|||||||
upload_dir: Optional[str] = None,
|
upload_dir: Optional[str] = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
生成前端直传 COS 所需的 Policy 和签名
|
生成前端直传 OSS 所需的 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:
|
Returns:
|
||||||
{
|
{
|
||||||
"q_sign_algorithm": "sha1",
|
"accessKeyId": "...",
|
||||||
"q_ak": "SecretId",
|
|
||||||
"q_key_time": "{start};{end}",
|
|
||||||
"q_signature": "...",
|
|
||||||
"policy": "base64 encoded policy",
|
"policy": "base64 encoded policy",
|
||||||
"host": "https://bucket.cos.region.myqcloud.com",
|
"signature": "...",
|
||||||
|
"host": "https://bucket.oss-cn-hangzhou.aliyuncs.com",
|
||||||
"dir": "uploads/2026/02/",
|
"dir": "uploads/2026/02/",
|
||||||
"expire": 1234567890,
|
"expire": 1234567890
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
if not settings.COS_SECRET_ID or not settings.COS_SECRET_KEY:
|
if not settings.OSS_ACCESS_KEY_ID or not settings.OSS_ACCESS_KEY_SECRET:
|
||||||
raise ValueError("COS 配置未设置")
|
raise ValueError("OSS 配置未设置")
|
||||||
|
|
||||||
# 计算时间范围
|
# 计算过期时间
|
||||||
start_time = int(time.time())
|
expire_time = int(time.time()) + expire_seconds
|
||||||
end_time = start_time + expire_seconds
|
expire_date = datetime.utcfromtimestamp(expire_time).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
|
||||||
# key_time: "{start};{end}"
|
|
||||||
key_time = f"{start_time};{end_time}"
|
|
||||||
|
|
||||||
# 默认上传目录:uploads/年/月/
|
# 默认上传目录:uploads/年/月/
|
||||||
if upload_dir is None:
|
if upload_dir is None:
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
upload_dir = f"uploads/{now.year}/{now.month:02d}/"
|
upload_dir = f"uploads/{now.year}/{now.month:02d}/"
|
||||||
|
|
||||||
# 1. sign_key = HMAC-SHA1(secret_key, key_time)
|
# 构建 Policy
|
||||||
sign_key = hmac.new(
|
|
||||||
settings.COS_SECRET_KEY.encode(),
|
|
||||||
key_time.encode(),
|
|
||||||
hashlib.sha1,
|
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
# 2. 构建 Policy(COS 表单上传 Policy 格式)
|
|
||||||
policy_dict = {
|
policy_dict = {
|
||||||
"expiration": datetime.utcfromtimestamp(end_time).strftime(
|
"expiration": expire_date,
|
||||||
"%Y-%m-%dT%H:%M:%S.000Z"
|
|
||||||
),
|
|
||||||
"conditions": [
|
"conditions": [
|
||||||
{"bucket": settings.COS_BUCKET_NAME},
|
{"bucket": settings.OSS_BUCKET_NAME},
|
||||||
["starts-with", "$key", upload_dir],
|
["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],
|
["content-length-range", 0, max_size_mb * 1024 * 1024],
|
||||||
],
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
# 3. Base64 编码 Policy
|
# Base64 编码 Policy
|
||||||
policy_json = json.dumps(policy_dict)
|
policy_json = json.dumps(policy_dict)
|
||||||
policy_base64 = base64.b64encode(policy_json.encode()).decode()
|
policy_base64 = base64.b64encode(policy_json.encode()).decode()
|
||||||
|
|
||||||
# 4. string_to_sign = SHA1(policy_base64)
|
# 计算签名
|
||||||
string_to_sign = hashlib.sha1(policy_base64.encode()).hexdigest()
|
signature = base64.b64encode(
|
||||||
|
hmac.new(
|
||||||
# 5. signature = HMAC-SHA1(sign_key, string_to_sign)
|
settings.OSS_ACCESS_KEY_SECRET.encode(),
|
||||||
signature = hmac.new(
|
policy_base64.encode(),
|
||||||
sign_key.encode(),
|
hashlib.sha1
|
||||||
string_to_sign.encode(),
|
).digest()
|
||||||
hashlib.sha1,
|
).decode()
|
||||||
).hexdigest()
|
|
||||||
|
|
||||||
# 构建 Host
|
# 构建 Host
|
||||||
host = f"https://{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com"
|
host = settings.OSS_BUCKET_DOMAIN
|
||||||
|
if not host:
|
||||||
|
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"q_sign_algorithm": "sha1",
|
"accessKeyId": settings.OSS_ACCESS_KEY_ID,
|
||||||
"q_ak": settings.COS_SECRET_ID,
|
|
||||||
"q_key_time": key_time,
|
|
||||||
"q_signature": signature,
|
|
||||||
"policy": policy_base64,
|
"policy": policy_base64,
|
||||||
|
"signature": signature,
|
||||||
"host": host,
|
"host": host,
|
||||||
"dir": upload_dir,
|
"dir": upload_dir,
|
||||||
"expire": end_time,
|
"expire": expire_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:
|
def get_file_url(file_key: str) -> str:
|
||||||
"""
|
"""
|
||||||
获取文件的访问 URL
|
获取文件的公开访问 URL
|
||||||
|
|
||||||
优先使用 CDN 域名,否则用 COS 源站域名。
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file_key: 文件在 COS 中的 key,如 "uploads/2026/02/video.mp4"
|
file_key: 文件在 OSS 中的 key,如 "uploads/2026/02/video.mp4"
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
完整的访问 URL
|
完整的访问 URL
|
||||||
"""
|
"""
|
||||||
if settings.COS_CDN_DOMAIN:
|
host = settings.OSS_BUCKET_DOMAIN
|
||||||
host = settings.COS_CDN_DOMAIN
|
if not host:
|
||||||
else:
|
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
||||||
host = f"https://{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com"
|
|
||||||
|
|
||||||
# 确保 host 以 https:// 开头
|
# 确保 host 以 https:// 开头
|
||||||
if not host.startswith("http"):
|
if not host.startswith("http"):
|
||||||
@ -139,22 +129,26 @@ def parse_file_key_from_url(url: str) -> str:
|
|||||||
从完整 URL 解析出文件 key
|
从完整 URL 解析出文件 key
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
url: 完整的 COS URL
|
url: 完整的 OSS URL
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
文件 key
|
文件 key
|
||||||
"""
|
"""
|
||||||
# 尝试移除 CDN 域名
|
host = settings.OSS_BUCKET_DOMAIN
|
||||||
if settings.COS_CDN_DOMAIN:
|
if not host:
|
||||||
cdn = settings.COS_CDN_DOMAIN.rstrip("/")
|
host = f"https://{settings.OSS_BUCKET_NAME}.{settings.OSS_ENDPOINT}"
|
||||||
if not cdn.startswith("http"):
|
|
||||||
cdn = f"https://{cdn}"
|
|
||||||
if url.startswith(cdn):
|
|
||||||
return url[len(cdn):].lstrip("/")
|
|
||||||
|
|
||||||
# 尝试移除 COS 源站域名
|
# 移除 host 前缀
|
||||||
cos_host = f"https://{settings.COS_BUCKET_NAME}.cos.{settings.COS_REGION}.myqcloud.com"
|
if url.startswith(host):
|
||||||
if url.startswith(cos_host):
|
return url[len(host):].lstrip("/")
|
||||||
return url[len(cos_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]
|
||||||
|
|
||||||
return url
|
return url
|
||||||
|
|||||||
@ -6,11 +6,13 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
container_name: miaosi-postgres
|
container_name: miaosi-postgres
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
|
POSTGRES_PASSWORD: postgres
|
||||||
POSTGRES_DB: ${POSTGRES_DB:-miaosi}
|
POSTGRES_DB: miaosi
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/postgres:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@ -21,8 +23,10 @@ services:
|
|||||||
redis:
|
redis:
|
||||||
image: redis:7-alpine
|
image: redis:7-alpine
|
||||||
container_name: miaosi-redis
|
container_name: miaosi-redis
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/redis:/data
|
- redis_data:/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
@ -35,18 +39,21 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: miaosi-api
|
container_name: miaosi-api
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-miaosi}
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
env_file:
|
DEBUG: "true"
|
||||||
- .env
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./app:/app/app
|
||||||
- video_temp:/tmp/videos
|
- video_temp:/tmp/videos
|
||||||
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
|
||||||
|
|
||||||
# Celery Worker
|
# Celery Worker
|
||||||
celery-worker:
|
celery-worker:
|
||||||
@ -55,16 +62,15 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: miaosi-celery-worker
|
container_name: miaosi-celery-worker
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-miaosi}
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
volumes:
|
volumes:
|
||||||
|
- ./app:/app/app
|
||||||
- video_temp:/tmp/videos
|
- video_temp:/tmp/videos
|
||||||
command: celery -A app.celery_app worker -l info -Q default,review -c 2
|
command: celery -A app.celery_app worker -l info -Q default,review -c 2
|
||||||
|
|
||||||
@ -75,42 +81,15 @@ services:
|
|||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: miaosi-celery-beat
|
container_name: miaosi-celery-beat
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@postgres:5432/${POSTGRES_DB:-miaosi}
|
DATABASE_URL: postgresql+asyncpg://postgres:postgres@postgres:5432/miaosi
|
||||||
REDIS_URL: redis://redis:6379/0
|
REDIS_URL: redis://redis:6379/0
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
- celery-worker
|
||||||
condition: service_healthy
|
volumes:
|
||||||
celery-worker: {}
|
- ./app:/app/app
|
||||||
command: celery -A app.celery_app beat -l info
|
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:
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
redis_data:
|
||||||
video_temp:
|
video_temp:
|
||||||
|
|||||||
@ -1,50 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -20,7 +20,6 @@ dependencies = [
|
|||||||
"cryptography>=42.0.0",
|
"cryptography>=42.0.0",
|
||||||
"openai>=1.12.0",
|
"openai>=1.12.0",
|
||||||
"cachetools>=5.3.0",
|
"cachetools>=5.3.0",
|
||||||
"sse-starlette>=2.0.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
#!/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)] 备份完成"
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# Docker 容器入口脚本
|
|
||||||
# 先初始化数据库,再启动应用
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=== 秒思智能审核平台 - 启动中 ==="
|
|
||||||
|
|
||||||
# 运行数据库迁移
|
|
||||||
echo "运行数据库迁移..."
|
|
||||||
alembic upgrade head
|
|
||||||
|
|
||||||
# 填充种子数据
|
|
||||||
echo "填充种子数据..."
|
|
||||||
python -m scripts.seed
|
|
||||||
|
|
||||||
# 启动应用
|
|
||||||
echo "启动应用..."
|
|
||||||
exec "$@"
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
# 数据库初始化脚本
|
|
||||||
# 运行 Alembic 迁移 + 填充种子数据
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
echo "=== 数据库初始化 ==="
|
|
||||||
|
|
||||||
echo "1. 运行 Alembic 迁移..."
|
|
||||||
alembic upgrade head
|
|
||||||
|
|
||||||
echo "2. 填充种子数据..."
|
|
||||||
python -m scripts.seed
|
|
||||||
|
|
||||||
echo "=== 数据库初始化完成 ==="
|
|
||||||
@ -1,454 +0,0 @@
|
|||||||
"""
|
|
||||||
种子数据脚本
|
|
||||||
创建 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()
|
|
||||||
@ -23,10 +23,6 @@ sleep 5
|
|||||||
echo "运行数据库迁移..."
|
echo "运行数据库迁移..."
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
|
|
||||||
# 填充种子数据
|
|
||||||
echo "填充种子数据..."
|
|
||||||
python -m scripts.seed
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "=== 基础服务已启动 ==="
|
echo "=== 基础服务已启动 ==="
|
||||||
echo "PostgreSQL: localhost:5432"
|
echo "PostgreSQL: localhost:5432"
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
# --- API 地址 ---
|
# --- API 地址 ---
|
||||||
# 后端 API 基础 URL(浏览器端访问)
|
# 后端 API 基础 URL(浏览器端访问)
|
||||||
NEXT_PUBLIC_API_BASE_URL=https://your-domain.com
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||||
|
|
||||||
# --- Mock 模式 ---
|
# --- Mock 模式 ---
|
||||||
# 设为 true 使用前端 mock 数据(development 环境下默认开启)
|
# 设为 true 使用前端 mock 数据(development 环境下默认开启)
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
|
import { SuccessTag, WarningTag, ErrorTag, PendingTag } from '@/components/ui/Tag'
|
||||||
@ -288,40 +286,9 @@ const mockMessages: Message[] = [
|
|||||||
|
|
||||||
export default function AgencyMessagesPage() {
|
export default function AgencyMessagesPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [messages, setMessages] = useState<Message[]>(mockMessages)
|
const [messages, setMessages] = useState(mockMessages)
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [filter, setFilter] = useState<'all' | 'unread' | 'pending'>('all')
|
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 unreadCount = messages.filter(m => !m.read).length
|
||||||
const pendingAppealRequests = messages.filter(m => m.appealRequest?.status === 'pending').length
|
const pendingAppealRequests = messages.filter(m => m.appealRequest?.status === 'pending').length
|
||||||
const pendingReviewCount = messages.filter(m =>
|
const pendingReviewCount = messages.filter(m =>
|
||||||
@ -343,18 +310,12 @@ export default function AgencyMessagesPage() {
|
|||||||
|
|
||||||
const filteredMessages = getFilteredMessages()
|
const filteredMessages = getFilteredMessages()
|
||||||
|
|
||||||
const markAsRead = async (id: string) => {
|
const markAsRead = (id: string) => {
|
||||||
setMessages(prev => prev.map(m => m.id === id ? { ...m, read: true } : m))
|
setMessages(prev => prev.map(m => m.id === id ? { ...m, read: true } : m))
|
||||||
if (!USE_MOCK) {
|
|
||||||
try { await api.markMessageAsRead(id) } catch {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const markAllAsRead = async () => {
|
const markAllAsRead = () => {
|
||||||
setMessages(prev => prev.map(m => ({ ...m, read: true })))
|
setMessages(prev => prev.map(m => ({ ...m, read: true })))
|
||||||
if (!USE_MOCK) {
|
|
||||||
try { await api.markAllMessagesAsRead() } catch {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理申诉次数请求
|
// 处理申诉次数请求
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
|
||||||
import { api } from '@/lib/api'
|
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@ -205,13 +203,7 @@ export default function AgencyCompanyPage() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
if (USE_MOCK) {
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
// Mock 模式:模拟保存延迟
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
||||||
} else {
|
|
||||||
// TODO: 后端企业信息保存 API 待实现,暂时使用 mock 行为
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
||||||
}
|
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
setIsEditing(false)
|
setIsEditing(false)
|
||||||
toast.success('公司信息已保存')
|
toast.success('公司信息已保存')
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
@ -34,24 +32,6 @@ export default function AgencyProfileEditPage() {
|
|||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [copied, setCopied] = 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 () => {
|
const handleCopyId = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(formData.agencyId)
|
await navigator.clipboard.writeText(formData.agencyId)
|
||||||
@ -64,21 +44,7 @@ export default function AgencyProfileEditPage() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
if (USE_MOCK) {
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
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)
|
setIsSaving(false)
|
||||||
toast.success('个人信息已保存')
|
toast.success('个人信息已保存')
|
||||||
router.back()
|
router.back()
|
||||||
|
|||||||
@ -18,8 +18,6 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertTriangle
|
AlertTriangle
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
|
||||||
import { api } from '@/lib/api'
|
|
||||||
|
|
||||||
export default function AgencyAccountSettingsPage() {
|
export default function AgencyAccountSettingsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -55,25 +53,10 @@ export default function AgencyAccountSettingsPage() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
if (USE_MOCK) {
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
setIsSaving(false)
|
||||||
setIsSaving(false)
|
toast.success('密码修改成功')
|
||||||
toast.success('密码修改成功')
|
setPasswordForm({ oldPassword: '', newPassword: '', confirmPassword: '' })
|
||||||
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 (
|
return (
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
|
||||||
import { api } from '@/lib/api'
|
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
@ -119,13 +117,7 @@ export default function AgencyNotificationSettingsPage() {
|
|||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
if (USE_MOCK) {
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
// Mock 模式:模拟保存延迟
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
||||||
} else {
|
|
||||||
// TODO: 后端通知设置 API 待实现,暂时使用 mock 行为
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
||||||
}
|
|
||||||
setIsSaving(false)
|
setIsSaving(false)
|
||||||
toast.success('通知设置已保存')
|
toast.success('通知设置已保存')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
|
||||||
import { api } from '@/lib/api'
|
|
||||||
import { Card, CardContent } from '@/components/ui/Card'
|
import { Card, CardContent } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import {
|
import {
|
||||||
@ -226,37 +224,9 @@ const mockMessages: Message[] = [
|
|||||||
|
|
||||||
export default function BrandMessagesPage() {
|
export default function BrandMessagesPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [messages, setMessages] = useState<Message[]>(mockMessages)
|
const [messages, setMessages] = useState(mockMessages)
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [filter, setFilter] = useState<'all' | 'unread' | 'pending'>('all')
|
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 unreadCount = messages.filter(m => !m.read).length
|
||||||
const pendingReviewCount = messages.filter(m =>
|
const pendingReviewCount = messages.filter(m =>
|
||||||
!m.read && (m.type === 'agency_review_pass' || m.type === 'script_pending' || m.type === 'video_pending')
|
!m.read && (m.type === 'agency_review_pass' || m.type === 'script_pending' || m.type === 'video_pending')
|
||||||
@ -277,18 +247,12 @@ export default function BrandMessagesPage() {
|
|||||||
|
|
||||||
const filteredMessages = getFilteredMessages()
|
const filteredMessages = getFilteredMessages()
|
||||||
|
|
||||||
const markAsRead = async (id: string) => {
|
const markAsRead = (id: string) => {
|
||||||
setMessages(prev => prev.map(m => m.id === id ? { ...m, read: true } : m))
|
setMessages(prev => prev.map(m => m.id === id ? { ...m, read: true } : m))
|
||||||
if (!USE_MOCK) {
|
|
||||||
try { await api.markMessageAsRead(id) } catch {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const markAllAsRead = async () => {
|
const markAllAsRead = () => {
|
||||||
setMessages(prev => prev.map(m => ({ ...m, read: true })))
|
setMessages(prev => prev.map(m => ({ ...m, read: true })))
|
||||||
if (!USE_MOCK) {
|
|
||||||
try { await api.markAllMessagesAsRead() } catch {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMessageClick = (message: Message) => {
|
const handleMessageClick = (message: Message) => {
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState } from 'react'
|
||||||
import { Download, Calendar, Filter } from 'lucide-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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { Select } from '@/components/ui/Select'
|
import { Select } from '@/components/ui/Select'
|
||||||
@ -45,33 +43,9 @@ const platformOptions = [
|
|||||||
export default function ReportsPage() {
|
export default function ReportsPage() {
|
||||||
const [period, setPeriod] = useState('7d')
|
const [period, setPeriod] = useState('7d')
|
||||||
const [platform, setPlatform] = useState('all')
|
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 = reportData.reduce(
|
const summary = mockReportData.reduce(
|
||||||
(acc, day) => ({
|
(acc, day) => ({
|
||||||
totalSubmitted: acc.totalSubmitted + day.submitted,
|
totalSubmitted: acc.totalSubmitted + day.submitted,
|
||||||
totalPassed: acc.totalPassed + day.passed,
|
totalPassed: acc.totalPassed + day.passed,
|
||||||
@ -79,7 +53,7 @@ export default function ReportsPage() {
|
|||||||
}),
|
}),
|
||||||
{ totalSubmitted: 0, totalPassed: 0, totalFailed: 0 }
|
{ totalSubmitted: 0, totalPassed: 0, totalFailed: 0 }
|
||||||
)
|
)
|
||||||
const passRate = summary.totalSubmitted > 0 ? Math.round((summary.totalPassed / summary.totalSubmitted) * 100) : 0
|
const passRate = Math.round((summary.totalPassed / summary.totalSubmitted) * 100)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -153,7 +127,7 @@ export default function ReportsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{reportData.map((row) => (
|
{mockReportData.map((row) => (
|
||||||
<tr key={row.id} className="border-b last:border-0">
|
<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 font-medium text-gray-900">{row.date}</td>
|
||||||
<td className="py-3 text-gray-600">{row.submitted}</td>
|
<td className="py-3 text-gray-600">{row.submitted}</td>
|
||||||
@ -194,7 +168,7 @@ export default function ReportsPage() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{reviewRecords.map((record) => (
|
{mockReviewRecords.map((record) => (
|
||||||
<tr key={record.id} className="border-b last:border-0 hover:bg-gray-50">
|
<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 font-medium text-gray-900">{record.videoTitle}</td>
|
||||||
<td className="py-3 text-gray-600">{record.creator}</td>
|
<td className="py-3 text-gray-600">{record.creator}</td>
|
||||||
|
|||||||
@ -30,8 +30,6 @@ import {
|
|||||||
EyeOff,
|
EyeOff,
|
||||||
Phone
|
Phone
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
|
||||||
import { api } from '@/lib/api'
|
|
||||||
|
|
||||||
export default function BrandSettingsPage() {
|
export default function BrandSettingsPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -107,32 +105,14 @@ export default function BrandSettingsPage() {
|
|||||||
router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleChangePassword = async () => {
|
const handleChangePassword = () => {
|
||||||
if (passwordForm.new !== passwordForm.confirm) {
|
if (passwordForm.new !== passwordForm.confirm) {
|
||||||
toast.error('两次输入的密码不一致')
|
toast.error('两次输入的密码不一致')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!passwordForm.current || !passwordForm.new) {
|
toast.success('密码修改成功')
|
||||||
toast.error('请填写完整密码信息')
|
setShowPasswordModal(false)
|
||||||
return
|
setPasswordForm({ current: '', new: '', confirm: '' })
|
||||||
}
|
|
||||||
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 = () => {
|
const handleEnable2FA = () => {
|
||||||
|
|||||||
@ -1,9 +1,7 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
|
||||||
import { api } from '@/lib/api'
|
|
||||||
import {
|
import {
|
||||||
UserPlus,
|
UserPlus,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
@ -468,8 +466,7 @@ function SuccessModal({
|
|||||||
|
|
||||||
export default function CreatorMessagesPage() {
|
export default function CreatorMessagesPage() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const [messages, setMessages] = useState<Message[]>(mockMessages)
|
const [messages, setMessages] = useState(mockMessages)
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [confirmModal, setConfirmModal] = useState<{ isOpen: boolean; type: 'accept' | 'ignore'; messageId: string }>({
|
const [confirmModal, setConfirmModal] = useState<{ isOpen: boolean; type: 'accept' | 'ignore'; messageId: string }>({
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
type: 'accept',
|
type: 'accept',
|
||||||
@ -480,47 +477,14 @@ export default function CreatorMessagesPage() {
|
|||||||
message: '',
|
message: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const loadData = useCallback(async () => {
|
const markAsRead = (id: string) => {
|
||||||
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 =>
|
setMessages(prev => prev.map(msg =>
|
||||||
msg.id === id ? { ...msg, read: true } : msg
|
msg.id === id ? { ...msg, read: true } : msg
|
||||||
))
|
))
|
||||||
if (!USE_MOCK) {
|
|
||||||
try { await api.markMessageAsRead(id) } catch {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const markAllAsRead = async () => {
|
const markAllAsRead = () => {
|
||||||
setMessages(prev => prev.map(msg => ({ ...msg, read: true })))
|
setMessages(prev => prev.map(msg => ({ ...msg, read: true })))
|
||||||
if (!USE_MOCK) {
|
|
||||||
try { await api.markAllMessagesAsRead() } catch {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据消息类型跳转到对应页面
|
// 根据消息类型跳转到对应页面
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { ArrowLeft, Camera, Check, Copy } from 'lucide-react'
|
import { ArrowLeft, Camera, Check, Copy } from 'lucide-react'
|
||||||
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
import { ResponsiveLayout } from '@/components/layout/ResponsiveLayout'
|
||||||
@ -8,8 +8,6 @@ import { Button } from '@/components/ui/Button'
|
|||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useToast } from '@/components/ui/Toast'
|
import { useToast } from '@/components/ui/Toast'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
|
||||||
import { api } from '@/lib/api'
|
|
||||||
|
|
||||||
// 模拟用户数据
|
// 模拟用户数据
|
||||||
const mockUser = {
|
const mockUser = {
|
||||||
@ -34,29 +32,11 @@ export default function ProfileEditPage() {
|
|||||||
douyinAccount: mockUser.douyinAccount,
|
douyinAccount: mockUser.douyinAccount,
|
||||||
bio: mockUser.bio,
|
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
|
// 复制达人ID
|
||||||
const handleCopyId = async () => {
|
const handleCopyId = async () => {
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(creatorId)
|
await navigator.clipboard.writeText(mockUser.creatorId)
|
||||||
setIdCopied(true)
|
setIdCopied(true)
|
||||||
setTimeout(() => setIdCopied(false), 2000)
|
setTimeout(() => setIdCopied(false), 2000)
|
||||||
} catch {
|
} catch {
|
||||||
@ -72,22 +52,8 @@ export default function ProfileEditPage() {
|
|||||||
// 保存
|
// 保存
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
if (USE_MOCK) {
|
// 模拟保存
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
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)
|
setIsSaving(false)
|
||||||
router.back()
|
router.back()
|
||||||
}
|
}
|
||||||
@ -123,7 +89,7 @@ export default function ProfileEditPage() {
|
|||||||
background: 'linear-gradient(135deg, #6366F1 0%, #4F46E5 100%)',
|
background: 'linear-gradient(135deg, #6366F1 0%, #4F46E5 100%)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-[40px] font-bold text-white">{formData.name?.[0] || '?'}</span>
|
<span className="text-[40px] font-bold text-white">{mockUser.initial}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* 相机按钮 */}
|
{/* 相机按钮 */}
|
||||||
<button
|
<button
|
||||||
@ -152,7 +118,7 @@ export default function ProfileEditPage() {
|
|||||||
<label className="text-sm font-medium text-text-primary">达人ID</label>
|
<label className="text-sm font-medium text-text-primary">达人ID</label>
|
||||||
<div className="flex gap-3">
|
<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">
|
<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">{creatorId}</span>
|
<span className="font-mono font-medium text-accent-indigo">{mockUser.creatorId}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleCopyId}
|
onClick={handleCopyId}
|
||||||
|
|||||||
@ -18,9 +18,6 @@ import { Button } from '@/components/ui/Button'
|
|||||||
import { Input } from '@/components/ui/Input'
|
import { Input } from '@/components/ui/Input'
|
||||||
import { Modal } from '@/components/ui/Modal'
|
import { Modal } from '@/components/ui/Modal'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
|
||||||
import { api } from '@/lib/api'
|
|
||||||
import { useToast } from '@/components/ui/Toast'
|
|
||||||
|
|
||||||
// 模拟登录设备数据
|
// 模拟登录设备数据
|
||||||
const mockDevices = [
|
const mockDevices = [
|
||||||
@ -111,31 +108,12 @@ function ChangePasswordModal({
|
|||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
})
|
})
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const toast = useToast()
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (formData.newPassword !== formData.confirmPassword) {
|
|
||||||
toast.error('两次输入的密码不一致')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
setIsSaving(true)
|
setIsSaving(true)
|
||||||
if (USE_MOCK) {
|
await new Promise(resolve => setTimeout(resolve, 1500))
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500))
|
setIsSaving(false)
|
||||||
setIsSaving(false)
|
setStep(2)
|
||||||
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 = () => {
|
const handleClose = () => {
|
||||||
|
|||||||
@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
import { USE_MOCK } from '@/contexts/AuthContext'
|
|
||||||
import { api } from '@/lib/api'
|
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Mail,
|
Mail,
|
||||||
|
|||||||
@ -83,10 +83,6 @@ export function SSEProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 流正常结束(服务器关闭连接),5秒后重连
|
|
||||||
if (!controller.signal.aborted) {
|
|
||||||
reconnectTimerRef.current = setTimeout(connect, 5000)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof DOMException && err.name === 'AbortError') return
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
||||||
// 5秒后重连
|
// 5秒后重连
|
||||||
|
|||||||
@ -57,19 +57,17 @@ export function useOSSUpload(fileType: string = 'general'): UseOSSUploadReturn {
|
|||||||
setProgress(10)
|
setProgress(10)
|
||||||
const policy = await api.getUploadPolicy(fileType)
|
const policy = await api.getUploadPolicy(fileType)
|
||||||
|
|
||||||
// 2. 构建 COS 直传 FormData
|
// 2. 构建 OSS 直传 FormData
|
||||||
const fileKey = `${policy.dir}${Date.now()}_${file.name}`
|
const fileKey = `${policy.dir}${Date.now()}_${file.name}`
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('key', fileKey)
|
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('policy', policy.policy)
|
||||||
|
formData.append('OSSAccessKeyId', policy.access_key_id)
|
||||||
|
formData.append('signature', policy.signature)
|
||||||
formData.append('success_action_status', '200')
|
formData.append('success_action_status', '200')
|
||||||
formData.append('file', file)
|
formData.append('file', file)
|
||||||
|
|
||||||
// 3. 上传到 COS
|
// 3. 上传到 OSS
|
||||||
setProgress(30)
|
setProgress(30)
|
||||||
const xhr = new XMLHttpRequest()
|
const xhr = new XMLHttpRequest()
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
|||||||
@ -136,11 +136,9 @@ export interface RefreshTokenResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UploadPolicyResponse {
|
export interface UploadPolicyResponse {
|
||||||
q_sign_algorithm: string
|
access_key_id: string
|
||||||
q_ak: string
|
|
||||||
q_key_time: string
|
|
||||||
q_signature: string
|
|
||||||
policy: string
|
policy: string
|
||||||
|
signature: string
|
||||||
host: string
|
host: string
|
||||||
dir: string
|
dir: string
|
||||||
expire: number
|
expire: number
|
||||||
@ -155,92 +153,6 @@ export interface FileUploadedResponse {
|
|||||||
file_type: string
|
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 管理 ====================
|
// ==================== Token 管理 ====================
|
||||||
|
|
||||||
function getAccessToken(): string | null {
|
function getAccessToken(): string | null {
|
||||||
@ -426,7 +338,7 @@ class ApiClient {
|
|||||||
// ==================== 文件上传 ====================
|
// ==================== 文件上传 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 COS 上传凭证
|
* 获取 OSS 上传凭证
|
||||||
*/
|
*/
|
||||||
async getUploadPolicy(fileType: string = 'general'): Promise<UploadPolicyResponse> {
|
async getUploadPolicy(fileType: string = 'general'): Promise<UploadPolicyResponse> {
|
||||||
const response = await this.client.post<UploadPolicyResponse>('/upload/policy', {
|
const response = await this.client.post<UploadPolicyResponse>('/upload/policy', {
|
||||||
@ -891,64 +803,6 @@ class ApiClient {
|
|||||||
return response.data
|
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')
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 健康检查 ====================
|
// ==================== 健康检查 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user