feat: 修复 H5 底部导航覆盖 + 更新项目进度文档
## H5 底部导航修复 (Bug #10) - 精简 App.vue,移除重复 tabbar,仅保留全局样式 - uni-page 设置 height: calc(100% - 50px) + overflow-y: auto - 内容区域精确停在底部导航上方,独立滚动不再叠加 - 恢复 custom-tab-bar 组件 ## 项目进度文档 - PROGRESS.md 更新至 10 个 Bug 修复 - 新增 H5 底部导航修复记录 - 新增历史变更条目
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated
|
||||
from app.database import get_db
|
||||
from app.services.admin import AdminService
|
||||
from app.api.v1.deps import get_current_user
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
async def require_admin(current_user: dict = Depends(get_current_user)) -> dict:
|
||||
if current_user.get("role") != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin access required")
|
||||
return current_user
|
||||
|
||||
|
||||
@router.get("/dashboard")
|
||||
async def get_dashboard(
|
||||
_: dict = Depends(require_admin),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = AdminService(db)
|
||||
return await service.get_dashboard()
|
||||
|
||||
|
||||
@router.get("/users")
|
||||
async def list_users(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
_: dict = Depends(require_admin),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = AdminService(db)
|
||||
return await service.list_users(page, size)
|
||||
|
||||
|
||||
@router.patch("/users/{target_user_id}/tier")
|
||||
async def update_user_tier(
|
||||
target_user_id: str,
|
||||
data: dict,
|
||||
_: dict = Depends(require_admin),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = AdminService(db)
|
||||
tier = data.get("tier")
|
||||
if tier not in ("free", "pro", "enterprise"):
|
||||
raise HTTPException(status_code=400, detail="Invalid tier")
|
||||
success = await service.update_user_tier(target_user_id, tier)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return {"message": f"User tier updated to {tier}"}
|
||||
|
||||
|
||||
@router.post("/users/{target_user_id}/toggle-active")
|
||||
async def toggle_user_active(
|
||||
target_user_id: str,
|
||||
_: dict = Depends(require_admin),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = AdminService(db)
|
||||
success = await service.toggle_user_active(target_user_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
return {"message": "User active status toggled"}
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def system_health(
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = AdminService(db)
|
||||
return await service.get_system_health()
|
||||
@@ -0,0 +1,73 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated
|
||||
from app.database import get_db
|
||||
from app.services.analytics import AnalyticsService
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/customers")
|
||||
async def customer_analytics(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = AnalyticsService(db)
|
||||
return await service.get_customer_stats(user_id)
|
||||
|
||||
|
||||
@router.get("/translations")
|
||||
async def translation_analytics(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = AnalyticsService(db)
|
||||
return await service.get_translation_stats(user_id)
|
||||
|
||||
|
||||
@router.get("/quotations")
|
||||
async def quotation_analytics(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = AnalyticsService(db)
|
||||
return await service.get_quotation_stats(user_id)
|
||||
|
||||
|
||||
@router.get("/messages")
|
||||
async def message_analytics(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = AnalyticsService(db)
|
||||
return await service.get_message_stats(user_id)
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
async def overview(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = AnalyticsService(db)
|
||||
customers = await service.get_customer_stats(user_id)
|
||||
translations = await service.get_translation_stats(user_id)
|
||||
quotations = await service.get_quotation_stats(user_id)
|
||||
messages = await service.get_message_stats(user_id)
|
||||
marketing = await service.get_marketing_stats(user_id)
|
||||
return {
|
||||
"customers": customers,
|
||||
"translations": translations,
|
||||
"quotations": quotations,
|
||||
"messages": messages,
|
||||
"marketing": marketing,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/marketing")
|
||||
async def marketing_analytics(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = AnalyticsService(db)
|
||||
return await service.get_marketing_stats(user_id)
|
||||
@@ -1,13 +1,14 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Header
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Annotated
|
||||
from typing import Annotated, Optional
|
||||
import uuid
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.core.security import hash_password, verify_password, create_access_token, create_refresh_token, decode_token
|
||||
from pydantic import BaseModel, EmailStr
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -30,7 +31,7 @@ class RefreshRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register(data: RegisterRequest, db: Annotated[AsyncSession, Depends(get_db)]):
|
||||
async def register(data: RegisterRequest, db: Annotated[AsyncSession, Depends(get_db)] = None):
|
||||
existing = await db.execute(select(User).where(User.phone == data.phone))
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail="Phone already registered")
|
||||
@@ -49,13 +50,14 @@ async def register(data: RegisterRequest, db: Annotated[AsyncSession, Depends(ge
|
||||
"phone": user.phone,
|
||||
"username": user.username,
|
||||
"tier": user.tier,
|
||||
"role": user.role,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/login", response_model=LoginResponse)
|
||||
async def login(
|
||||
form: Annotated[OAuth2PasswordRequestForm, Depends()],
|
||||
db: Annotated[AsyncSession, Depends(get_db)],
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
result = await db.execute(select(User).where(User.phone == form.username))
|
||||
user = result.scalar_one_or_none()
|
||||
@@ -67,7 +69,7 @@ async def login(
|
||||
)
|
||||
|
||||
return LoginResponse(
|
||||
access_token=create_access_token({"sub": str(user.id), "tier": user.tier}),
|
||||
access_token=create_access_token({"sub": str(user.id), "tier": user.tier, "role": user.role}),
|
||||
refresh_token=create_refresh_token({"sub": str(user.id)}),
|
||||
user={
|
||||
"id": str(user.id),
|
||||
@@ -78,6 +80,29 @@ async def login(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login/guest")
|
||||
async def guest_login():
|
||||
guest_id = f"guest_{uuid.uuid4().hex[:12]}"
|
||||
access_token = create_access_token(
|
||||
{"sub": guest_id, "tier": "guest", "role": "guest", "is_guest": True},
|
||||
expires_delta=timedelta(hours=24)
|
||||
)
|
||||
refresh_token = create_refresh_token({"sub": guest_id, "is_guest": True})
|
||||
|
||||
return LoginResponse(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
user={
|
||||
"id": guest_id,
|
||||
"phone": None,
|
||||
"username": "游客用户",
|
||||
"tier": "guest",
|
||||
"is_guest": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh")
|
||||
async def refresh(data: RefreshRequest):
|
||||
payload = decode_token(data.refresh_token)
|
||||
@@ -92,7 +117,7 @@ async def refresh(data: RefreshRequest):
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(
|
||||
authorization: str = None,
|
||||
authorization: Optional[str] = Header(None, alias="Authorization"),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
@@ -112,6 +137,7 @@ async def get_me(
|
||||
"phone": user.phone,
|
||||
"username": user.username,
|
||||
"tier": user.tier,
|
||||
"role": user.role,
|
||||
"settings": user.settings,
|
||||
"created_at": user.created_at.isoformat() if user.created_at else None,
|
||||
}
|
||||
@@ -124,10 +150,50 @@ class SettingsUpdate(BaseModel):
|
||||
languages: list = None
|
||||
|
||||
|
||||
class WeChatLoginRequest(BaseModel):
|
||||
code: str
|
||||
encrypted_data: str = ""
|
||||
iv: str = ""
|
||||
|
||||
|
||||
@router.post("/wechat-login")
|
||||
async def wechat_login(data: WeChatLoginRequest, db: Annotated[AsyncSession, Depends(get_db)] = None):
|
||||
from app.services.wechat import wechat_service
|
||||
|
||||
session = await wechat_service.code2session(data.code)
|
||||
if not session:
|
||||
raise HTTPException(status_code=400, detail="WeChat login failed")
|
||||
|
||||
openid = session.get("openid")
|
||||
result = await db.execute(select(User).where(User.wechat_openid == openid))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if not user:
|
||||
user = User(
|
||||
wechat_openid=openid,
|
||||
username=f"wx_{openid[-8:]}",
|
||||
tier="free",
|
||||
)
|
||||
db.add(user)
|
||||
await db.flush()
|
||||
|
||||
return LoginResponse(
|
||||
access_token=create_access_token({"sub": str(user.id), "tier": user.tier, "role": user.role}),
|
||||
refresh_token=create_refresh_token({"sub": str(user.id)}),
|
||||
user={
|
||||
"id": str(user.id),
|
||||
"phone": user.phone,
|
||||
"username": user.username,
|
||||
"tier": user.tier,
|
||||
"role": user.role,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/settings")
|
||||
async def update_settings(
|
||||
data: SettingsUpdate,
|
||||
authorization: str = None,
|
||||
authorization: Optional[str] = Header(None, alias="Authorization"),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated, Optional
|
||||
from typing import Annotated, Optional, List
|
||||
from app.database import get_db
|
||||
from app.services.customer import CustomerService
|
||||
from app.services.customer_health import CustomerHealthService
|
||||
from app.services.import_service import import_service
|
||||
from app.services import export
|
||||
from app.core.security import decode_token
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
|
||||
@@ -87,6 +90,95 @@ async def delete_customer(
|
||||
return {"message": "Customer deleted"}
|
||||
|
||||
|
||||
@router.post("/import")
|
||||
async def import_customers(
|
||||
file: UploadFile = File(...),
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
from app.workers.tasks import process_customer_import
|
||||
|
||||
content = await file.read()
|
||||
filename = file.filename or ""
|
||||
|
||||
if filename.endswith(".xlsx"):
|
||||
records, parse_errors = import_service.parse_xlsx(content)
|
||||
elif filename.endswith(".csv"):
|
||||
records, parse_errors = import_service.parse_csv(content)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="Unsupported file format. Use .xlsx or .csv")
|
||||
|
||||
if parse_errors and not records:
|
||||
raise HTTPException(status_code=400, detail=f"Parse failed: {'; '.join(parse_errors)}")
|
||||
|
||||
valid, validation_errors = import_service.validate_records(records)
|
||||
all_errors = parse_errors + validation_errors
|
||||
imported_count = 0
|
||||
|
||||
for record in valid:
|
||||
try:
|
||||
svc = CustomerService(db)
|
||||
await svc.create_customer(user_id, record)
|
||||
imported_count += 1
|
||||
except Exception as e:
|
||||
all_errors.append(f"Import failed for {record.get('name', 'unknown')}: {str(e)}")
|
||||
|
||||
return {
|
||||
"imported": imported_count,
|
||||
"total": len(records),
|
||||
"errors": all_errors,
|
||||
"filename": filename,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/export/csv")
|
||||
async def export_customers(
|
||||
status: Optional[str] = None,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = CustomerService(db)
|
||||
result = await service.list_customers(user_id, status, 1, 9999)
|
||||
items = result.get("items", [])
|
||||
csv_bytes = export.export_customers_csv(items)
|
||||
return Response(
|
||||
content=csv_bytes,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=customers.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/health-overview")
|
||||
async def get_health_overview(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = CustomerHealthService(db)
|
||||
return await service.get_health_overview(user_id)
|
||||
|
||||
|
||||
@router.get("/health-scores")
|
||||
async def get_all_health_scores(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = CustomerHealthService(db)
|
||||
return {"items": await service.get_all_health_scores(user_id)}
|
||||
|
||||
|
||||
@router.get("/{customer_id}/health")
|
||||
async def get_customer_health(
|
||||
customer_id: str,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = CustomerHealthService(db)
|
||||
health = await service.get_customer_health(user_id, customer_id)
|
||||
if not health:
|
||||
raise HTTPException(status_code=404, detail="Customer not found")
|
||||
return health
|
||||
|
||||
|
||||
@router.get("/{customer_id}/conversation")
|
||||
async def get_conversation(
|
||||
customer_id: str,
|
||||
|
||||
@@ -1,13 +1,43 @@
|
||||
from fastapi import HTTPException, Depends
|
||||
from fastapi import HTTPException, Depends, Header
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from app.core.security import decode_token
|
||||
from typing import Optional
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user_id(authorization: str = None) -> str:
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
async def get_current_user_id(
|
||||
authorization: Optional[str] = Header(None, alias="Authorization"),
|
||||
cred: Optional[HTTPAuthorizationCredentials] = Depends(security),
|
||||
) -> str:
|
||||
token = None
|
||||
if cred:
|
||||
token = cred.credentials
|
||||
elif authorization and authorization.startswith("Bearer "):
|
||||
token = authorization[7:]
|
||||
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Missing or invalid token")
|
||||
|
||||
payload = decode_token(authorization[7:])
|
||||
payload = decode_token(token)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
return payload.get("sub")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
cred: HTTPAuthorizationCredentials = Depends(security),
|
||||
) -> dict:
|
||||
if not cred:
|
||||
raise HTTPException(status_code=401, detail="Missing or invalid token")
|
||||
|
||||
payload = decode_token(cred.credentials)
|
||||
if not payload:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
return {
|
||||
"id": payload.get("sub"),
|
||||
"tier": payload.get("tier", "free"),
|
||||
"role": payload.get("role", "user"),
|
||||
}
|
||||
|
||||
@@ -1,26 +1,9 @@
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
from app.services.exchange import ExchangeRateService
|
||||
from datetime import datetime
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class ExchangeRateResponse(BaseModel):
|
||||
from_currency: str
|
||||
to_currency: str
|
||||
rate: float
|
||||
updated_at: str
|
||||
|
||||
|
||||
EXCHANGE_RATES = {
|
||||
("USD", "CNY"): 7.24,
|
||||
("EUR", "CNY"): 7.85,
|
||||
("GBP", "CNY"): 9.15,
|
||||
("CNY", "USD"): 0.138,
|
||||
("USD", "EUR"): 0.92,
|
||||
("EUR", "USD"): 1.09,
|
||||
("GBP", "USD"): 1.27,
|
||||
("USD", "GBP"): 0.79,
|
||||
}
|
||||
service = ExchangeRateService()
|
||||
|
||||
|
||||
@router.get("/convert")
|
||||
@@ -29,26 +12,25 @@ async def convert_currency(
|
||||
to_currency: str = "CNY",
|
||||
amount: float = 1.0,
|
||||
):
|
||||
rate = EXCHANGE_RATES.get((from_currency, to_currency), 1.0)
|
||||
rate = await service.get_rate(from_currency, to_currency)
|
||||
if rate is None:
|
||||
return {"error": f"No rate available for {from_currency} -> {to_currency}"}
|
||||
|
||||
return {
|
||||
"from_currency": from_currency,
|
||||
"to_currency": to_currency,
|
||||
"from_currency": from_currency.upper(),
|
||||
"to_currency": to_currency.upper(),
|
||||
"amount": amount,
|
||||
"converted": round(amount * rate, 2),
|
||||
"rate": rate,
|
||||
"updated_at": "2026-05-08T00:00:00Z",
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/rates")
|
||||
async def get_rates(base: str = "USD"):
|
||||
rates = {}
|
||||
for (from_curr, to_curr), rate in EXCHANGE_RATES.items():
|
||||
if from_curr == base:
|
||||
rates[to_curr] = rate
|
||||
|
||||
rates = await service.get_all_rates(base)
|
||||
return {
|
||||
"base": base,
|
||||
"base": base.upper(),
|
||||
"rates": rates,
|
||||
"updated_at": "2026-05-08T00:00:00Z",
|
||||
}
|
||||
"updated_at": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.models.feedback import Feedback
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class FeedbackRequest(BaseModel):
|
||||
category: str = "general"
|
||||
content: str
|
||||
contact: str = ""
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def submit_feedback(
|
||||
data: FeedbackRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
if not data.content.strip():
|
||||
raise HTTPException(status_code=400, detail="Content is required")
|
||||
|
||||
fb = Feedback(
|
||||
user_id=user_id,
|
||||
category=data.category,
|
||||
content=data.content.strip(),
|
||||
contact=data.contact.strip(),
|
||||
)
|
||||
db.add(fb)
|
||||
await db.flush()
|
||||
return {"status": "ok", "id": str(fb.id)}
|
||||
@@ -0,0 +1,89 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated, Optional
|
||||
from app.database import get_db
|
||||
from app.services.followup_engine import FollowupEngine
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/strategies")
|
||||
async def list_strategies(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
engine = FollowupEngine(db)
|
||||
await engine.ensure_default_strategies()
|
||||
return {"strategies": await engine.get_strategies()}
|
||||
|
||||
|
||||
@router.get("/pending")
|
||||
async def get_pending_followups(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
engine = FollowupEngine(db)
|
||||
return await engine.get_pending_followups(user_id, page, size)
|
||||
|
||||
|
||||
@router.get("/logs")
|
||||
async def get_followup_logs(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
engine = FollowupEngine(db)
|
||||
return await engine.get_followup_logs(user_id, page, size)
|
||||
|
||||
|
||||
@router.post("/{log_id}/send")
|
||||
async def mark_followup_sent(
|
||||
log_id: str,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
engine = FollowupEngine(db)
|
||||
success = await engine.mark_sent(user_id, log_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Followup log not found")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/{log_id}/edit")
|
||||
async def edit_and_send_followup(
|
||||
log_id: str,
|
||||
body: dict,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
edited_text = body.get("edited_text", "")
|
||||
if not edited_text:
|
||||
raise HTTPException(status_code=400, detail="edited_text is required")
|
||||
engine = FollowupEngine(db)
|
||||
success = await engine.mark_edited(user_id, log_id, edited_text)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Followup log not found")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
async def get_followup_stats(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
engine = FollowupEngine(db)
|
||||
return await engine.get_stats(user_id)
|
||||
|
||||
|
||||
@router.post("/scan")
|
||||
async def trigger_followup_scan(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
engine = FollowupEngine(db)
|
||||
result = await engine.scan_and_followup()
|
||||
return result
|
||||
@@ -0,0 +1,102 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated
|
||||
from app.database import get_db
|
||||
from app.services.preference import UserPreferenceService
|
||||
from app.services.marketing_effect import MarketingEffectService
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/select")
|
||||
async def record_selection(
|
||||
data: dict,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
message_id = data.get("message_id")
|
||||
selected_index = data.get("selected_index")
|
||||
if message_id is None or selected_index is None:
|
||||
raise HTTPException(status_code=400, detail="message_id and selected_index required")
|
||||
service = UserPreferenceService(db)
|
||||
success = await service.record_selection(user_id, message_id, selected_index)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/edit")
|
||||
async def record_edit(
|
||||
data: dict,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
message_id = data.get("message_id")
|
||||
edited_text = data.get("edited_text")
|
||||
if not message_id or edited_text is None:
|
||||
raise HTTPException(status_code=400, detail="message_id and edited_text required")
|
||||
service = UserPreferenceService(db)
|
||||
success = await service.record_edit(user_id, message_id, edited_text)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Message not found")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/analyze")
|
||||
async def analyze_preferences(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = UserPreferenceService(db)
|
||||
preferences = await service.analyze_preferences(user_id)
|
||||
return preferences
|
||||
|
||||
|
||||
@router.get("/preferences")
|
||||
async def get_preferences(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = UserPreferenceService(db)
|
||||
return await service.get_analysis(user_id)
|
||||
|
||||
|
||||
@router.post("/marketing-effect")
|
||||
async def track_marketing_effect(
|
||||
data: dict,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = MarketingEffectService(db)
|
||||
result = await service.track_event(
|
||||
user_id=user_id,
|
||||
content=data.get("content", ""),
|
||||
product_id=data.get("product_id"),
|
||||
product_name=data.get("product_name"),
|
||||
channel=data.get("channel", "copy"),
|
||||
event_type=data.get("event_type", "copy"),
|
||||
target_audience=data.get("target_audience", ""),
|
||||
metadata=data.get("metadata"),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/marketing-effects")
|
||||
async def get_marketing_effects(
|
||||
page: int = 1,
|
||||
size: int = 20,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = MarketingEffectService(db)
|
||||
return await service.get_effects(user_id, page, size)
|
||||
|
||||
|
||||
@router.get("/marketing-effects/stats")
|
||||
async def get_marketing_effect_stats(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = MarketingEffectService(db)
|
||||
return await service.get_stats(user_id)
|
||||
@@ -1,8 +1,12 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Optional
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from typing import Optional, Annotated
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.services.marketing import MarketingService
|
||||
from app.services.preference import UserPreferenceService
|
||||
from app.core.security import decode_token
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
from app.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
@@ -36,11 +40,15 @@ class CompetitorRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("/generate")
|
||||
async def generate_marketing(data: MarketingRequest, authorization: str = None):
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Missing token")
|
||||
|
||||
async def generate_marketing(
|
||||
data: MarketingRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = MarketingService()
|
||||
pref_service = UserPreferenceService(db)
|
||||
pref_context = await pref_service.get_preference_context(user_id, "marketing")
|
||||
|
||||
product_info = {
|
||||
"name": data.product_name,
|
||||
"description": data.description,
|
||||
@@ -48,7 +56,7 @@ async def generate_marketing(data: MarketingRequest, authorization: str = None):
|
||||
"price": data.price,
|
||||
"keywords": data.keywords,
|
||||
}
|
||||
results = await service.generate(product_info, data.target, data.style, data.language, data.count)
|
||||
results = await service.generate(product_info, data.target, data.style, data.language, data.count, pref_context)
|
||||
|
||||
return {
|
||||
"results": results,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated, Optional
|
||||
from app.database import get_db
|
||||
from app.services.notification import NotificationService
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_notifications(
|
||||
page: int = Query(1, ge=1),
|
||||
size: int = Query(20, ge=1, le=100),
|
||||
unread_only: bool = Query(False),
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = NotificationService(db)
|
||||
return await service.list_notifications(user_id, page, size, unread_only)
|
||||
|
||||
|
||||
@router.get("/unread-count")
|
||||
async def unread_count(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = NotificationService(db)
|
||||
count = await service.get_unread_count(user_id)
|
||||
return {"count": count}
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/read")
|
||||
async def mark_read(
|
||||
notification_id: str,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = NotificationService(db)
|
||||
success = await service.mark_read(user_id, notification_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/read-all")
|
||||
async def mark_all_read(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = NotificationService(db)
|
||||
count = await service.mark_all_read(user_id)
|
||||
return {"status": "ok", "count": count}
|
||||
|
||||
|
||||
@router.delete("/{notification_id}")
|
||||
async def delete_notification(
|
||||
notification_id: str,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = NotificationService(db)
|
||||
success = await service.delete_notification(user_id, notification_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Notification not found")
|
||||
return {"status": "ok"}
|
||||
@@ -0,0 +1,43 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.services.onboarding import OnboardingService
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class OnboardingRequest(BaseModel):
|
||||
name: str
|
||||
description: str
|
||||
category: str = ""
|
||||
target: str = "US importers"
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = OnboardingService(db)
|
||||
return await service.check_status(user_id)
|
||||
|
||||
|
||||
@router.post("/product")
|
||||
async def create_first_product(
|
||||
data: OnboardingRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
if not data.name.strip():
|
||||
raise HTTPException(status_code=400, detail="Product name is required")
|
||||
service = OnboardingService(db)
|
||||
return await service.generate_first_product(
|
||||
user_id=user_id,
|
||||
name=data.name.strip(),
|
||||
description=data.description.strip(),
|
||||
category=data.category,
|
||||
target=data.target,
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.services.payment import PaymentService
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreateOrderRequest(BaseModel):
|
||||
plan: str
|
||||
|
||||
|
||||
class PaymentCallbackRequest(BaseModel):
|
||||
payment_id: str
|
||||
success: bool
|
||||
|
||||
|
||||
@router.get("/plans")
|
||||
async def get_plans():
|
||||
svc = PaymentService(None)
|
||||
return await svc.get_plans()
|
||||
|
||||
|
||||
@router.get("/subscription")
|
||||
async def get_subscription(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
svc = PaymentService(db)
|
||||
return await svc.get_current_subscription(user_id)
|
||||
|
||||
|
||||
@router.post("/create-order")
|
||||
async def create_order(
|
||||
data: CreateOrderRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
svc = PaymentService(db)
|
||||
try:
|
||||
return await svc.create_order(user_id, data.plan)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/callback")
|
||||
async def payment_callback(
|
||||
data: PaymentCallbackRequest,
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
svc = PaymentService(db)
|
||||
success = await svc.handle_payment_callback(data.payment_id, data.success)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Order not found")
|
||||
return {"status": "ok"}
|
||||
+41
-125
@@ -1,147 +1,63 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Optional, List
|
||||
from typing import Annotated, Optional
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.models.user import User
|
||||
from app.core.security import decode_token
|
||||
from app.services.push import PushService
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DeviceRegister(BaseModel):
|
||||
class DeviceRegisterRequest(BaseModel):
|
||||
client_id: str
|
||||
platform: Optional[str] = None
|
||||
platform: str = "weapp"
|
||||
push_token: Optional[str] = None
|
||||
device_info: Optional[dict] = None
|
||||
|
||||
|
||||
class PushMessage(BaseModel):
|
||||
title: str
|
||||
content: str
|
||||
payload: Optional[dict] = None
|
||||
target_type: str = "all"
|
||||
target_value: Optional[str] = None
|
||||
|
||||
|
||||
class PushResponse(BaseModel):
|
||||
success: bool
|
||||
message_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
# 模拟存储的设备信息(实际应存数据库)
|
||||
devices_db = {}
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
async def register_device(
|
||||
data: DeviceRegister,
|
||||
authorization: str = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
data: DeviceRegisterRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
return {"error": "Unauthorized"}, 401
|
||||
|
||||
payload = decode_token(authorization[7:])
|
||||
if not payload:
|
||||
return {"error": "Invalid token"}, 401
|
||||
|
||||
user_id = payload.get("sub")
|
||||
|
||||
if user_id not in devices_db:
|
||||
devices_db[user_id] = []
|
||||
|
||||
existing = [d for d in devices_db[user_id] if d.get("client_id") == data.client_id]
|
||||
if not existing:
|
||||
devices_db[user_id].append({
|
||||
"client_id": data.client_id,
|
||||
"platform": data.platform,
|
||||
"device_info": data.device_info,
|
||||
})
|
||||
|
||||
return {"success": True, "message": "Device registered"}
|
||||
|
||||
|
||||
@router.post("/send")
|
||||
async def send_push(
|
||||
message: PushMessage,
|
||||
authorization: str = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
return {"error": "Unauthorized"}, 401
|
||||
|
||||
payload = decode_token(authorization[7:])
|
||||
if not payload:
|
||||
return {"error": "Invalid token"}, 401
|
||||
|
||||
user_id = payload.get("sub")
|
||||
|
||||
user_devices = devices_db.get(user_id, [])
|
||||
if not user_devices:
|
||||
return PushResponse(success=False, error="No devices registered")
|
||||
|
||||
# 实际项目中这里调用 uni-push/极光等API
|
||||
# 模拟返回成功
|
||||
message_id = f"msg_{user_id}_{int(payload.get('iat', 0))}"
|
||||
|
||||
print(f"Push message to user {user_id}: {message.title} - {message.content}")
|
||||
|
||||
return PushResponse(success=True, message_id=message_id)
|
||||
|
||||
|
||||
@router.post("/send-to-customer")
|
||||
async def send_to_customer(
|
||||
customer_id: str,
|
||||
title: str,
|
||||
content: str,
|
||||
payload: Optional[dict] = None,
|
||||
authorization: str = None,
|
||||
):
|
||||
"""
|
||||
针对特定客户的推送通知
|
||||
例如:客户沉默提醒、报价提醒等
|
||||
"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
return {"error": "Unauthorized"}, 401
|
||||
|
||||
payload_data = decode_token(authorization[7:])
|
||||
if not payload_data:
|
||||
return {"error": "Invalid token"}, 401
|
||||
|
||||
user_id = payload_data.get("sub")
|
||||
|
||||
# 这里可以添加针对客户的特定逻辑
|
||||
notification = {
|
||||
"type": "customer_alert",
|
||||
"customer_id": customer_id,
|
||||
"title": title,
|
||||
"content": content,
|
||||
"payload": payload or {}
|
||||
service = PushService(db)
|
||||
device = await service.register_device(
|
||||
user_id=user_id,
|
||||
client_id=data.client_id,
|
||||
platform=data.platform,
|
||||
push_token=data.push_token,
|
||||
device_info=data.device_info,
|
||||
)
|
||||
return {
|
||||
"success": True,
|
||||
"device_id": str(device.id),
|
||||
"message": "Device registered",
|
||||
}
|
||||
|
||||
print(f"Customer notification for user {user_id}, customer {customer_id}: {title}")
|
||||
|
||||
return PushResponse(success=True, message_id=f"alert_{customer_id}")
|
||||
@router.post("/unregister")
|
||||
async def unregister_device(
|
||||
data: dict,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
client_id = data.get("client_id")
|
||||
if not client_id:
|
||||
raise HTTPException(status_code=400, detail="client_id required")
|
||||
service = PushService(db)
|
||||
success = await service.unregister_device(user_id, client_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Device not found")
|
||||
return {"success": True, "message": "Device unregistered"}
|
||||
|
||||
|
||||
@router.get("/devices")
|
||||
async def list_devices(
|
||||
authorization: str = None,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
"""列出用户已注册的设备"""
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
return {"error": "Unauthorized"}, 401
|
||||
|
||||
payload = decode_token(authorization[7:])
|
||||
if not payload:
|
||||
return {"error": "Invalid token"}, 401
|
||||
|
||||
user_id = payload.get("sub")
|
||||
user_devices = devices_db.get(user_id, [])
|
||||
|
||||
return {
|
||||
"devices": user_devices,
|
||||
"count": len(user_devices)
|
||||
}
|
||||
service = PushService(db)
|
||||
devices = await service.get_user_devices(user_id)
|
||||
return {"devices": devices, "count": len(devices)}
|
||||
|
||||
@@ -1,13 +1,39 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated, Optional
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.services.quotation import QuotationService
|
||||
from app.services.pdf_generator import pdf_generator
|
||||
from app.services import export
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
from app.models.quotation import Quotation
|
||||
from app.models.customer import Customer
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class InquiryRequest(BaseModel):
|
||||
inquiry_text: str
|
||||
customer_id: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/generate-from-inquiry")
|
||||
async def generate_from_inquiry(
|
||||
data: InquiryRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = QuotationService(db)
|
||||
result = await service.generate_from_inquiry(
|
||||
user_id=user_id,
|
||||
inquiry_text=data.inquiry_text,
|
||||
customer_id=data.customer_id,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_quotation(
|
||||
data: dict,
|
||||
@@ -58,3 +84,78 @@ async def update_quotation_status(
|
||||
if not quotation:
|
||||
raise HTTPException(status_code=404, detail="Quotation not found")
|
||||
return quotation
|
||||
|
||||
|
||||
@router.get("/export/csv")
|
||||
async def export_quotations(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = QuotationService(db)
|
||||
result = await service.list_quotations(user_id, 1, 9999)
|
||||
items = result.get("items", [])
|
||||
csv_bytes = export.export_quotations_csv(items)
|
||||
return Response(
|
||||
content=csv_bytes,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=quotations.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{quotation_id}/pdf")
|
||||
async def export_quotation_pdf(
|
||||
quotation_id: str,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = QuotationService(db)
|
||||
quotation = await service.get_quotation(user_id, quotation_id)
|
||||
if not quotation:
|
||||
raise HTTPException(status_code=404, detail="Quotation not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(Customer).where(Customer.id == quotation["customer_id"])
|
||||
)
|
||||
customer = result.scalar_one_or_none()
|
||||
|
||||
pdf_data = pdf_generator.generate_quotation({
|
||||
"quotation_number": f"{quotation_id[:8].upper()}",
|
||||
"customer_name": customer.name if customer else "",
|
||||
"customer_company": customer.company if customer else "",
|
||||
"customer_country": customer.country if customer else "",
|
||||
"date": quotation["created_at"][:10] if quotation.get("created_at") else "",
|
||||
"valid_until": quotation.get("valid_until", ""),
|
||||
"currency": quotation.get("currency", "USD"),
|
||||
"items": quotation.get("items", []),
|
||||
"subtotal": quotation.get("subtotal", 0),
|
||||
"discount": quotation.get("discount", 0),
|
||||
"shipping": quotation.get("shipping", 0),
|
||||
"total": quotation.get("total", 0),
|
||||
"payment_terms": quotation.get("payment_terms", ""),
|
||||
"delivery_terms": quotation.get("delivery_terms", ""),
|
||||
"lead_time": quotation.get("lead_time", ""),
|
||||
"notes": quotation.get("notes", ""),
|
||||
})
|
||||
|
||||
if not pdf_data:
|
||||
raise HTTPException(status_code=501, detail="PDF generation not available (weasyprint not installed)")
|
||||
|
||||
service = QuotationService(db)
|
||||
result = await db.execute(
|
||||
select(Quotation).where(
|
||||
and_(Quotation.id == quotation_id, Quotation.user_id == user_id)
|
||||
)
|
||||
)
|
||||
q = result.scalar_one_or_none()
|
||||
if q:
|
||||
pdf_url = f"/quotations/{quotation_id}/pdf"
|
||||
q.pdf_url = pdf_url
|
||||
await db.flush()
|
||||
|
||||
return Response(
|
||||
content=pdf_data,
|
||||
media_type="application/pdf",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="quotation-{quotation_id[:8]}.pdf"',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated
|
||||
from app.database import get_db
|
||||
from app.services.silent_pattern import SilentPatternService
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/risk-analysis")
|
||||
async def get_silent_risk_analysis(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = SilentPatternService(db)
|
||||
risks = await service.analyze_silent_risk(user_id)
|
||||
return {
|
||||
"items": risks,
|
||||
"total": len(risks),
|
||||
"high_risk": len([r for r in risks if r["risk_level"] == "high"]),
|
||||
"medium_risk": len([r for r in risks if r["risk_level"] == "medium"]),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{customer_id}/suggestions")
|
||||
async def get_followup_suggestions(
|
||||
customer_id: str,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = SilentPatternService(db)
|
||||
suggestions = await service.get_suggestions(user_id, customer_id)
|
||||
return {"customer_id": customer_id, "suggestions": suggestions}
|
||||
@@ -0,0 +1,117 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated, Optional
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.services.team import TeamService
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class CreateTeamRequest(BaseModel):
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class InviteRequest(BaseModel):
|
||||
user_id: str
|
||||
|
||||
|
||||
class UpdateRoleRequest(BaseModel):
|
||||
role: str
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_team(
|
||||
data: CreateTeamRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = TeamService(db)
|
||||
try:
|
||||
team = await service.create_team(user_id, data.name, data.description)
|
||||
return team
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_teams(
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = TeamService(db)
|
||||
return {"teams": await service.list_user_teams(user_id)}
|
||||
|
||||
|
||||
@router.get("/{team_id}")
|
||||
async def get_team(
|
||||
team_id: str,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = TeamService(db)
|
||||
team = await service.get_team(team_id, user_id)
|
||||
if not team:
|
||||
raise HTTPException(status_code=404, detail="Team not found")
|
||||
return team
|
||||
|
||||
|
||||
@router.post("/{team_id}/invite")
|
||||
async def invite_member(
|
||||
team_id: str,
|
||||
data: InviteRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = TeamService(db)
|
||||
try:
|
||||
result = await service.invite_member(team_id, user_id, data.user_id)
|
||||
return result
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
|
||||
@router.delete("/{team_id}/members/{member_id}")
|
||||
async def remove_member(
|
||||
team_id: str,
|
||||
member_id: str,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = TeamService(db)
|
||||
success = await service.remove_member(team_id, user_id, member_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Member not found or not removable")
|
||||
return {"message": "Member removed"}
|
||||
|
||||
|
||||
@router.post("/{team_id}/leave")
|
||||
async def leave_team(
|
||||
team_id: str,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = TeamService(db)
|
||||
success = await service.leave_team(team_id, user_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail="Cannot leave as owner or not a member")
|
||||
return {"message": "Left team"}
|
||||
|
||||
|
||||
@router.patch("/{team_id}/members/{member_id}/role")
|
||||
async def update_member_role(
|
||||
team_id: str,
|
||||
member_id: str,
|
||||
data: UpdateRoleRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
service = TeamService(db)
|
||||
if data.role not in ("admin", "member", "viewer"):
|
||||
raise HTTPException(status_code=400, detail="Invalid role")
|
||||
success = await service.update_role(team_id, user_id, member_id, data.role)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Member not found or not updatable")
|
||||
return {"message": "Role updated"}
|
||||
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated
|
||||
from app.database import get_db
|
||||
from app.services.corpus_trainer import CorpusTrainer
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/corpus/run")
|
||||
async def run_corpus_training(
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
trainer = CorpusTrainer(db)
|
||||
result = await trainer.run_pipeline()
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/corpus/embeddings")
|
||||
async def compute_embeddings(
|
||||
batch_size: int = 50,
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
trainer = CorpusTrainer(db)
|
||||
result = await trainer.compute_embeddings(batch_size)
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/corpus/stats")
|
||||
async def corpus_stats(
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
trainer = CorpusTrainer(db)
|
||||
return await trainer.get_stats()
|
||||
|
||||
|
||||
@router.post("/corpus/deduplicate")
|
||||
async def deduplicate_corpus(
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
trainer = CorpusTrainer(db)
|
||||
result = await trainer.deduplicate()
|
||||
return result
|
||||
@@ -1,8 +1,13 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import APIRouter, HTTPException, Response, Depends
|
||||
from typing import Optional, Dict, Any, Annotated
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from app.database import get_db
|
||||
from app.services.translation import TranslationService
|
||||
from app.services.tts import tts_service
|
||||
from app.services.preference import UserPreferenceService
|
||||
from app.core.security import decode_token
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -27,13 +32,10 @@ class ExtractRequest(BaseModel):
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def translate_text(data: TranslateRequest, authorization: str = None):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing token")
|
||||
|
||||
payload = decode_token(authorization[7:])
|
||||
user_id = payload.get("sub") if payload else None
|
||||
|
||||
async def translate_text(
|
||||
data: TranslateRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
):
|
||||
service = TranslationService()
|
||||
result = await service.translate(
|
||||
text=data.text,
|
||||
@@ -46,9 +48,13 @@ async def translate_text(data: TranslateRequest, authorization: str = None):
|
||||
|
||||
|
||||
@router.post("/reply")
|
||||
async def generate_reply(data: ReplyRequest, authorization: str = None):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing token")
|
||||
async def generate_reply(
|
||||
data: ReplyRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
pref_service = UserPreferenceService(db)
|
||||
pref_context = await pref_service.get_preference_context(user_id, "reply")
|
||||
|
||||
service = TranslationService()
|
||||
results = await service.generate_reply(
|
||||
@@ -56,25 +62,65 @@ async def generate_reply(data: ReplyRequest, authorization: str = None):
|
||||
context=data.context,
|
||||
tone=data.tone,
|
||||
count=data.count,
|
||||
preference_context=pref_context,
|
||||
)
|
||||
return {"suggestions": results, "inquiry": data.inquiry, "count": len(results)}
|
||||
|
||||
|
||||
@router.post("/extract")
|
||||
async def extract_info(data: ExtractRequest, authorization: str = None):
|
||||
if not authorization or not authorization.startswith("Bearer "):
|
||||
raise HTTPException(status_code=401, detail="Missing token")
|
||||
|
||||
async def extract_info(
|
||||
data: ExtractRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
):
|
||||
service = TranslationService()
|
||||
result = await service.extract_info(data.text, data.extract_type)
|
||||
return {"extracted": result, "type": data.extract_type}
|
||||
|
||||
|
||||
@router.post("/feedback")
|
||||
async def feedback(data: dict, authorization: str = None):
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Missing token")
|
||||
class TTSRequest(BaseModel):
|
||||
text: str
|
||||
lang: str = "en"
|
||||
rate: str = "0%"
|
||||
pitch: str = "0Hz"
|
||||
|
||||
|
||||
@router.post("/tts")
|
||||
async def text_to_speech(
|
||||
data: TTSRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
):
|
||||
audio = await tts_service.synthesize(data.text, data.lang, data.rate, data.pitch)
|
||||
if not audio:
|
||||
raise HTTPException(status_code=501, detail="TTS not available (edge-tts not installed or synthesis failed)")
|
||||
|
||||
return Response(content=audio, media_type="audio/mpeg", headers={
|
||||
"Content-Disposition": f'attachment; filename="tts-{data.lang}.mp3"',
|
||||
})
|
||||
|
||||
|
||||
@router.get("/tts")
|
||||
async def text_to_speech_get(
|
||||
text: str = "",
|
||||
lang: str = "en",
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
):
|
||||
if not text.strip():
|
||||
raise HTTPException(status_code=400, detail="Text is required")
|
||||
|
||||
audio = await tts_service.synthesize(text, lang)
|
||||
if not audio:
|
||||
raise HTTPException(status_code=501, detail="TTS not available")
|
||||
|
||||
return Response(content=audio, media_type="audio/mpeg", headers={
|
||||
"Content-Disposition": f'attachment; filename="tts-{lang}.mp3"',
|
||||
})
|
||||
|
||||
|
||||
@router.post("/feedback")
|
||||
async def feedback(
|
||||
data: dict,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
):
|
||||
from app.ai.trade_corpus import TradeCorpus
|
||||
corpus = TradeCorpus()
|
||||
|
||||
@@ -84,3 +130,26 @@ async def feedback(data: dict, authorization: str = None):
|
||||
await corpus.rate_entry(entry_id, rating)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
public_router = APIRouter(tags=["translate-public"])
|
||||
|
||||
|
||||
@public_router.post("/translate")
|
||||
async def public_translate(data: TranslateRequest):
|
||||
service = TranslationService()
|
||||
result = await service.translate(
|
||||
text=data.text,
|
||||
target_lang=data.target_lang,
|
||||
source_lang=data.source_lang,
|
||||
context=data.context,
|
||||
user_id=None,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@public_router.post("/extract")
|
||||
async def public_extract(data: ExtractRequest):
|
||||
service = TranslationService()
|
||||
result = await service.extract_info(data.text, data.extract_type)
|
||||
return {"extracted": result, "type": data.extract_type}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends
|
||||
from fastapi import APIRouter, Request, HTTPException, Depends, Header
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from typing import Annotated
|
||||
from sqlalchemy import select, and_
|
||||
from typing import Annotated, Optional
|
||||
from pydantic import BaseModel
|
||||
from app.database import get_db
|
||||
from app.services.whatsapp import WhatsAppService
|
||||
from app.services.customer import CustomerService
|
||||
from app.services.translation import TranslationService
|
||||
from app.core.security import decode_token
|
||||
from app.api.v1.deps import get_current_user_id
|
||||
from app.config import settings
|
||||
from app.models.customer import Customer
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -26,35 +27,92 @@ async def verify_webhook(
|
||||
|
||||
|
||||
@router.post("/webhook")
|
||||
async def handle_webhook(request: Request, db: Annotated[AsyncSession, Depends(get_db)] = None):
|
||||
async def handle_webhook(
|
||||
request: Request,
|
||||
x_hub_signature_256: Optional[str] = Header(None),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
svc = WhatsAppService()
|
||||
body = await request.json()
|
||||
body = await request.body()
|
||||
|
||||
msg_data = svc.parse_webhook(body)
|
||||
if x_hub_signature_256:
|
||||
if not svc.verify_signature(body, x_hub_signature_256):
|
||||
raise HTTPException(status_code=403, detail="Invalid signature")
|
||||
|
||||
import json
|
||||
body_json = json.loads(body)
|
||||
msg_data = svc.parse_webhook(body_json)
|
||||
if not msg_data:
|
||||
return {"status": "ok"}
|
||||
|
||||
# TODO: Route to correct user based on WhatsApp number
|
||||
# For MVP, handle as generic incoming message
|
||||
from_number = msg_data.get("from")
|
||||
text = msg_data.get("text", "")
|
||||
|
||||
if from_number:
|
||||
result = await db.execute(
|
||||
select(Customer).where(Customer.whatsapp_id == from_number)
|
||||
)
|
||||
customer = result.scalar_one_or_none()
|
||||
|
||||
if customer:
|
||||
user_id = str(customer.user_id)
|
||||
cust_svc = CustomerService(db)
|
||||
await cust_svc.save_message(
|
||||
user_id=user_id,
|
||||
customer_id=str(customer.id),
|
||||
direction="inbound",
|
||||
content=text,
|
||||
)
|
||||
|
||||
return {"status": "ok", "message": "received"}
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
to: str
|
||||
text: str = ""
|
||||
template_name: Optional[str] = None
|
||||
template_params: Optional[dict] = None
|
||||
media_url: Optional[str] = None
|
||||
media_type: Optional[str] = None
|
||||
|
||||
|
||||
@router.post("/send")
|
||||
async def send_message(
|
||||
data: dict,
|
||||
data: SendMessageRequest,
|
||||
user_id: str = Depends(get_current_user_id),
|
||||
db: Annotated[AsyncSession, Depends(get_db)] = None,
|
||||
):
|
||||
text = data.get("text")
|
||||
to = data.get("to")
|
||||
if not text or not to:
|
||||
raise HTTPException(status_code=400, detail="text and to are required")
|
||||
|
||||
svc = WhatsAppService()
|
||||
sent = await svc.send_text(to, text)
|
||||
|
||||
sent = False
|
||||
if data.template_name and data.template_params:
|
||||
sent = await svc.send_template(data.to, data.template_name, data.template_params)
|
||||
elif data.media_url and data.media_type:
|
||||
sent = await svc.send_media(data.to, data.media_url, data.media_type, caption=data.text)
|
||||
elif data.text:
|
||||
sent = await svc.send_text(data.to, data.text)
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="text, template, or media required")
|
||||
|
||||
if not sent:
|
||||
raise HTTPException(status_code=500, detail="Failed to send WhatsApp message")
|
||||
|
||||
return {"status": "sent", "to": to}
|
||||
cust_svc = CustomerService(db)
|
||||
result = await db.execute(
|
||||
select(Customer).where(
|
||||
and_(Customer.whatsapp_id == data.to, Customer.user_id == user_id)
|
||||
)
|
||||
)
|
||||
customer = result.scalar_one_or_none()
|
||||
if customer:
|
||||
await cust_svc.save_message(
|
||||
user_id=user_id,
|
||||
customer_id=str(customer.id),
|
||||
direction="outbound",
|
||||
content=data.text or f"[{data.media_type or 'template'}]",
|
||||
)
|
||||
|
||||
return {"status": "sent", "to": data.to}
|
||||
|
||||
|
||||
@router.get("/qr")
|
||||
|
||||
Reference in New Issue
Block a user