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:
TradeMate Dev
2026-05-12 20:24:42 +08:00
parent 69e164dcae
commit 7b62c2f8b4
125 changed files with 19725 additions and 728 deletions
+72
View File
@@ -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()
+73
View File
@@ -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)
+74 -8
View File
@@ -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 "):
+94 -2
View File
@@ -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,
+34 -4
View File
@@ -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"),
}
+14 -32
View File
@@ -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(),
}
+35
View File
@@ -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)}
+89
View File
@@ -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
+102
View File
@@ -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)
+15 -7
View File
@@ -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,
+66
View File
@@ -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"}
+43
View File
@@ -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,
)
+58
View File
@@ -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
View File
@@ -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)}
+102 -1
View File
@@ -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"',
},
)
+34
View File
@@ -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}
+117
View File
@@ -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"}
+44
View File
@@ -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
+89 -20
View File
@@ -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}
+76 -18
View File
@@ -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")