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,129 @@
|
||||
from typing import Dict, Any, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from app.models.user import User
|
||||
from app.models.team import Team, TeamMember
|
||||
from app.models.analytics import UsageLog
|
||||
from app.models.customer import Customer
|
||||
from app.models.quotation import Quotation
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdminService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_dashboard(self) -> Dict[str, Any]:
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
user_count = await self.db.execute(select(func.count(User.id)))
|
||||
team_count = await self.db.execute(select(func.count(Team.id)))
|
||||
customer_count = await self.db.execute(select(func.count(Customer.id)))
|
||||
quotation_count = await self.db.execute(select(func.count(Quotation.id)))
|
||||
|
||||
today_logs = await self.db.execute(
|
||||
select(func.count(UsageLog.id)).where(UsageLog.created_at >= today_start)
|
||||
)
|
||||
total_logs = await self.db.execute(select(func.count(UsageLog.id)))
|
||||
|
||||
recent_users_result = await self.db.execute(
|
||||
select(User).order_by(User.created_at.desc()).limit(5)
|
||||
)
|
||||
recent_users = recent_users_result.scalars().all()
|
||||
|
||||
return {
|
||||
"users": {
|
||||
"total": user_count.scalar() or 0,
|
||||
},
|
||||
"teams": {
|
||||
"total": team_count.scalar() or 0,
|
||||
},
|
||||
"customers": {
|
||||
"total": customer_count.scalar() or 0,
|
||||
},
|
||||
"quotations": {
|
||||
"total": quotation_count.scalar() or 0,
|
||||
},
|
||||
"usage": {
|
||||
"today": today_logs.scalar() or 0,
|
||||
"total": total_logs.scalar() or 0,
|
||||
},
|
||||
"recent_users": [
|
||||
{
|
||||
"id": str(u.id),
|
||||
"username": u.username,
|
||||
"tier": u.tier,
|
||||
"is_active": u.is_active,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
}
|
||||
for u in recent_users
|
||||
],
|
||||
}
|
||||
|
||||
async def list_users(self, page: int = 1, size: int = 20) -> Dict[str, Any]:
|
||||
query = select(User).order_by(User.created_at.desc()).offset((page - 1) * size).limit(size)
|
||||
count_query = select(func.count(User.id))
|
||||
|
||||
total = await self.db.execute(count_query)
|
||||
result = await self.db.execute(query)
|
||||
users = result.scalars().all()
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": str(u.id),
|
||||
"username": u.username,
|
||||
"phone": u.phone,
|
||||
"tier": u.tier,
|
||||
"is_active": u.is_active,
|
||||
"created_at": u.created_at.isoformat() if u.created_at else None,
|
||||
}
|
||||
for u in users
|
||||
],
|
||||
"total": total.scalar(),
|
||||
"page": page,
|
||||
"size": size,
|
||||
}
|
||||
|
||||
async def update_user_tier(self, user_id: str, tier: str) -> bool:
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return False
|
||||
user.tier = tier
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
async def toggle_user_active(self, user_id: str) -> bool:
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return False
|
||||
user.is_active = not user.is_active
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
async def get_system_health(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"status": "healthy",
|
||||
"version": "1.0.0",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
}
|
||||
|
||||
async def log_usage(self, user_id: str, action: str, detail: Dict = None, ip: str = None, ua: str = None):
|
||||
try:
|
||||
log = UsageLog(
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
detail=detail or {},
|
||||
ip_address=ip,
|
||||
user_agent=ua,
|
||||
)
|
||||
self.db.add(log)
|
||||
await self.db.flush()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to log usage: {e}")
|
||||
@@ -0,0 +1,191 @@
|
||||
from typing import Dict, Any, List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, extract
|
||||
from app.models.customer import Customer, Conversation, Message
|
||||
from app.models.quotation import Quotation
|
||||
from app.models.analytics import UsageLog
|
||||
from app.models.user import User
|
||||
from app.models.preference import MarketingEffect
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AnalyticsService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_customer_stats(self, user_id: str) -> Dict[str, Any]:
|
||||
total = await self.db.execute(
|
||||
select(func.count(Customer.id)).where(Customer.user_id == user_id)
|
||||
)
|
||||
by_status = await self.db.execute(
|
||||
select(Customer.status, func.count(Customer.id))
|
||||
.where(Customer.user_id == user_id)
|
||||
.group_by(Customer.status)
|
||||
)
|
||||
by_country = await self.db.execute(
|
||||
select(Customer.country, func.count(Customer.id))
|
||||
.where(Customer.user_id == user_id)
|
||||
.where(Customer.country.isnot(None))
|
||||
.group_by(Customer.country)
|
||||
.order_by(func.count(Customer.id).desc())
|
||||
.limit(10)
|
||||
)
|
||||
|
||||
now = datetime.utcnow()
|
||||
silent_3 = await self.db.execute(
|
||||
select(func.count(Customer.id)).where(
|
||||
and_(
|
||||
Customer.user_id == user_id,
|
||||
Customer.last_contact_at.isnot(None),
|
||||
Customer.last_contact_at < now - timedelta(days=3),
|
||||
Customer.status.in_(["lead", "negotiating"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total.scalar() or 0,
|
||||
"by_status": {row[0] or "unknown": row[1] for row in by_status.all()},
|
||||
"by_country": {row[0] or "unknown": row[1] for row in by_country.all()},
|
||||
"silent_customers": silent_3.scalar() or 0,
|
||||
}
|
||||
|
||||
async def get_translation_stats(self, user_id: str) -> Dict[str, Any]:
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
today_count = await self.db.execute(
|
||||
select(func.count(UsageLog.id)).where(
|
||||
and_(
|
||||
UsageLog.user_id == user_id,
|
||||
UsageLog.action == "translate",
|
||||
UsageLog.created_at >= today_start,
|
||||
)
|
||||
)
|
||||
)
|
||||
total_count = await self.db.execute(
|
||||
select(func.count(UsageLog.id)).where(
|
||||
and_(UsageLog.user_id == user_id, UsageLog.action == "translate")
|
||||
)
|
||||
)
|
||||
|
||||
daily_result = await self.db.execute(
|
||||
select(
|
||||
extract("year", UsageLog.created_at),
|
||||
extract("month", UsageLog.created_at),
|
||||
extract("day", UsageLog.created_at),
|
||||
func.count(UsageLog.id),
|
||||
)
|
||||
.where(
|
||||
and_(
|
||||
UsageLog.user_id == user_id,
|
||||
UsageLog.action == "translate",
|
||||
UsageLog.created_at >= now - timedelta(days=30),
|
||||
)
|
||||
)
|
||||
.group_by(
|
||||
extract("year", UsageLog.created_at),
|
||||
extract("month", UsageLog.created_at),
|
||||
extract("day", UsageLog.created_at),
|
||||
)
|
||||
.order_by(
|
||||
extract("year", UsageLog.created_at),
|
||||
extract("month", UsageLog.created_at),
|
||||
extract("day", UsageLog.created_at),
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"today": today_count.scalar() or 0,
|
||||
"total": total_count.scalar() or 0,
|
||||
"daily": [
|
||||
{
|
||||
"date": f"{int(r[0])}-{int(r[1]):02d}-{int(r[2]):02d}",
|
||||
"count": r[3],
|
||||
}
|
||||
for r in daily_result.all()
|
||||
],
|
||||
}
|
||||
|
||||
async def get_quotation_stats(self, user_id: str) -> Dict[str, Any]:
|
||||
total = await self.db.execute(
|
||||
select(func.count(Quotation.id)).where(Quotation.user_id == user_id)
|
||||
)
|
||||
by_status = await self.db.execute(
|
||||
select(Quotation.status, func.count(Quotation.id))
|
||||
.where(Quotation.user_id == user_id)
|
||||
.group_by(Quotation.status)
|
||||
)
|
||||
total_value = await self.db.execute(
|
||||
select(func.sum(Quotation.total)).where(
|
||||
and_(Quotation.user_id == user_id, Quotation.status == "accepted")
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total.scalar() or 0,
|
||||
"by_status": {row[0] or "draft": row[1] for row in by_status.all()},
|
||||
"total_accepted_value": float(total_value.scalar() or 0),
|
||||
}
|
||||
|
||||
async def get_message_stats(self, user_id: str) -> Dict[str, Any]:
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
total_msgs = await self.db.execute(
|
||||
select(func.count(Message.id))
|
||||
.join(Conversation, Message.conversation_id == Conversation.id)
|
||||
.where(Conversation.user_id == user_id)
|
||||
)
|
||||
today_msgs = await self.db.execute(
|
||||
select(func.count(Message.id))
|
||||
.join(Conversation, Message.conversation_id == Conversation.id)
|
||||
.where(
|
||||
and_(
|
||||
Conversation.user_id == user_id,
|
||||
Message.created_at >= today_start,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
"total": total_msgs.scalar() or 0,
|
||||
"today": today_msgs.scalar() or 0,
|
||||
}
|
||||
|
||||
async def get_marketing_stats(self, user_id: str) -> Dict[str, Any]:
|
||||
total = await self.db.execute(
|
||||
select(func.count(MarketingEffect.id)).where(MarketingEffect.user_id == user_id)
|
||||
)
|
||||
copy_count = await self.db.execute(
|
||||
select(func.count(MarketingEffect.id)).where(
|
||||
and_(MarketingEffect.user_id == user_id, MarketingEffect.event_type == "copy")
|
||||
)
|
||||
)
|
||||
send_count = await self.db.execute(
|
||||
select(func.count(MarketingEffect.id)).where(
|
||||
and_(MarketingEffect.user_id == user_id, MarketingEffect.event_type == "send")
|
||||
)
|
||||
)
|
||||
top_products = await self.db.execute(
|
||||
select(MarketingEffect.product_name, func.count(MarketingEffect.id))
|
||||
.where(
|
||||
and_(
|
||||
MarketingEffect.user_id == user_id,
|
||||
MarketingEffect.product_name.isnot(None),
|
||||
)
|
||||
)
|
||||
.group_by(MarketingEffect.product_name)
|
||||
.order_by(func.count(MarketingEffect.id).desc())
|
||||
.limit(5)
|
||||
)
|
||||
|
||||
return {
|
||||
"total_events": total.scalar() or 0,
|
||||
"copy_count": copy_count.scalar() or 0,
|
||||
"send_count": send_count.scalar() or 0,
|
||||
"top_products": [{"name": r[0], "count": r[1]} for r in top_products.all()],
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from sqlalchemy import select, func, and_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CorpusTrainer:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def compute_embeddings(self, batch_size: int = 50) -> Dict[str, Any]:
|
||||
from app.models.corpus import CorpusEntry
|
||||
|
||||
result = await self.db.execute(
|
||||
select(CorpusEntry).where(CorpusEntry.embedding.is_(None)).limit(batch_size)
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
|
||||
updated = 0
|
||||
for entry in entries:
|
||||
try:
|
||||
embedding = await self._generate_embedding(entry.source_text)
|
||||
if embedding:
|
||||
entry.embedding = embedding
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Embedding failed for entry {entry.id}: {e}")
|
||||
|
||||
await self.db.flush()
|
||||
return {"processed": len(entries), "updated": updated}
|
||||
|
||||
async def score_entries(self, batch_size: int = 100) -> Dict[str, Any]:
|
||||
from app.models.corpus import CorpusEntry
|
||||
|
||||
result = await self.db.execute(
|
||||
select(CorpusEntry)
|
||||
.where(CorpusEntry.quality_score.is_(None))
|
||||
.limit(batch_size)
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
|
||||
updated = 0
|
||||
for entry in entries:
|
||||
score = self._calculate_quality_score(entry)
|
||||
entry.quality_score = score
|
||||
updated += 1
|
||||
|
||||
await self.db.flush()
|
||||
return {"processed": len(entries), "updated": updated}
|
||||
|
||||
async def deduplicate(self) -> Dict[str, Any]:
|
||||
from app.models.corpus import CorpusEntry
|
||||
|
||||
subquery = (
|
||||
select(
|
||||
CorpusEntry.source_text,
|
||||
CorpusEntry.task_type,
|
||||
func.min(CorpusEntry.id).label("keep_id"),
|
||||
)
|
||||
.group_by(CorpusEntry.source_text, CorpusEntry.task_type)
|
||||
.having(func.count(CorpusEntry.id) > 1)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(CorpusEntry).where(
|
||||
and_(
|
||||
CorpusEntry.source_text == subquery.c.source_text,
|
||||
CorpusEntry.task_type == subquery.c.task_type,
|
||||
CorpusEntry.id != subquery.c.keep_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
duplicates = result.scalars().all()
|
||||
|
||||
for dup in duplicates:
|
||||
await self.db.delete(dup)
|
||||
|
||||
await self.db.flush()
|
||||
return {"duplicates_removed": len(duplicates)}
|
||||
|
||||
async def prune_low_quality(self, min_score: float = 0.2, max_age_days: int = 90) -> Dict[str, Any]:
|
||||
from app.models.corpus import CorpusEntry
|
||||
|
||||
cutoff = datetime.utcnow() - timedelta(days=max_age_days)
|
||||
result = await self.db.execute(
|
||||
select(CorpusEntry).where(
|
||||
and_(
|
||||
CorpusEntry.quality_score < min_score,
|
||||
CorpusEntry.created_at < cutoff,
|
||||
CorpusEntry.usage_count.is_(None) | (CorpusEntry.usage_count < 2),
|
||||
)
|
||||
)
|
||||
)
|
||||
entries = result.scalars().all()
|
||||
|
||||
for e in entries:
|
||||
await self.db.delete(e)
|
||||
|
||||
await self.db.flush()
|
||||
return {"pruned": len(entries)}
|
||||
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
from app.models.corpus import CorpusEntry
|
||||
|
||||
total = await self.db.execute(select(func.count(CorpusEntry.id)))
|
||||
by_type = await self.db.execute(
|
||||
select(CorpusEntry.task_type, func.count(CorpusEntry.id))
|
||||
.group_by(CorpusEntry.task_type)
|
||||
)
|
||||
with_embeddings = await self.db.execute(
|
||||
select(func.count(CorpusEntry.id)).where(CorpusEntry.embedding.isnot(None))
|
||||
)
|
||||
high_quality = await self.db.execute(
|
||||
select(func.count(CorpusEntry.id)).where(CorpusEntry.quality_score >= 0.7)
|
||||
)
|
||||
low_quality = await self.db.execute(
|
||||
select(func.count(CorpusEntry.id)).where(CorpusEntry.quality_score < 0.3)
|
||||
)
|
||||
|
||||
return {
|
||||
"total_entries": total.scalar() or 0,
|
||||
"by_task_type": {row[0]: row[1] for row in by_type.all()},
|
||||
"with_embeddings": with_embeddings.scalar() or 0,
|
||||
"high_quality": high_quality.scalar() or 0,
|
||||
"low_quality": low_quality.scalar() or 0,
|
||||
}
|
||||
|
||||
async def run_pipeline(self) -> Dict[str, Any]:
|
||||
dedup_result = await self.deduplicate()
|
||||
score_result = await self.score_entries()
|
||||
embed_result = await self.compute_embeddings()
|
||||
prune_result = await self.prune_low_quality()
|
||||
stats = await self.get_stats()
|
||||
|
||||
return {
|
||||
"deduplication": dedup_result,
|
||||
"scoring": score_result,
|
||||
"embeddings": embed_result,
|
||||
"pruning": prune_result,
|
||||
"stats": stats,
|
||||
}
|
||||
|
||||
def _calculate_quality_score(self, entry) -> float:
|
||||
score = 0.5
|
||||
|
||||
if entry.user_rating:
|
||||
score = entry.user_rating / 5.0
|
||||
|
||||
if entry.user_edited:
|
||||
score = max(score - 0.1, 0)
|
||||
|
||||
if entry.usage_count and entry.usage_count > 5:
|
||||
score = min(score + 0.15, 1.0)
|
||||
|
||||
src_len = len(entry.source_text) if entry.source_text else 0
|
||||
tgt_len = len(entry.target_text) if entry.target_text else 0
|
||||
if src_len > 10 and tgt_len > 10:
|
||||
score = min(score + 0.1, 1.0)
|
||||
if src_len < 3 or tgt_len < 3:
|
||||
score = max(score - 0.3, 0)
|
||||
|
||||
return round(score, 2)
|
||||
|
||||
async def _generate_embedding(self, text: str) -> Optional[List[float]]:
|
||||
try:
|
||||
from app.config import settings
|
||||
import httpx
|
||||
|
||||
if settings.OPENAI_API_KEY:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
"https://api.openai.com/v1/embeddings",
|
||||
headers={"Authorization": f"Bearer {settings.OPENAI_API_KEY}"},
|
||||
json={"model": "text-embedding-3-small", "input": text[:8000]},
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return data["data"][0]["embedding"]
|
||||
except Exception as e:
|
||||
logger.warning(f"Embedding generation failed: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,333 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, desc
|
||||
from app.models.customer import Customer, Message, Conversation
|
||||
from app.models.quotation import Quotation
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEAL_SIGNAL_KEYWORDS = [
|
||||
"moq", "minimum order", "sample", "certification", "certificate",
|
||||
"fob", "cif", "lead time", "delivery time", "shipping",
|
||||
"payment term", "tt", "lc", "deposit", "price", "quotation",
|
||||
"order", "purchase", "buy", "interested", "inquiry", "rfq",
|
||||
]
|
||||
|
||||
POSITIVE_WORDS = ["yes", "interested", "good", "great", "perfect", "thanks", "thank you", "proceed", "confirm", "agree"]
|
||||
NEGATIVE_WORDS = ["no", "not interested", "too expensive", "high price", "over budget", "not now", "later", "maybe later"]
|
||||
|
||||
|
||||
class CustomerHealthService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_health_overview(self, user_id: str) -> Dict[str, Any]:
|
||||
customers_result = await self.db.execute(
|
||||
select(Customer.id, Customer.status, Customer.last_contact_at).where(
|
||||
Customer.user_id == user_id
|
||||
)
|
||||
)
|
||||
rows = customers_result.all()
|
||||
total = len(rows)
|
||||
active = 0
|
||||
watch = 0
|
||||
critical = 0
|
||||
for row in rows:
|
||||
score = self._calculate_silence_score(row.last_contact_at)
|
||||
status_weight = self._status_weight(row.status)
|
||||
combined = score * 0.7 + status_weight * 0.3
|
||||
if combined >= 70:
|
||||
active += 1
|
||||
elif combined >= 40:
|
||||
watch += 1
|
||||
else:
|
||||
critical += 1
|
||||
return {
|
||||
"total": total,
|
||||
"active": active,
|
||||
"watch": watch,
|
||||
"critical": critical,
|
||||
}
|
||||
|
||||
async def get_customer_health(self, user_id: str, customer_id: str) -> Optional[Dict[str, Any]]:
|
||||
result = await self.db.execute(
|
||||
select(Customer).where(
|
||||
and_(Customer.id == customer_id, Customer.user_id == user_id)
|
||||
)
|
||||
)
|
||||
customer = result.scalar_one_or_none()
|
||||
if not customer:
|
||||
return None
|
||||
return await self._compute_full_health(user_id, customer)
|
||||
|
||||
async def get_all_health_scores(self, user_id: str) -> List[Dict[str, Any]]:
|
||||
customers_result = await self.db.execute(
|
||||
select(Customer).where(Customer.user_id == user_id).order_by(Customer.updated_at.desc())
|
||||
)
|
||||
customers = customers_result.scalars().all()
|
||||
results = []
|
||||
for c in customers:
|
||||
health = await self._compute_full_health(user_id, c)
|
||||
results.append(health)
|
||||
return results
|
||||
|
||||
async def _compute_full_health(self, user_id: str, customer: Customer) -> Dict[str, Any]:
|
||||
response_trend = await self._calc_response_trend(customer.id)
|
||||
sentiment = await self._calc_sentiment(customer.id)
|
||||
inquiry_depth = await self._calc_inquiry_depth(customer.id)
|
||||
silence_score = self._calculate_silence_score(customer.last_contact_at)
|
||||
business_value = await self._calc_business_value(customer.id)
|
||||
|
||||
silence_days = self._silence_days(customer.last_contact_at)
|
||||
dimensions = {
|
||||
"response_trend": response_trend,
|
||||
"sentiment": sentiment,
|
||||
"inquiry_depth": inquiry_depth,
|
||||
"silence": {"score": silence_score, "days": silence_days},
|
||||
"business_value": business_value,
|
||||
}
|
||||
result = self.calc_total_score(dimensions)
|
||||
|
||||
return {
|
||||
"customer_id": str(customer.id),
|
||||
"customer_name": customer.name,
|
||||
"status": customer.status,
|
||||
"total_score": result["total_score"],
|
||||
"grade": result["grade"],
|
||||
"dimensions": dimensions,
|
||||
"suggestion": self._suggestion(result["grade"], customer),
|
||||
}
|
||||
|
||||
async def _calc_response_trend(self, customer_id: str) -> Dict[str, Any]:
|
||||
now_7d_ago = datetime.utcnow() - timedelta(days=7)
|
||||
prev_7d_ago = datetime.utcnow() - timedelta(days=14)
|
||||
|
||||
recent_result = await self.db.execute(
|
||||
select(func.avg(
|
||||
func.extract("epoch", Message.created_at) -
|
||||
func.extract("epoch", func.lag(Message.created_at).over(order_by=Message.created_at))
|
||||
)).where(
|
||||
and_(
|
||||
Message.conversation_id == select(Conversation.id).where(
|
||||
Conversation.customer_id == customer_id
|
||||
).limit(1).scalar_subquery(),
|
||||
Message.direction == "inbound",
|
||||
Message.created_at >= now_7d_ago,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
previous_result = await self.db.execute(
|
||||
select(func.avg(
|
||||
func.extract("epoch", Message.created_at) -
|
||||
func.extract("epoch", func.lag(Message.created_at).over(order_by=Message.created_at))
|
||||
)).where(
|
||||
and_(
|
||||
Message.conversation_id == select(Conversation.id).where(
|
||||
Conversation.customer_id == customer_id
|
||||
).limit(1).scalar_subquery(),
|
||||
Message.direction == "inbound",
|
||||
Message.created_at >= prev_7d_ago,
|
||||
Message.created_at < now_7d_ago,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
recent_avg = recent_result.scalar()
|
||||
prev_avg = previous_result.scalar()
|
||||
|
||||
recent_hours = (recent_avg / 3600) if recent_avg else None
|
||||
prev_hours = (prev_avg / 3600) if prev_avg else None
|
||||
return self.calc_response_score(recent_hours, prev_hours)
|
||||
|
||||
async def _calc_sentiment(self, customer_id: str) -> Dict[str, Any]:
|
||||
conv_result = await self.db.execute(
|
||||
select(Conversation.id).where(
|
||||
Conversation.customer_id == customer_id
|
||||
).order_by(Conversation.created_at.desc()).limit(1)
|
||||
)
|
||||
conv_id = conv_result.scalar_one_or_none()
|
||||
if not conv_id:
|
||||
return {"score": 50, "label": "neutral", "last_messages": []}
|
||||
|
||||
msg_result = await self.db.execute(
|
||||
select(Message.content).where(
|
||||
and_(
|
||||
Message.conversation_id == conv_id,
|
||||
Message.direction == "inbound",
|
||||
)
|
||||
).order_by(desc(Message.created_at)).limit(3)
|
||||
)
|
||||
messages = list(msg_result.scalars().all())
|
||||
return self.calc_sentiment_score(messages)
|
||||
|
||||
async def _calc_inquiry_depth(self, customer_id: str) -> Dict[str, Any]:
|
||||
conv_result = await self.db.execute(
|
||||
select(Conversation.id).where(
|
||||
Conversation.customer_id == customer_id
|
||||
).order_by(Conversation.created_at.desc()).limit(1)
|
||||
)
|
||||
conv_id = conv_result.scalar_one_or_none()
|
||||
if not conv_id:
|
||||
return {"score": 0, "signals_found": [], "signal_count": 0}
|
||||
|
||||
msg_result = await self.db.execute(
|
||||
select(Message.content).where(
|
||||
and_(
|
||||
Message.conversation_id == conv_id,
|
||||
Message.direction == "inbound",
|
||||
)
|
||||
).order_by(desc(Message.created_at)).limit(20)
|
||||
)
|
||||
messages = list(msg_result.scalars().all())
|
||||
return self.calc_inquiry_depth_score(messages)
|
||||
|
||||
@staticmethod
|
||||
def calculate_silence_score(last_contact_at: Optional[datetime]) -> float:
|
||||
days = CustomerHealthService.silence_days(last_contact_at)
|
||||
return max(0, min(100, 100 - (days / 14) * 100))
|
||||
|
||||
@staticmethod
|
||||
def silence_days(last_contact_at: Optional[datetime]) -> int:
|
||||
if not last_contact_at:
|
||||
return 999
|
||||
return (datetime.utcnow() - last_contact_at).days
|
||||
|
||||
@staticmethod
|
||||
def status_weight(status: Optional[str]) -> float:
|
||||
mapping = {"customer": 100, "negotiating": 70, "lead": 40, "lost": 10}
|
||||
return mapping.get(status, 40)
|
||||
|
||||
@staticmethod
|
||||
def grade(score: float) -> str:
|
||||
if score >= 80:
|
||||
return "active"
|
||||
elif score >= 50:
|
||||
return "watch"
|
||||
else:
|
||||
return "critical"
|
||||
|
||||
@staticmethod
|
||||
def calc_response_score(recent_hours: Optional[float], prev_hours: Optional[float]) -> Dict[str, Any]:
|
||||
if recent_hours is None and prev_hours is None:
|
||||
return {"score": 50, "recent_avg_hours": None, "trend": "stable"}
|
||||
if recent_hours is None:
|
||||
return {"score": 30, "recent_avg_hours": None, "trend": "declining"}
|
||||
if prev_hours is None or prev_hours == 0:
|
||||
score = max(0, min(100, 100 - recent_hours * 5))
|
||||
return {"score": round(score), "recent_avg_hours": round(recent_hours, 1), "trend": "stable"}
|
||||
if recent_hours < prev_hours:
|
||||
score = max(0, min(100, 100 - recent_hours * 5))
|
||||
return {"score": round(score), "recent_avg_hours": round(recent_hours, 1), "trend": "improving"}
|
||||
else:
|
||||
score = max(0, min(100, 80 - recent_hours * 3))
|
||||
return {"score": round(score), "recent_avg_hours": round(recent_hours, 1), "trend": "declining"}
|
||||
|
||||
@staticmethod
|
||||
def calc_sentiment_score(messages: List[str]) -> Dict[str, Any]:
|
||||
if not messages:
|
||||
return {"score": 50, "label": "neutral", "last_messages": []}
|
||||
positive = 0
|
||||
negative = 0
|
||||
for msg in messages:
|
||||
lower = msg.lower()
|
||||
if any(w in lower for w in POSITIVE_WORDS):
|
||||
positive += 1
|
||||
if any(w in lower for w in NEGATIVE_WORDS):
|
||||
negative += 1
|
||||
if positive > negative:
|
||||
return {"score": 80, "label": "positive", "last_messages": messages}
|
||||
elif negative > positive:
|
||||
return {"score": 20, "label": "negative", "last_messages": messages}
|
||||
else:
|
||||
return {"score": 50, "label": "neutral", "last_messages": messages}
|
||||
|
||||
@staticmethod
|
||||
def calc_inquiry_depth_score(messages: List[str]) -> Dict[str, Any]:
|
||||
found_signals = []
|
||||
for msg in messages:
|
||||
lower = msg.lower()
|
||||
for kw in DEAL_SIGNAL_KEYWORDS:
|
||||
if kw in lower and kw not in found_signals:
|
||||
found_signals.append(kw)
|
||||
count = len(found_signals)
|
||||
if count >= 5:
|
||||
score = 100
|
||||
elif count >= 3:
|
||||
score = 75
|
||||
elif count >= 1:
|
||||
score = 50
|
||||
else:
|
||||
score = 0
|
||||
return {"score": score, "signals_found": found_signals, "signal_count": count}
|
||||
|
||||
@staticmethod
|
||||
def calc_business_value_score(total_value: float) -> Dict[str, Any]:
|
||||
if total_value >= 100000:
|
||||
score = 100
|
||||
elif total_value >= 50000:
|
||||
score = 80
|
||||
elif total_value >= 10000:
|
||||
score = 60
|
||||
elif total_value >= 1000:
|
||||
score = 40
|
||||
elif total_value > 0:
|
||||
score = 20
|
||||
else:
|
||||
score = 0
|
||||
return {"score": score, "total_value": round(total_value, 2)}
|
||||
|
||||
@staticmethod
|
||||
def calc_total_score(dimensions: Dict[str, Any]) -> Dict[str, Any]:
|
||||
total = (
|
||||
dimensions.get("response_trend", {}).get("score", 0) * 0.25
|
||||
+ dimensions.get("sentiment", {}).get("score", 0) * 0.20
|
||||
+ dimensions.get("inquiry_depth", {}).get("score", 0) * 0.20
|
||||
+ dimensions.get("silence", {}).get("score", 0) * 0.20
|
||||
+ dimensions.get("business_value", {}).get("score", 0) * 0.15
|
||||
)
|
||||
return {"total_score": round(total, 1), "grade": CustomerHealthService.grade(total)}
|
||||
|
||||
@staticmethod
|
||||
def suggestion(grade: str, silence_days: int, status: Optional[str]) -> str:
|
||||
if grade == "active":
|
||||
return "保持正常跟进,客户状态良好"
|
||||
elif grade == "watch":
|
||||
if silence_days >= 3:
|
||||
return f"客户已沉默{silence_days}天,建议3天内安排跟进"
|
||||
return "客户活跃度下降,建议关注"
|
||||
else:
|
||||
if status in ("lead", "negotiating"):
|
||||
return f"客户已沉默{silence_days}天,建议立即跟进,提供优惠或新产品信息"
|
||||
return f"客户已沉默{silence_days}天,建议重新激活"
|
||||
|
||||
def _calculate_silence_score(self, last_contact_at: Optional[datetime]) -> float:
|
||||
return self.calculate_silence_score(last_contact_at)
|
||||
|
||||
def _silence_days(self, last_contact_at: Optional[datetime]) -> int:
|
||||
return self.silence_days(last_contact_at)
|
||||
|
||||
def _status_weight(self, status: Optional[str]) -> float:
|
||||
return self.status_weight(status)
|
||||
|
||||
def _grade(self, score: float) -> str:
|
||||
return self.grade(score)
|
||||
|
||||
def _suggestion(self, grade: str, customer: Customer) -> str:
|
||||
return self.suggestion(grade, self._silence_days(customer.last_contact_at), customer.status)
|
||||
|
||||
async def _calc_business_value(self, customer_id: str) -> Dict[str, Any]:
|
||||
result = await self.db.execute(
|
||||
select(func.sum(Quotation.total)).where(
|
||||
and_(
|
||||
Quotation.customer_id == customer_id,
|
||||
Quotation.status.in_(["sent", "accepted"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
total_value = result.scalar() or 0
|
||||
return self.calc_business_value_score(total_value)
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
from typing import Dict, Optional
|
||||
from datetime import datetime
|
||||
from app.config import settings
|
||||
from app.core.redis import get_redis
|
||||
import httpx
|
||||
import json
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FALLBACK_RATES: Dict[str, Dict[str, float]] = {
|
||||
"USD": {"CNY": 7.24, "EUR": 0.92, "GBP": 0.79, "JPY": 151.50, "KRW": 1320.00, "AUD": 1.52, "CAD": 1.37, "INR": 83.50, "BRL": 5.10, "RUB": 92.00},
|
||||
"CNY": {"USD": 0.138, "EUR": 0.127, "GBP": 0.109, "JPY": 20.93, "KRW": 182.32, "AUD": 0.21, "CAD": 0.19},
|
||||
"EUR": {"USD": 1.09, "CNY": 7.85, "GBP": 0.86, "JPY": 164.50, "KRW": 1435.00},
|
||||
"GBP": {"USD": 1.27, "CNY": 9.15, "EUR": 1.16, "JPY": 192.00},
|
||||
}
|
||||
|
||||
CACHE_TTL = 21600
|
||||
|
||||
|
||||
class ExchangeRateService:
|
||||
def __init__(self):
|
||||
self._rates_cache: Optional[Dict] = None
|
||||
self._cache_time: Optional[datetime] = None
|
||||
|
||||
async def get_rate(self, from_currency: str, to_currency: str) -> Optional[float]:
|
||||
from_currency = from_currency.upper()
|
||||
to_currency = to_currency.upper()
|
||||
if from_currency == to_currency:
|
||||
return 1.0
|
||||
|
||||
rates = await self._get_all_rates(from_currency)
|
||||
if rates and to_currency in rates:
|
||||
return rates[to_currency]
|
||||
|
||||
base_rates = FALLBACK_RATES.get(from_currency, {})
|
||||
return base_rates.get(to_currency)
|
||||
|
||||
async def convert(self, from_currency: str, to_currency: str, amount: float = 1.0) -> Optional[float]:
|
||||
rate = await self.get_rate(from_currency, to_currency)
|
||||
if rate is None:
|
||||
return None
|
||||
return round(amount * rate, 2)
|
||||
|
||||
async def get_all_rates(self, base: str = "USD") -> Dict[str, float]:
|
||||
base = base.upper()
|
||||
rates = await self._get_all_rates(base)
|
||||
if rates:
|
||||
return rates
|
||||
return FALLBACK_RATES.get(base, {})
|
||||
|
||||
async def _get_all_rates(self, base: str) -> Optional[Dict[str, float]]:
|
||||
cached = await self._get_from_cache(base)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
rates = None
|
||||
for fetcher in [self._fetch_from_frankfurter, self._fetch_from_exchangerate_api]:
|
||||
try:
|
||||
rates = await fetcher(base)
|
||||
if rates:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning(f"Exchange rate fetcher failed: {e}")
|
||||
|
||||
if rates:
|
||||
await self._set_cache(base, rates)
|
||||
|
||||
return rates
|
||||
|
||||
async def _fetch_from_frankfurter(self, base: str) -> Optional[Dict[str, float]]:
|
||||
supported = ["USD", "EUR", "GBP", "CNY", "JPY", "KRW", "AUD", "CAD", "INR", "BRL"]
|
||||
if base not in supported:
|
||||
return None
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"https://api.frankfurter.app/latest",
|
||||
params={"from": base, "to": ",".join(supported)},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
return data.get("rates")
|
||||
except Exception as e:
|
||||
logger.warning(f"Frankfurter API failed: {e}")
|
||||
return None
|
||||
|
||||
async def _fetch_from_exchangerate_api(self, base: str) -> Optional[Dict[str, float]]:
|
||||
if not settings.EXCHANGE_RATE_API_KEY:
|
||||
return None
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"https://v6.exchangerate-api.com/v6/{settings.EXCHANGE_RATE_API_KEY}/latest/{base}",
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("result") == "success":
|
||||
return data.get("conversion_rates")
|
||||
except Exception as e:
|
||||
logger.warning(f"ExchangeRate-API failed: {e}")
|
||||
return None
|
||||
|
||||
async def _get_from_cache(self, base: str) -> Optional[Dict[str, float]]:
|
||||
try:
|
||||
r = await get_redis()
|
||||
data = await r.get(f"exchange_rate:{base}")
|
||||
if data:
|
||||
return json.loads(data)
|
||||
except Exception as e:
|
||||
logger.debug(f"Redis cache miss for {base}: {e}")
|
||||
return None
|
||||
|
||||
async def _set_cache(self, base: str, rates: Dict[str, float]):
|
||||
try:
|
||||
r = await get_redis()
|
||||
await r.setex(f"exchange_rate:{base}", CACHE_TTL, json.dumps(rates))
|
||||
except Exception as e:
|
||||
logger.debug(f"Redis cache set failed for {base}: {e}")
|
||||
@@ -0,0 +1,37 @@
|
||||
from typing import List, Dict, Any
|
||||
import csv
|
||||
import io
|
||||
|
||||
|
||||
def export_customers_csv(customers: List[Dict[str, Any]]) -> bytes:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["Name", "Company", "Country", "Phone", "Email", "Status", "Last Contact"])
|
||||
for c in customers:
|
||||
writer.writerow([
|
||||
c.get("name", ""),
|
||||
c.get("company", ""),
|
||||
c.get("country", ""),
|
||||
c.get("phone", ""),
|
||||
c.get("email", ""),
|
||||
c.get("status", ""),
|
||||
c.get("last_contact_at", ""),
|
||||
])
|
||||
return output.getvalue().encode("utf-8-sig")
|
||||
|
||||
|
||||
def export_quotations_csv(quotations: List[Dict[str, Any]]) -> bytes:
|
||||
output = io.StringIO()
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(["Title", "Customer", "Currency", "Subtotal", "Total", "Status", "Date"])
|
||||
for q in quotations:
|
||||
writer.writerow([
|
||||
q.get("title", ""),
|
||||
q.get("customer_name", ""),
|
||||
q.get("currency", "USD"),
|
||||
q.get("subtotal", 0),
|
||||
q.get("total", 0),
|
||||
q.get("status", ""),
|
||||
q.get("created_at", ""),
|
||||
])
|
||||
return output.getvalue().encode("utf-8-sig")
|
||||
@@ -0,0 +1,396 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, or_, desc
|
||||
from app.models.followup import FollowupStrategy, FollowupLog
|
||||
from app.models.customer import Customer
|
||||
from app.models.notification import Notification
|
||||
from app.ai.router import get_ai_router
|
||||
from app.services.customer_health import CustomerHealthService
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_STRATEGIES = [
|
||||
{
|
||||
"name": "温和提醒",
|
||||
"description": "沉默3-5天,健康分50-79 — 温和提醒",
|
||||
"trigger_condition": {
|
||||
"min_silence_days": 3,
|
||||
"max_silence_days": 5,
|
||||
"min_health_score": 50,
|
||||
"max_health_score": 79,
|
||||
"status_filter": ["lead", "negotiating"],
|
||||
},
|
||||
"channel": "whatsapp",
|
||||
"ai_prompt_template": "You are a professional export sales assistant. Write a gentle follow-up message to a customer who hasn't responded in {silence_days} days. Customer name: {customer_name}. Tone: warm but professional. Keep under 100 words. Suggest checking if they need any further information about the product.",
|
||||
"priority": 1,
|
||||
},
|
||||
{
|
||||
"name": "价值提供",
|
||||
"description": "沉默6-10天,健康分30-49 — 推送价值信息",
|
||||
"trigger_condition": {
|
||||
"min_silence_days": 6,
|
||||
"max_silence_days": 10,
|
||||
"min_health_score": 30,
|
||||
"max_health_score": 49,
|
||||
"status_filter": ["lead", "negotiating"],
|
||||
},
|
||||
"channel": "email",
|
||||
"ai_prompt_template": "You are a professional export sales assistant. Write a follow-up email to a customer who hasn't responded in {silence_days} days. Customer: {customer_name}. Share some valuable industry news, new product catalog highlights, or certification updates to rekindle interest. Keep under 150 words.",
|
||||
"priority": 2,
|
||||
},
|
||||
{
|
||||
"name": "重新激活",
|
||||
"description": "沉默11+天,健康分<30 — 紧急重新激活",
|
||||
"trigger_condition": {
|
||||
"min_silence_days": 11,
|
||||
"max_silence_days": 999,
|
||||
"min_health_score": 0,
|
||||
"max_health_score": 29,
|
||||
"status_filter": ["lead", "negotiating"],
|
||||
},
|
||||
"channel": "email",
|
||||
"ai_prompt_template": "You are a professional export sales assistant. Write a re-engagement email to a customer who has been silent for {silence_days} days. Customer: {customer_name}. Offer a limited-time discount, new product launch info, or a holiday greeting. Create a sense of urgency without being pushy. Keep under 150 words.",
|
||||
"priority": 3,
|
||||
},
|
||||
{
|
||||
"name": "促进决策",
|
||||
"description": "客户有回复但未成交,健康分60+ — 促进成交",
|
||||
"trigger_condition": {
|
||||
"min_silence_days": 2,
|
||||
"max_silence_days": 7,
|
||||
"min_health_score": 60,
|
||||
"max_health_score": 100,
|
||||
"status_filter": ["negotiating"],
|
||||
},
|
||||
"channel": "whatsapp",
|
||||
"ai_prompt_template": "You are a professional export sales assistant. The customer {customer_name} has shown interest but hasn't placed an order yet. Write a message sharing a success story, a limited-time offer, or highlighting what makes your product different from competitors. Keep under 120 words. Tone: confident and helpful.",
|
||||
"priority": 0,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class FollowupEngine:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.ai = get_ai_router()
|
||||
self.health_service = CustomerHealthService(db)
|
||||
|
||||
async def ensure_default_strategies(self):
|
||||
result = await self.db.execute(
|
||||
select(FollowupStrategy).limit(1)
|
||||
)
|
||||
if result.scalar_one_or_none():
|
||||
return
|
||||
for s in DEFAULT_STRATEGIES:
|
||||
strategy = FollowupStrategy(
|
||||
name=s["name"],
|
||||
description=s["description"],
|
||||
trigger_condition=s["trigger_condition"],
|
||||
channel=s["channel"],
|
||||
ai_prompt_template=s["ai_prompt_template"],
|
||||
priority=s["priority"],
|
||||
)
|
||||
self.db.add(strategy)
|
||||
await self.db.flush()
|
||||
logger.info(f"Created {len(DEFAULT_STRATEGIES)} default followup strategies")
|
||||
|
||||
async def get_strategies(self) -> List[Dict[str, Any]]:
|
||||
result = await self.db.execute(
|
||||
select(FollowupStrategy).order_by(FollowupStrategy.priority)
|
||||
)
|
||||
strategies = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": str(s.id),
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"trigger_condition": s.trigger_condition,
|
||||
"channel": s.channel,
|
||||
"priority": s.priority,
|
||||
"is_active": s.is_active,
|
||||
}
|
||||
for s in strategies
|
||||
]
|
||||
|
||||
async def evaluate_customer(self, user_id: str, customer: Customer) -> Optional[Dict[str, Any]]:
|
||||
health = await self.health_service.get_customer_health(user_id, str(customer.id))
|
||||
if not health:
|
||||
return None
|
||||
|
||||
silence_days = health["dimensions"]["silence"]["days"]
|
||||
health_score = health["total_score"]
|
||||
|
||||
strategies_result = await self.db.execute(
|
||||
select(FollowupStrategy).where(
|
||||
and_(
|
||||
FollowupStrategy.is_active == True,
|
||||
)
|
||||
).order_by(FollowupStrategy.priority)
|
||||
)
|
||||
strategies = strategies_result.scalars().all()
|
||||
|
||||
for strategy in strategies:
|
||||
cond = strategy.trigger_condition
|
||||
if not cond:
|
||||
continue
|
||||
|
||||
if silence_days < cond.get("min_silence_days", 0):
|
||||
continue
|
||||
if silence_days > cond.get("max_silence_days", 999):
|
||||
continue
|
||||
if health_score < cond.get("min_health_score", 0):
|
||||
continue
|
||||
if health_score > cond.get("max_health_score", 100):
|
||||
continue
|
||||
if cond.get("status_filter") and customer.status not in cond["status_filter"]:
|
||||
continue
|
||||
|
||||
existing = await self.db.execute(
|
||||
select(FollowupLog).where(
|
||||
and_(
|
||||
FollowupLog.customer_id == customer.id,
|
||||
FollowupLog.strategy_id == strategy.id,
|
||||
FollowupLog.status.in_(["pending", "sent"]),
|
||||
FollowupLog.created_at > datetime.utcnow() - timedelta(days=7),
|
||||
)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
continue
|
||||
|
||||
return {
|
||||
"strategy": strategy,
|
||||
"silence_days": silence_days,
|
||||
"health_score": health_score,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
async def generate_followup_content(self, strategy: FollowupStrategy, customer: Customer, silence_days: int) -> str:
|
||||
try:
|
||||
prompt = strategy.ai_prompt_template.format(
|
||||
customer_name=customer.name,
|
||||
silence_days=silence_days,
|
||||
company=customer.company or "",
|
||||
)
|
||||
result = await self.ai.execute("marketing", "generate_marketing",
|
||||
{"name": customer.name, "description": prompt},
|
||||
customer.country or "US",
|
||||
"professional",
|
||||
"en"
|
||||
)
|
||||
return result.get("content", "")
|
||||
except Exception as e:
|
||||
logger.warning(f"AI content generation failed: {e}")
|
||||
return f"Hi {customer.name}, just checking in to see if you need any further information about our products. Looking forward to hearing from you!"
|
||||
|
||||
async def create_followup_log(self, user_id: str, customer: Customer,
|
||||
strategy: FollowupStrategy, silence_days: int,
|
||||
health_score: int, content: str) -> FollowupLog:
|
||||
log = FollowupLog(
|
||||
user_id=user_id,
|
||||
customer_id=customer.id,
|
||||
strategy_id=strategy.id,
|
||||
status="pending",
|
||||
channel=strategy.channel,
|
||||
ai_generated_content=content,
|
||||
content=content,
|
||||
health_score_at_time=health_score,
|
||||
silence_days_at_time=silence_days,
|
||||
)
|
||||
self.db.add(log)
|
||||
await self.db.flush()
|
||||
return log
|
||||
|
||||
async def scan_and_followup(self) -> Dict[str, Any]:
|
||||
await self.ensure_default_strategies()
|
||||
|
||||
customers_result = await self.db.execute(
|
||||
select(Customer).where(
|
||||
Customer.status.in_(["lead", "negotiating"])
|
||||
)
|
||||
)
|
||||
customers = customers_result.scalars().all()
|
||||
|
||||
processed = 0
|
||||
notifications_sent = 0
|
||||
logs_created = 0
|
||||
|
||||
for customer in customers:
|
||||
try:
|
||||
result = await self.evaluate_customer(str(customer.user_id), customer)
|
||||
if not result:
|
||||
continue
|
||||
|
||||
content = await self.generate_followup_content(
|
||||
result["strategy"], customer, result["silence_days"]
|
||||
)
|
||||
log = await self.create_followup_log(
|
||||
str(customer.user_id), customer,
|
||||
result["strategy"], result["silence_days"],
|
||||
result["health_score"], content,
|
||||
)
|
||||
|
||||
title = f"跟进提醒: {customer.name}"
|
||||
notify_content = f"{result['strategy'].name} — {content[:80]}..."
|
||||
n = Notification(
|
||||
user_id=customer.user_id,
|
||||
title=title,
|
||||
content=notify_content,
|
||||
notification_type="followup",
|
||||
reference_type="customer",
|
||||
reference_id=str(customer.id),
|
||||
)
|
||||
self.db.add(n)
|
||||
|
||||
processed += 1
|
||||
logs_created += 1
|
||||
notifications_sent += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Followup scan failed for customer {customer.id}: {e}")
|
||||
continue
|
||||
|
||||
if processed > 0:
|
||||
await self.db.flush()
|
||||
logger.info(f"Followup scan: {processed} customers matched, {logs_created} logs, {notifications_sent} notifications")
|
||||
|
||||
return {
|
||||
"customers_scanned": len(customers),
|
||||
"followups_created": logs_created,
|
||||
"notifications_sent": notifications_sent,
|
||||
}
|
||||
|
||||
async def get_pending_followups(self, user_id: str, page: int = 1, size: int = 20) -> Dict[str, Any]:
|
||||
query = select(FollowupLog).where(
|
||||
and_(
|
||||
FollowupLog.user_id == user_id,
|
||||
FollowupLog.status == "pending",
|
||||
)
|
||||
).order_by(FollowupLog.created_at.desc()).offset(
|
||||
(page - 1) * size
|
||||
).limit(size)
|
||||
|
||||
count_q = select(FollowupLog).where(
|
||||
and_(
|
||||
FollowupLog.user_id == user_id,
|
||||
FollowupLog.status == "pending",
|
||||
)
|
||||
)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
count_result = await self.db.execute(count_q)
|
||||
total = len(count_result.scalars().all())
|
||||
|
||||
items = []
|
||||
for log in logs:
|
||||
customer_result = await self.db.execute(
|
||||
select(Customer).where(Customer.id == log.customer_id)
|
||||
)
|
||||
customer = customer_result.scalar_one_or_none()
|
||||
items.append({
|
||||
"id": str(log.id),
|
||||
"customer_id": str(log.customer_id),
|
||||
"customer_name": customer.name if customer else "Unknown",
|
||||
"strategy": "跟进",
|
||||
"channel": log.channel,
|
||||
"content": log.content,
|
||||
"ai_generated_content": log.ai_generated_content,
|
||||
"health_score": log.health_score_at_time,
|
||||
"silence_days": log.silence_days_at_time,
|
||||
"status": log.status,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
})
|
||||
|
||||
return {"items": items, "total": total, "page": page, "size": size}
|
||||
|
||||
async def get_followup_logs(self, user_id: str, page: int = 1, size: int = 20) -> Dict[str, Any]:
|
||||
query = select(FollowupLog).where(
|
||||
FollowupLog.user_id == user_id
|
||||
).order_by(FollowupLog.created_at.desc()).offset(
|
||||
(page - 1) * size
|
||||
).limit(size)
|
||||
|
||||
count_q = select(FollowupLog).where(FollowupLog.user_id == user_id)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
logs = result.scalars().all()
|
||||
|
||||
count_result = await self.db.execute(count_q)
|
||||
total = len(count_result.scalars().all())
|
||||
|
||||
items = []
|
||||
for log in logs:
|
||||
customer_result = await self.db.execute(
|
||||
select(Customer).where(Customer.id == log.customer_id)
|
||||
)
|
||||
customer = customer_result.scalar_one_or_none()
|
||||
items.append({
|
||||
"id": str(log.id),
|
||||
"customer_id": str(log.customer_id),
|
||||
"customer_name": customer.name if customer else "Unknown",
|
||||
"channel": log.channel,
|
||||
"content": log.content,
|
||||
"ai_generated_content": log.ai_generated_content,
|
||||
"user_edited_content": log.user_edited_content,
|
||||
"status": log.status,
|
||||
"health_score": log.health_score_at_time,
|
||||
"silence_days": log.silence_days_at_time,
|
||||
"sent_at": log.sent_at.isoformat() if log.sent_at else None,
|
||||
"replied_at": log.replied_at.isoformat() if log.replied_at else None,
|
||||
"created_at": log.created_at.isoformat() if log.created_at else None,
|
||||
})
|
||||
|
||||
return {"items": items, "total": total, "page": page, "size": size}
|
||||
|
||||
async def mark_sent(self, user_id: str, log_id: str) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(FollowupLog).where(
|
||||
and_(FollowupLog.id == log_id, FollowupLog.user_id == user_id)
|
||||
)
|
||||
)
|
||||
log = result.scalar_one_or_none()
|
||||
if not log:
|
||||
return False
|
||||
log.status = "sent"
|
||||
log.sent_at = datetime.utcnow()
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
async def mark_edited(self, user_id: str, log_id: str, edited_text: str) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(FollowupLog).where(
|
||||
and_(FollowupLog.id == log_id, FollowupLog.user_id == user_id)
|
||||
)
|
||||
)
|
||||
log = result.scalar_one_or_none()
|
||||
if not log:
|
||||
return False
|
||||
log.user_edited_content = edited_text
|
||||
log.content = edited_text
|
||||
log.status = "sent"
|
||||
log.sent_at = datetime.utcnow()
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
async def get_stats(self, user_id: str) -> Dict[str, Any]:
|
||||
logs_result = await self.db.execute(
|
||||
select(FollowupLog).where(FollowupLog.user_id == user_id)
|
||||
)
|
||||
all_logs = logs_result.scalars().all()
|
||||
total = len(all_logs)
|
||||
pending = sum(1 for l in all_logs if l.status == "pending")
|
||||
sent = sum(1 for l in all_logs if l.status == "sent")
|
||||
replied = sum(1 for l in all_logs if l.status == "replied")
|
||||
|
||||
return {
|
||||
"total_followups": total,
|
||||
"pending": pending,
|
||||
"sent": sent,
|
||||
"replied": replied,
|
||||
"completion_rate": round(sent / total * 100, 1) if total > 0 else 0,
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
from typing import Dict, Any, List, Optional, Tuple
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import openpyxl
|
||||
HAS_OPENPYXL = True
|
||||
except ImportError:
|
||||
HAS_OPENPYXL = False
|
||||
logger.warning("openpyxl not installed, XLSX import disabled")
|
||||
|
||||
|
||||
REQUIRED_COLUMNS = {"name"}
|
||||
OPTIONAL_COLUMNS = {
|
||||
"company", "country", "phone", "email", "whatsapp_id",
|
||||
"source", "tags", "notes", "status", "estimated_value",
|
||||
}
|
||||
|
||||
|
||||
class ImportService:
|
||||
@staticmethod
|
||||
def parse_xlsx(file_bytes: bytes) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||
if not HAS_OPENPYXL:
|
||||
return [], ["openpyxl not installed"]
|
||||
|
||||
try:
|
||||
wb = openpyxl.load_workbook(io.BytesIO(file_bytes), read_only=True)
|
||||
ws = wb.active
|
||||
rows = list(ws.iter_rows(values_only=True))
|
||||
if not rows:
|
||||
return [], ["Empty file"]
|
||||
|
||||
headers = [str(h).strip().lower() if h else "" for h in rows[0]]
|
||||
missing = REQUIRED_COLUMNS - set(headers)
|
||||
if missing:
|
||||
return [], [f"Missing required columns: {', '.join(missing)}"]
|
||||
|
||||
records = []
|
||||
errors = []
|
||||
for i, row in enumerate(rows[1:], 2):
|
||||
if all(v is None or str(v).strip() == "" for v in row):
|
||||
continue
|
||||
record = {}
|
||||
for j, val in enumerate(row):
|
||||
if j < len(headers) and headers[j]:
|
||||
record[headers[j]] = str(val).strip() if val is not None else ""
|
||||
if not record.get("name"):
|
||||
errors.append(f"Row {i}: missing name")
|
||||
continue
|
||||
records.append(record)
|
||||
|
||||
return records, errors
|
||||
|
||||
except Exception as e:
|
||||
return [], [f"Parse error: {str(e)}"]
|
||||
|
||||
@staticmethod
|
||||
def parse_csv(file_bytes: bytes) -> Tuple[List[Dict[str, Any]], List[str]]:
|
||||
try:
|
||||
text = file_bytes.decode("utf-8-sig")
|
||||
reader = csv.DictReader(io.StringIO(text))
|
||||
if not reader.fieldnames:
|
||||
return [], ["Empty or invalid CSV"]
|
||||
|
||||
headers = [h.strip().lower() for h in reader.fieldnames]
|
||||
missing = REQUIRED_COLUMNS - set(headers)
|
||||
if missing:
|
||||
return [], [f"Missing required columns: {', '.join(missing)}"]
|
||||
|
||||
records = []
|
||||
errors = []
|
||||
for i, row in enumerate(reader, 2):
|
||||
cleaned = {}
|
||||
for k, v in row.items():
|
||||
key = k.strip().lower()
|
||||
if key:
|
||||
cleaned[key] = v.strip() if v else ""
|
||||
if not cleaned.get("name"):
|
||||
errors.append(f"Row {i}: missing name")
|
||||
continue
|
||||
cleaned = {k: v for k, v in cleaned.items() if k in REQUIRED_COLUMNS | OPTIONAL_COLUMNS}
|
||||
records.append(cleaned)
|
||||
|
||||
return records, errors
|
||||
|
||||
except Exception as e:
|
||||
return [], [f"Parse error: {str(e)}"]
|
||||
|
||||
@staticmethod
|
||||
def validate_records(records: List[Dict]) -> Tuple[List[Dict], List[str]]:
|
||||
valid = []
|
||||
errors = []
|
||||
for i, r in enumerate(records, 1):
|
||||
if r.get("status") and r["status"] not in ("lead", "negotiating", "customer", "lost", "archived"):
|
||||
errors.append(f"Row {i}: invalid status '{r['status']}'")
|
||||
continue
|
||||
if r.get("phone") and not r["phone"].strip():
|
||||
r.pop("phone", None)
|
||||
r.setdefault("status", "lead")
|
||||
r.setdefault("source", "import")
|
||||
r.setdefault("tags", [])
|
||||
if isinstance(r.get("tags"), str):
|
||||
r["tags"] = [t.strip() for t in r["tags"].split(",") if t.strip()]
|
||||
valid.append(r)
|
||||
return valid, errors
|
||||
|
||||
|
||||
import_service = ImportService()
|
||||
@@ -16,13 +16,14 @@ class MarketingService:
|
||||
style: str = "professional",
|
||||
language: str = "en",
|
||||
count: int = 3,
|
||||
preference_context: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
results = []
|
||||
styles = self._get_style_variants(style, count)
|
||||
|
||||
for s in styles:
|
||||
try:
|
||||
result = await self.ai.marketing(product_info, target, s, language)
|
||||
result = await self.ai.marketing(product_info, target, s, language, preference_context)
|
||||
results.append({
|
||||
"content": result.get("content", ""),
|
||||
"style": s,
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from app.models.preference import MarketingEffect
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MarketingEffectService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def track_event(
|
||||
self,
|
||||
user_id: str,
|
||||
content: str,
|
||||
product_id: Optional[str] = None,
|
||||
product_name: Optional[str] = None,
|
||||
channel: str = "copy",
|
||||
event_type: str = "copy",
|
||||
target_audience: str = "",
|
||||
metadata: Optional[Dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
||||
|
||||
event = MarketingEffect(
|
||||
user_id=user_id,
|
||||
content_hash=content_hash,
|
||||
product_id=product_id,
|
||||
product_name=product_name,
|
||||
channel=channel,
|
||||
event_type=event_type,
|
||||
target_audience=target_audience,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
self.db.add(event)
|
||||
await self.db.flush()
|
||||
|
||||
return {
|
||||
"id": str(event.id),
|
||||
"event_type": event_type,
|
||||
"content_hash": content_hash,
|
||||
}
|
||||
|
||||
async def get_effects(
|
||||
self, user_id: str, page: int = 1, size: int = 20
|
||||
) -> Dict[str, Any]:
|
||||
query = (
|
||||
select(MarketingEffect)
|
||||
.where(MarketingEffect.user_id == user_id)
|
||||
.order_by(MarketingEffect.created_at.desc())
|
||||
.offset((page - 1) * size)
|
||||
.limit(size)
|
||||
)
|
||||
count_query = select(func.count(MarketingEffect.id)).where(
|
||||
MarketingEffect.user_id == user_id
|
||||
)
|
||||
|
||||
total = await self.db.execute(count_query)
|
||||
result = await self.db.execute(query)
|
||||
events = result.scalars().all()
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": str(e.id),
|
||||
"product_name": e.product_name,
|
||||
"channel": e.channel,
|
||||
"event_type": e.event_type,
|
||||
"target_audience": e.target_audience,
|
||||
"created_at": e.created_at.isoformat() if e.created_at else None,
|
||||
}
|
||||
for e in events
|
||||
],
|
||||
"total": total.scalar() or 0,
|
||||
"page": page,
|
||||
"size": size,
|
||||
}
|
||||
|
||||
async def get_stats(self, user_id: str) -> Dict[str, Any]:
|
||||
today = datetime.utcnow().date()
|
||||
week_ago = today - timedelta(days=7)
|
||||
|
||||
total_query = select(func.count(MarketingEffect.id)).where(
|
||||
MarketingEffect.user_id == user_id
|
||||
)
|
||||
today_query = select(func.count(MarketingEffect.id)).where(
|
||||
and_(
|
||||
MarketingEffect.user_id == user_id,
|
||||
func.date(MarketingEffect.created_at) == today,
|
||||
)
|
||||
)
|
||||
week_query = select(func.count(MarketingEffect.id)).where(
|
||||
and_(
|
||||
MarketingEffect.user_id == user_id,
|
||||
func.date(MarketingEffect.created_at) >= week_ago,
|
||||
)
|
||||
)
|
||||
copy_query = select(func.count(MarketingEffect.id)).where(
|
||||
and_(
|
||||
MarketingEffect.user_id == user_id,
|
||||
MarketingEffect.event_type == "copy",
|
||||
)
|
||||
)
|
||||
send_query = select(func.count(MarketingEffect.id)).where(
|
||||
and_(
|
||||
MarketingEffect.user_id == user_id,
|
||||
MarketingEffect.event_type == "send",
|
||||
)
|
||||
)
|
||||
|
||||
totals = await self.db.execute(total_query)
|
||||
todays = await self.db.execute(today_query)
|
||||
weeks = await self.db.execute(week_query)
|
||||
copies = await self.db.execute(copy_query)
|
||||
sends = await self.db.execute(send_query)
|
||||
|
||||
return {
|
||||
"total_events": totals.scalar() or 0,
|
||||
"today": todays.scalar() or 0,
|
||||
"this_week": weeks.scalar() or 0,
|
||||
"copy_count": copies.scalar() or 0,
|
||||
"send_count": sends.scalar() or 0,
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
from typing import Dict, Any, List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from app.models.notification import Notification
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def list_notifications(
|
||||
self, user_id: str, page: int = 1, size: int = 20, unread_only: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
query = select(Notification).where(Notification.user_id == user_id)
|
||||
if unread_only:
|
||||
query = query.where(Notification.is_read == False)
|
||||
query = query.order_by(Notification.created_at.desc()).offset(
|
||||
(page - 1) * size
|
||||
).limit(size)
|
||||
|
||||
count_query = select(func.count(Notification.id)).where(
|
||||
Notification.user_id == user_id
|
||||
)
|
||||
if unread_only:
|
||||
count_query = count_query.where(Notification.is_read == False)
|
||||
|
||||
total = await self.db.execute(count_query)
|
||||
result = await self.db.execute(query)
|
||||
notifications = result.scalars().all()
|
||||
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"id": str(n.id),
|
||||
"title": n.title,
|
||||
"content": n.content,
|
||||
"type": n.notification_type,
|
||||
"reference_type": n.reference_type,
|
||||
"reference_id": n.reference_id,
|
||||
"is_read": n.is_read,
|
||||
"created_at": n.created_at.isoformat() if n.created_at else None,
|
||||
}
|
||||
for n in notifications
|
||||
],
|
||||
"total": total.scalar() or 0,
|
||||
"page": page,
|
||||
"size": size,
|
||||
}
|
||||
|
||||
async def get_unread_count(self, user_id: str) -> int:
|
||||
result = await self.db.execute(
|
||||
select(func.count(Notification.id)).where(
|
||||
and_(Notification.user_id == user_id, Notification.is_read == False)
|
||||
)
|
||||
)
|
||||
return result.scalar() or 0
|
||||
|
||||
async def mark_read(self, user_id: str, notification_id: str) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(Notification).where(
|
||||
and_(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
n = result.scalar_one_or_none()
|
||||
if not n:
|
||||
return False
|
||||
n.is_read = True
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
async def mark_all_read(self, user_id: str) -> int:
|
||||
result = await self.db.execute(
|
||||
select(Notification).where(
|
||||
and_(Notification.user_id == user_id, Notification.is_read == False)
|
||||
)
|
||||
)
|
||||
notifications = result.scalars().all()
|
||||
for n in notifications:
|
||||
n.is_read = True
|
||||
await self.db.flush()
|
||||
return len(notifications)
|
||||
|
||||
async def delete_notification(self, user_id: str, notification_id: str) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(Notification).where(
|
||||
and_(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == user_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
n = result.scalar_one_or_none()
|
||||
if not n:
|
||||
return False
|
||||
await self.db.delete(n)
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def create_notification(
|
||||
db: AsyncSession, user_id: str, title: str, content: str,
|
||||
notification_type: str = "system",
|
||||
reference_type: str = None, reference_id: str = None,
|
||||
):
|
||||
n = Notification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
notification_type=notification_type,
|
||||
reference_type=reference_type,
|
||||
reference_id=reference_id,
|
||||
)
|
||||
db.add(n)
|
||||
await db.flush()
|
||||
return n
|
||||
@@ -0,0 +1,74 @@
|
||||
from typing import Dict, Any, List, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
from app.models.user import User, Product
|
||||
from app.services.marketing import MarketingService
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OnboardingService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def check_status(self, user_id: str) -> Dict[str, Any]:
|
||||
product_count = await self.db.execute(
|
||||
select(func.count(Product.id)).where(
|
||||
Product.user_id == user_id, Product.is_active == True
|
||||
)
|
||||
)
|
||||
has_products = (product_count.scalar() or 0) > 0
|
||||
return {"onboarded": has_products}
|
||||
|
||||
async def generate_first_product(
|
||||
self, user_id: str, name: str, description: str, category: str = "", target: str = "US importers"
|
||||
) -> Dict[str, Any]:
|
||||
product = Product(
|
||||
user_id=user_id,
|
||||
name=name,
|
||||
description=description,
|
||||
category=category or "general",
|
||||
is_active=True,
|
||||
)
|
||||
self.db.add(product)
|
||||
await self.db.flush()
|
||||
|
||||
mkt = MarketingService()
|
||||
try:
|
||||
content = await mkt.generate(
|
||||
product_name=name,
|
||||
description=description,
|
||||
category=category or "general",
|
||||
target=target,
|
||||
style="professional",
|
||||
count=3,
|
||||
language="en",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Onboarding content generation failed: {e}")
|
||||
content = [f"Check out our {name} - {description[:100]}..."]
|
||||
|
||||
try:
|
||||
keywords_result = await mkt.generate_keywords(
|
||||
product_name=name, description=description, category=category or "general"
|
||||
)
|
||||
keywords = keywords_result if isinstance(keywords_result, list) else []
|
||||
except Exception as e:
|
||||
logger.warning(f"Keyword generation failed: {e}")
|
||||
keywords = []
|
||||
|
||||
product.keywords = keywords[:10]
|
||||
await self.db.flush()
|
||||
|
||||
return {
|
||||
"product": {
|
||||
"id": str(product.id),
|
||||
"name": product.name,
|
||||
"description": product.description,
|
||||
"category": product.category,
|
||||
"keywords": keywords[:10],
|
||||
},
|
||||
"generated_content": content,
|
||||
"keywords": keywords[:10],
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
import hmac
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from app.models.subscription import Subscription
|
||||
from app.models.user import User
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PLANS = {
|
||||
"free": {"price": 0, "duration_days": None},
|
||||
"pro": {"price": 99, "duration_days": 30},
|
||||
"enterprise": {"price": 399, "duration_days": 30},
|
||||
}
|
||||
|
||||
|
||||
class PaymentService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_plans(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"plans": [
|
||||
{
|
||||
"id": "free",
|
||||
"name": "免费版",
|
||||
"price": 0,
|
||||
"features": [
|
||||
"1 个产品",
|
||||
"20 次翻译/天",
|
||||
"5 个客户",
|
||||
"基础回复建议",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "pro",
|
||||
"name": "Pro 版",
|
||||
"price": 99,
|
||||
"features": [
|
||||
"10 个产品",
|
||||
"无限翻译",
|
||||
"50 个客户",
|
||||
"跟进提醒",
|
||||
"报价单生成",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "enterprise",
|
||||
"name": "企业版",
|
||||
"price": 399,
|
||||
"features": [
|
||||
"无限产品",
|
||||
"多人协作",
|
||||
"品牌报价单",
|
||||
"专属语料训练",
|
||||
"API 接入",
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async def get_current_subscription(self, user_id: str) -> Dict[str, Any]:
|
||||
result = await self.db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.user_id == user_id,
|
||||
Subscription.status == "active",
|
||||
).order_by(Subscription.created_at.desc()).limit(1)
|
||||
)
|
||||
sub = result.scalar_one_or_none()
|
||||
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
return {
|
||||
"plan": user.tier if user else "free",
|
||||
"status": sub.status if sub else "active",
|
||||
"expires_at": sub.expires_at.isoformat() if sub and sub.expires_at else None,
|
||||
"auto_renew": sub.auto_renew if sub else False,
|
||||
}
|
||||
|
||||
async def create_order(self, user_id: str, plan: str) -> Dict[str, Any]:
|
||||
if plan not in PLANS:
|
||||
raise ValueError(f"Invalid plan: {plan}")
|
||||
|
||||
plan_info = PLANS[plan]
|
||||
if plan_info["price"] == 0:
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
user.tier = plan
|
||||
await self.db.flush()
|
||||
return {"status": "ok", "plan": plan, "amount": 0}
|
||||
|
||||
from app.config import settings
|
||||
order_id = f"ORD{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{user_id[-6:]}"
|
||||
|
||||
sub = Subscription(
|
||||
user_id=user_id,
|
||||
plan=plan,
|
||||
status="pending",
|
||||
amount=plan_info["price"],
|
||||
payment_id=order_id,
|
||||
)
|
||||
self.db.add(sub)
|
||||
await self.db.flush()
|
||||
|
||||
pay_params = {
|
||||
"appId": settings.WECHAT_APP_ID or "",
|
||||
"timeStamp": str(int(datetime.utcnow().timestamp())),
|
||||
"nonceStr": hashlib.md5(order_id.encode()).hexdigest()[:16],
|
||||
"package": f"prepay_id={order_id}",
|
||||
"signType": "MD5",
|
||||
}
|
||||
sign_str = "&".join(f"{k}={v}" for k, v in sorted(pay_params.items()))
|
||||
sign_str += f"&key={settings.SECRET_KEY}"
|
||||
pay_params["paySign"] = hashlib.md5(sign_str.encode()).hexdigest().upper()
|
||||
|
||||
return {
|
||||
"status": "pending",
|
||||
"order_id": order_id,
|
||||
"plan": plan,
|
||||
"amount": plan_info["price"],
|
||||
"currency": "CNY",
|
||||
"pay_params": pay_params,
|
||||
}
|
||||
|
||||
async def handle_payment_callback(self, payment_id: str, success: bool) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(Subscription).where(Subscription.payment_id == payment_id)
|
||||
)
|
||||
sub = result.scalar_one_or_none()
|
||||
if not sub:
|
||||
return False
|
||||
|
||||
if success:
|
||||
sub.status = "active"
|
||||
sub.started_at = datetime.utcnow()
|
||||
sub.expires_at = datetime.utcnow() + timedelta(days=PLANS[sub.plan]["duration_days"])
|
||||
|
||||
user_result = await self.db.execute(select(User).where(User.id == sub.user_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if user:
|
||||
user.tier = sub.plan
|
||||
else:
|
||||
sub.status = "failed"
|
||||
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
|
||||
payment_service = PaymentService
|
||||
@@ -0,0 +1,229 @@
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
import os
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
from weasyprint import HTML
|
||||
HAS_WEASYPRINT = True
|
||||
except ImportError:
|
||||
HAS_WEASYPRINT = False
|
||||
logger.warning("weasyprint not installed, PDF generation disabled")
|
||||
|
||||
|
||||
QUOTATION_TEMPLATE = """
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
@page {{
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
}}
|
||||
body {{
|
||||
font-family: 'Helvetica Neue', Arial, sans-serif;
|
||||
font-size: 12pt;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}}
|
||||
.header {{
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
border-bottom: 2px solid #1890ff;
|
||||
padding-bottom: 20px;
|
||||
}}
|
||||
.header h1 {{
|
||||
font-size: 24pt;
|
||||
color: #1890ff;
|
||||
margin: 0;
|
||||
}}
|
||||
.header .number {{
|
||||
font-size: 14pt;
|
||||
color: #666;
|
||||
}}
|
||||
.info-grid {{
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 30px;
|
||||
}}
|
||||
.info-block {{
|
||||
width: 48%;
|
||||
}}
|
||||
.info-block h3 {{
|
||||
font-size: 11pt;
|
||||
color: #1890ff;
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
padding-bottom: 4px;
|
||||
}}
|
||||
.info-block p {{
|
||||
margin: 4px 0;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
table {{
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 30px;
|
||||
}}
|
||||
th {{
|
||||
background: #1890ff;
|
||||
color: white;
|
||||
padding: 10px 8px;
|
||||
text-align: left;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
td {{
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid #e8e8e8;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
.amount-row td {{
|
||||
text-align: right;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
}}
|
||||
.total-row td {{
|
||||
font-weight: bold;
|
||||
font-size: 12pt;
|
||||
border-top: 2px solid #333;
|
||||
}}
|
||||
.terms {{
|
||||
margin-top: 30px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #e8e8e8;
|
||||
}}
|
||||
.terms h3 {{
|
||||
font-size: 11pt;
|
||||
color: #1890ff;
|
||||
}}
|
||||
.terms p {{
|
||||
font-size: 9pt;
|
||||
color: #666;
|
||||
margin: 4px 0;
|
||||
}}
|
||||
.footer {{
|
||||
text-align: center;
|
||||
margin-top: 40px;
|
||||
font-size: 9pt;
|
||||
color: #999;
|
||||
}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>QUOTATION</h1>
|
||||
<p class="number">#{quotation_number}</p>
|
||||
</div>
|
||||
|
||||
<div class="info-grid">
|
||||
<div class="info-block">
|
||||
<h3>Bill To</h3>
|
||||
<p>{customer_name}</p>
|
||||
<p>{customer_company}</p>
|
||||
<p>{customer_country}</p>
|
||||
</div>
|
||||
<div class="info-block">
|
||||
<h3>Quote Details</h3>
|
||||
<p>Date: {date}</p>
|
||||
<p>Valid Until: {valid_until}</p>
|
||||
<p>Currency: {currency}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Item</th>
|
||||
<th>Description</th>
|
||||
<th>Qty</th>
|
||||
<th>Unit</th>
|
||||
<th>Unit Price</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items_rows}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<table>
|
||||
<tr class="amount-row"><td colspan="4"></td><td>Subtotal:</td><td>{subtotal}</td></tr>
|
||||
<tr class="amount-row"><td colspan="4"></td><td>Discount:</td><td>-{discount}</td></tr>
|
||||
<tr class="amount-row"><td colspan="4"></td><td>Shipping:</td><td>{shipping}</td></tr>
|
||||
<tr class="total-row"><td colspan="4"></td><td>TOTAL:</td><td>{total}</td></tr>
|
||||
</table>
|
||||
|
||||
<div class="terms">
|
||||
<h3>Terms & Conditions</h3>
|
||||
<p>Payment Terms: {payment_terms}</p>
|
||||
<p>Delivery Terms: {delivery_terms}</p>
|
||||
<p>Lead Time: {lead_time}</p>
|
||||
{notes_html}
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p>Generated by TradeMate - {generated_at}</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
class PDFGenerator:
|
||||
@staticmethod
|
||||
def generate_quotation(data: Dict[str, Any]) -> Optional[bytes]:
|
||||
if not HAS_WEASYPRINT:
|
||||
return None
|
||||
|
||||
items = data.get("items", [])
|
||||
items_rows = ""
|
||||
for i, item in enumerate(items, 1):
|
||||
items_rows += (
|
||||
f"<tr>"
|
||||
f"<td>{item.get('product_name', '')}</td>"
|
||||
f"<td>{item.get('description', '') or ''}</td>"
|
||||
f"<td>{item.get('quantity', 0)}</td>"
|
||||
f"<td>{item.get('unit', 'pcs')}</td>"
|
||||
f"<td>{item.get('unit_price', 0):.2f}</td>"
|
||||
f"<td>{item.get('total_price', 0):.2f}</td>"
|
||||
f"</tr>"
|
||||
)
|
||||
|
||||
cur = data.get("currency", "USD")
|
||||
subtotal = f"{cur} {data.get('subtotal', 0):.2f}"
|
||||
discount = f"{cur} {data.get('discount', 0):.2f}" if data.get("discount") else f"{cur} 0.00"
|
||||
shipping = f"{cur} {data.get('shipping', 0):.2f}" if data.get("shipping") else f"{cur} 0.00"
|
||||
total = f"{cur} {data.get('total', 0):.2f}"
|
||||
|
||||
notes_html = ""
|
||||
if data.get("notes"):
|
||||
notes_html = f"<p>Notes: {data['notes']}</p>"
|
||||
|
||||
html = QUOTATION_TEMPLATE.format(
|
||||
quotation_number=data.get("quotation_number", "N/A"),
|
||||
customer_name=data.get("customer_name", ""),
|
||||
customer_company=data.get("customer_company", "") or "",
|
||||
customer_country=data.get("customer_country", "") or "",
|
||||
date=data.get("date", datetime.utcnow().strftime("%Y-%m-%d")),
|
||||
valid_until=data.get("valid_until", "N/A"),
|
||||
currency=cur,
|
||||
items_rows=items_rows,
|
||||
subtotal=subtotal,
|
||||
discount=discount,
|
||||
shipping=shipping,
|
||||
total=total,
|
||||
payment_terms=data.get("payment_terms", "N/A"),
|
||||
delivery_terms=data.get("delivery_terms", "N/A"),
|
||||
lead_time=data.get("lead_time", "N/A"),
|
||||
notes_html=notes_html,
|
||||
generated_at=datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC"),
|
||||
)
|
||||
|
||||
pdf = HTML(string=html).write_pdf()
|
||||
return pdf
|
||||
|
||||
|
||||
pdf_generator = PDFGenerator()
|
||||
@@ -0,0 +1,217 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, desc
|
||||
from app.models.customer import Message, Conversation
|
||||
from app.models.user import User
|
||||
from app.models.preference import PreferenceAnalysis
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserPreferenceService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def record_selection(self, user_id: str, message_id: str, selected_index: int) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(Message).where(Message.id == message_id)
|
||||
)
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
return False
|
||||
msg.selected_suggestion = selected_index
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
async def record_edit(self, user_id: str, message_id: str, edited_text: str) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(Message).where(Message.id == message_id)
|
||||
)
|
||||
msg = result.scalar_one_or_none()
|
||||
if not msg:
|
||||
return False
|
||||
msg.user_edited = edited_text
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
async def analyze_preferences(self, user_id: str) -> Dict[str, Any]:
|
||||
user_conv_subq = select(Conversation.id).where(
|
||||
Conversation.user_id == user_id
|
||||
).subquery()
|
||||
|
||||
count_result = await self.db.execute(
|
||||
select(func.count(Message.id)).where(
|
||||
and_(
|
||||
Message.conversation_id.in_(select(user_conv_subq)),
|
||||
Message.selected_suggestion.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
if total < 3:
|
||||
return {"needs_more_data": True, "interaction_count": total}
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Message)
|
||||
.where(
|
||||
and_(
|
||||
Message.conversation_id.in_(select(user_conv_subq)),
|
||||
Message.selected_suggestion.isnot(None),
|
||||
)
|
||||
)
|
||||
.order_by(desc(Message.created_at))
|
||||
.limit(100)
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
|
||||
tone_counts = {}
|
||||
edit_count = 0
|
||||
total_chars_saved = 0
|
||||
greeting_patterns = []
|
||||
signoff_patterns = []
|
||||
|
||||
for m in messages:
|
||||
suggestions = m.ai_suggestions or []
|
||||
selected = m.selected_suggestion
|
||||
if suggestions and selected is not None and selected < len(suggestions):
|
||||
tone = suggestions[selected].get("tone", "unknown")
|
||||
tone_counts[tone] = tone_counts.get(tone, 0) + 1
|
||||
|
||||
if m.user_edited:
|
||||
edit_count += 1
|
||||
if suggestions and selected is not None and selected < len(suggestions):
|
||||
original = suggestions[selected].get("reply", "")
|
||||
total_chars_saved += abs(len(original) - len(m.user_edited))
|
||||
|
||||
preferred_tone = max(tone_counts, key=tone_counts.get) if tone_counts else "professional"
|
||||
edit_ratio = edit_count / len(messages) if messages else 0
|
||||
avg_edit_size = total_chars_saved / edit_count if edit_count > 0 else 0
|
||||
|
||||
greeting_style = self._extract_greeting_style(messages)
|
||||
sign_off_style = self._extract_sign_off_style(messages)
|
||||
|
||||
preferences = {
|
||||
"preferred_tone": preferred_tone,
|
||||
"edit_ratio": edit_ratio,
|
||||
"avg_edit_size": avg_edit_size,
|
||||
"greeting_style": greeting_style,
|
||||
"sign_off_style": sign_off_style,
|
||||
"tone_distribution": tone_counts,
|
||||
"interaction_count": len(messages),
|
||||
"confidence": min(1.0, len(messages) / 20),
|
||||
}
|
||||
|
||||
existing = await self.db.execute(
|
||||
select(PreferenceAnalysis).where(PreferenceAnalysis.user_id == user_id)
|
||||
)
|
||||
analysis = existing.scalar_one_or_none()
|
||||
|
||||
if analysis:
|
||||
analysis.preferred_tone = preferred_tone
|
||||
analysis.greeting_style = greeting_style
|
||||
analysis.sign_off_style = sign_off_style
|
||||
analysis.analysis_data = preferences
|
||||
analysis.interaction_count = len(messages)
|
||||
analysis.confidence = preferences["confidence"]
|
||||
analysis.last_analysis_at = datetime.utcnow()
|
||||
else:
|
||||
analysis = PreferenceAnalysis(
|
||||
user_id=user_id,
|
||||
task_type="reply",
|
||||
preferred_tone=preferred_tone,
|
||||
greeting_style=greeting_style,
|
||||
sign_off_style=sign_off_style,
|
||||
analysis_data=preferences,
|
||||
interaction_count=len(messages),
|
||||
confidence=preferences["confidence"],
|
||||
last_analysis_at=datetime.utcnow(),
|
||||
)
|
||||
self.db.add(analysis)
|
||||
|
||||
await self.db.flush()
|
||||
|
||||
await self._update_user_settings(user_id, preferences)
|
||||
return preferences
|
||||
|
||||
async def get_preference_context(self, user_id: str, task_type: str = "reply") -> Optional[str]:
|
||||
result = await self.db.execute(
|
||||
select(PreferenceAnalysis).where(
|
||||
and_(
|
||||
PreferenceAnalysis.user_id == user_id,
|
||||
PreferenceAnalysis.task_type == task_type,
|
||||
)
|
||||
)
|
||||
)
|
||||
analysis = result.scalar_one_or_none()
|
||||
if not analysis or analysis.confidence < 0.3:
|
||||
return None
|
||||
|
||||
parts = []
|
||||
if analysis.preferred_tone:
|
||||
parts.append(f"user's preferred tone: {analysis.preferred_tone}")
|
||||
if analysis.greeting_style:
|
||||
parts.append(f"user's typical greeting: {analysis.greeting_style}")
|
||||
if analysis.sign_off_style:
|
||||
parts.append(f"user's typical sign-off: {analysis.sign_off_style}")
|
||||
|
||||
if parts:
|
||||
return "This user prefers: " + "; ".join(parts) + "."
|
||||
return None
|
||||
|
||||
async def get_analysis(self, user_id: str) -> Dict[str, Any]:
|
||||
result = await self.db.execute(
|
||||
select(PreferenceAnalysis).where(PreferenceAnalysis.user_id == user_id)
|
||||
)
|
||||
analysis = result.scalar_one_or_none()
|
||||
if not analysis:
|
||||
return {"analyzed": False, "interaction_count": 0, "confidence": 0}
|
||||
|
||||
return {
|
||||
"analyzed": True,
|
||||
"preferred_tone": analysis.preferred_tone,
|
||||
"greeting_style": analysis.greeting_style,
|
||||
"sign_off_style": analysis.sign_off_style,
|
||||
"interaction_count": analysis.interaction_count,
|
||||
"confidence": analysis.confidence,
|
||||
"last_analysis_at": analysis.last_analysis_at.isoformat() if analysis.last_analysis_at else None,
|
||||
}
|
||||
|
||||
async def _update_user_settings(self, user_id: str, preferences: Dict[str, Any]):
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
user = result.scalar_one_or_none()
|
||||
if user:
|
||||
settings = dict(user.settings or {})
|
||||
settings["preferred_tone"] = preferences.get("preferred_tone", settings.get("reply_tone", "professional"))
|
||||
settings["ai_learning"] = {
|
||||
"analyzed": True,
|
||||
"confidence": preferences.get("confidence", 0),
|
||||
"edit_ratio": preferences.get("edit_ratio", 0),
|
||||
"greeting_style": preferences.get("greeting_style", ""),
|
||||
"sign_off_style": preferences.get("sign_off_style", ""),
|
||||
}
|
||||
user.settings = settings
|
||||
await self.db.flush()
|
||||
|
||||
def _extract_greeting_style(self, messages: List[Message]) -> str:
|
||||
for m in messages:
|
||||
text = m.user_edited or (m.ai_suggestions[m.selected_suggestion].get("reply", "") if m.selected_suggestion is not None and m.ai_suggestions and m.selected_suggestion < len(m.ai_suggestions) else "")
|
||||
if text:
|
||||
first_word = text.strip().split()[0] if text.strip() else ""
|
||||
if first_word in ["Dear", "Hi", "Hello", "Hey", "To"]:
|
||||
return first_word
|
||||
return ""
|
||||
|
||||
def _extract_sign_off_style(self, messages: List[Message]) -> str:
|
||||
for m in messages:
|
||||
text = m.user_edited or (m.ai_suggestions[m.selected_suggestion].get("reply", "") if m.selected_suggestion is not None and m.ai_suggestions and m.selected_suggestion < len(m.ai_suggestions) else "")
|
||||
if text:
|
||||
words = text.strip().split()
|
||||
if len(words) >= 3:
|
||||
last_three = " ".join(words[-3:])
|
||||
for signoff in ["Best regards", "Best wishes", "Sincerely", "Cheers", "Regards", "Yours"]:
|
||||
if signoff in last_three:
|
||||
return signoff
|
||||
return ""
|
||||
@@ -0,0 +1,156 @@
|
||||
from typing import Optional, Dict, Any, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from app.config import settings
|
||||
from app.models.device import Device
|
||||
import httpx
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PushService:
|
||||
def __init__(self, db: Optional[AsyncSession] = None):
|
||||
self.db = db
|
||||
|
||||
@staticmethod
|
||||
def send_notification(user_id: str, title: str, content: str, payload: Optional[Dict[str, Any]] = None) -> bool:
|
||||
logger.info(f"[PUSH] user={user_id} title={title} content={content}")
|
||||
try:
|
||||
import asyncio
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
result = loop.run_until_complete(
|
||||
PushService._send_via_wechat(user_id, title, content, payload)
|
||||
)
|
||||
loop.close()
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.warning(f"Push send failed (logged only): {e}")
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def send_bulk(user_ids: List[str], title: str, content: str, payload: Optional[Dict[str, Any]] = None) -> int:
|
||||
sent = 0
|
||||
for uid in user_ids:
|
||||
if PushService.send_notification(uid, title, content, payload):
|
||||
sent += 1
|
||||
return sent
|
||||
|
||||
async def send_async(self, user_id: str, title: str, content: str, payload: Optional[Dict[str, Any]] = None) -> bool:
|
||||
logger.info(f"[PUSH_ASYNC] user={user_id} title={title}")
|
||||
|
||||
result = await self._send_via_wechat(user_id, title, content, payload)
|
||||
|
||||
await self._save_in_app_notification(user_id, title, content, payload)
|
||||
|
||||
return result
|
||||
|
||||
async def register_device(self, user_id: str, client_id: str, platform: str = "weapp", push_token: Optional[str] = None, device_info: Optional[Dict] = None) -> Device:
|
||||
result = await self.db.execute(
|
||||
select(Device).where(
|
||||
and_(Device.user_id == user_id, Device.client_id == client_id)
|
||||
)
|
||||
)
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
existing.platform = platform
|
||||
existing.push_token = push_token
|
||||
existing.device_info = device_info or {}
|
||||
existing.is_active = True
|
||||
await self.db.flush()
|
||||
return existing
|
||||
|
||||
device = Device(
|
||||
user_id=user_id,
|
||||
platform=platform,
|
||||
push_token=push_token,
|
||||
client_id=client_id,
|
||||
device_info=device_info or {},
|
||||
)
|
||||
self.db.add(device)
|
||||
await self.db.flush()
|
||||
return device
|
||||
|
||||
async def get_user_devices(self, user_id: str) -> List[Dict]:
|
||||
result = await self.db.execute(
|
||||
select(Device).where(
|
||||
and_(Device.user_id == user_id, Device.is_active == True)
|
||||
)
|
||||
)
|
||||
devices = result.scalars().all()
|
||||
return [
|
||||
{
|
||||
"id": str(d.id),
|
||||
"platform": d.platform,
|
||||
"client_id": d.client_id,
|
||||
"device_info": d.device_info,
|
||||
"created_at": d.created_at.isoformat() if d.created_at else None,
|
||||
}
|
||||
for d in devices
|
||||
]
|
||||
|
||||
async def unregister_device(self, user_id: str, client_id: str) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(Device).where(
|
||||
and_(Device.user_id == user_id, Device.client_id == client_id)
|
||||
)
|
||||
)
|
||||
device = result.scalar_one_or_none()
|
||||
if not device:
|
||||
return False
|
||||
device.is_active = False
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
async def _send_via_wechat(user_id: str, title: str, content: str, payload: Optional[Dict] = None) -> bool:
|
||||
if not settings.WECHAT_APP_ID or not settings.WECHAT_APP_SECRET:
|
||||
logger.debug("WeChat push not configured, falling back to log")
|
||||
return True
|
||||
|
||||
try:
|
||||
from app.services.wechat import wechat_service
|
||||
access_token = await wechat_service._get_access_token()
|
||||
if not access_token:
|
||||
logger.warning("Cannot get WeChat access token for push")
|
||||
return False
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
"https://api.weixin.qq.com/cgi-bin/message/subscribe/send",
|
||||
params={"access_token": access_token},
|
||||
json={
|
||||
"touser": user_id,
|
||||
"template_id": settings.WECHAT_PUSH_TEMPLATE_ID or "",
|
||||
"data": {
|
||||
"thing1": {"value": title[:20]},
|
||||
"thing2": {"value": content[:20]},
|
||||
},
|
||||
"miniprogram_state": "formal",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errcode", 0) != 0:
|
||||
logger.warning(f"WeChat push failed: {data}")
|
||||
return False
|
||||
logger.info(f"WeChat push sent to user {user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"WeChat push error: {e}")
|
||||
return False
|
||||
|
||||
async def _save_in_app_notification(self, user_id: str, title: str, content: str, payload: Optional[Dict] = None):
|
||||
if not self.db:
|
||||
return
|
||||
try:
|
||||
from app.services.notification import NotificationService
|
||||
await NotificationService.create_notification(
|
||||
self.db, user_id, title, content,
|
||||
notification_type="push",
|
||||
reference_type=(payload or {}).get("reference_type"),
|
||||
reference_id=(payload or {}).get("reference_id"),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save in-app notification: {e}")
|
||||
@@ -1,11 +1,13 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
from sqlalchemy import select, and_, or_
|
||||
from app.models.quotation import Quotation, QuotationItem
|
||||
from app.models.customer import Customer
|
||||
from app.models.user import Product
|
||||
from app.ai.router import get_ai_router
|
||||
from datetime import datetime
|
||||
import logging
|
||||
import json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -90,6 +92,135 @@ class QuotationService:
|
||||
await self.db.flush()
|
||||
return await self._to_dict(q)
|
||||
|
||||
async def generate_from_inquiry(
|
||||
self, user_id: str, inquiry_text: str, customer_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
ai = get_ai_router()
|
||||
|
||||
schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"product_requests": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"product_name": {"type": "string"},
|
||||
"quantity": {"type": "integer"},
|
||||
"unit": {"type": "string"},
|
||||
"specifications": {"type": "string"},
|
||||
"target_price": {"type": "string"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"payment_terms": {"type": "string"},
|
||||
"delivery_terms": {"type": "string"},
|
||||
"urgency": {"type": "string"},
|
||||
},
|
||||
}
|
||||
|
||||
extract_result = await ai.extract(inquiry_text, schema)
|
||||
extracted = extract_result.get("data", {})
|
||||
product_requests = extracted.get("product_requests", [])
|
||||
|
||||
if not product_requests:
|
||||
schema_simple = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"product_name": {"type": "string"},
|
||||
"quantity": {"type": "integer"},
|
||||
"specifications": {"type": "string"},
|
||||
},
|
||||
}
|
||||
extract_result = await ai.extract(inquiry_text, schema_simple)
|
||||
extracted = extract_result.get("data", {})
|
||||
if extracted.get("product_name"):
|
||||
product_requests = [{
|
||||
"product_name": extracted["product_name"],
|
||||
"quantity": extracted.get("quantity", 1),
|
||||
"unit": "pcs",
|
||||
"specifications": extracted.get("specifications", ""),
|
||||
}]
|
||||
|
||||
product_result = await self.db.execute(
|
||||
select(Product).where(
|
||||
and_(
|
||||
Product.user_id == user_id,
|
||||
Product.is_active == True,
|
||||
)
|
||||
)
|
||||
)
|
||||
user_products = product_result.scalars().all()
|
||||
|
||||
matched_products = []
|
||||
for req in product_requests:
|
||||
req_name = req.get("product_name", "").lower()
|
||||
best_match = None
|
||||
best_score = 0
|
||||
|
||||
for p in user_products:
|
||||
score = 0
|
||||
p_name = (p.name or "").lower()
|
||||
p_name_en = (p.name_en or "").lower()
|
||||
|
||||
if req_name in p_name or p_name in req_name:
|
||||
score += 3
|
||||
if req_name in p_name_en or p_name_en in req_name:
|
||||
score += 2
|
||||
|
||||
keywords = p.keywords or []
|
||||
for kw in keywords:
|
||||
if isinstance(kw, str) and kw.lower() in req_name:
|
||||
score += 1
|
||||
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_match = p
|
||||
|
||||
if best_match and best_score > 0:
|
||||
unit_price = float(best_match.price) if best_match.price else 0
|
||||
quantity = req.get("quantity", 1)
|
||||
matched_products.append({
|
||||
"product_id": str(best_match.id),
|
||||
"product_name": best_match.name,
|
||||
"description": best_match.description_en or best_match.description,
|
||||
"quantity": quantity,
|
||||
"unit_price": unit_price,
|
||||
"total_price": unit_price * quantity,
|
||||
"unit": req.get("unit", "pcs"),
|
||||
"match_score": best_score,
|
||||
})
|
||||
else:
|
||||
matched_products.append({
|
||||
"product_id": None,
|
||||
"product_name": req.get("product_name", "Unknown"),
|
||||
"description": req.get("specifications", ""),
|
||||
"quantity": req.get("quantity", 1),
|
||||
"unit_price": 0,
|
||||
"total_price": 0,
|
||||
"unit": req.get("unit", "pcs"),
|
||||
"match_score": 0,
|
||||
})
|
||||
|
||||
subtotal = sum(p["total_price"] for p in matched_products)
|
||||
total = subtotal
|
||||
|
||||
suggested_quotation = {
|
||||
"title": f"Quotation - {', '.join(p['product_name'] for p in matched_products[:3])}",
|
||||
"items": matched_products,
|
||||
"subtotal": subtotal,
|
||||
"total": total,
|
||||
"payment_terms": extracted.get("payment_terms", "T/T"),
|
||||
"delivery_terms": extracted.get("delivery_terms", "FOB"),
|
||||
"lead_time": "15-20 days" if extracted.get("urgency") != "urgent" else "7-10 days",
|
||||
"notes": f"Generated from customer inquiry: {inquiry_text[:100]}..." if len(inquiry_text) > 100 else f"Generated from customer inquiry: {inquiry_text}",
|
||||
"extracted_data": extracted,
|
||||
"matched_count": len([p for p in matched_products if p["product_id"]]),
|
||||
"unmatched_count": len([p for p in matched_products if not p["product_id"]]),
|
||||
}
|
||||
|
||||
return suggested_quotation
|
||||
|
||||
async def generate_quotation_text(self, q: Quotation) -> str:
|
||||
items_result = await self.db.execute(
|
||||
select(QuotationItem).where(QuotationItem.quotation_id == q.id)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from app.models.customer import Customer, Conversation, Message
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SilentPatternService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def analyze_silent_risk(self, user_id: str) -> List[Dict[str, Any]]:
|
||||
cutoff_3d = datetime.utcnow() - timedelta(days=3)
|
||||
cutoff_7d = datetime.utcnow() - timedelta(days=7)
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Customer).where(
|
||||
and_(
|
||||
Customer.user_id == user_id,
|
||||
Customer.status.in_(["lead", "negotiating"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
customers = result.scalars().all()
|
||||
|
||||
risk_scores = []
|
||||
for c in customers:
|
||||
score, reasons = await self._calculate_risk_score(c, cutoff_3d, cutoff_7d)
|
||||
if score > 0:
|
||||
risk_scores.append({
|
||||
"customer_id": str(c.id),
|
||||
"name": c.name,
|
||||
"company": c.company,
|
||||
"country": c.country,
|
||||
"status": c.status,
|
||||
"estimated_value": c.estimated_value,
|
||||
"last_contact_at": c.last_contact_at.isoformat() if c.last_contact_at else None,
|
||||
"silence_days": (datetime.utcnow() - c.last_contact_at).days if c.last_contact_at else 0,
|
||||
"risk_score": score,
|
||||
"risk_level": self._risk_level(score),
|
||||
"reasons": reasons,
|
||||
})
|
||||
|
||||
risk_scores.sort(key=lambda x: x["risk_score"], reverse=True)
|
||||
return risk_scores
|
||||
|
||||
async def _calculate_risk_score(
|
||||
self, customer: Customer, cutoff_3d: datetime, cutoff_7d: datetime
|
||||
) -> tuple:
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
if not customer.last_contact_at:
|
||||
return (0, [])
|
||||
|
||||
silence_days = (datetime.utcnow() - customer.last_contact_at).days
|
||||
|
||||
if silence_days >= 7:
|
||||
score += 40
|
||||
reasons.append(f"沉默超过7天")
|
||||
elif silence_days >= 3:
|
||||
score += 20
|
||||
reasons.append(f"沉默超过3天")
|
||||
|
||||
conv_query = await self.db.execute(
|
||||
select(Conversation).where(
|
||||
and_(
|
||||
Conversation.customer_id == customer.id,
|
||||
Conversation.user_id == customer.user_id,
|
||||
)
|
||||
).order_by(Conversation.created_at.desc()).limit(1)
|
||||
)
|
||||
conv = conv_query.scalar_one_or_none()
|
||||
if not conv:
|
||||
return (score, reasons)
|
||||
|
||||
msg_count_query = await self.db.execute(
|
||||
select(func.count(Message.id)).where(
|
||||
and_(
|
||||
Message.conversation_id == conv.id,
|
||||
Message.direction == "inbound",
|
||||
)
|
||||
)
|
||||
)
|
||||
inbound_count = msg_count_query.scalar() or 0
|
||||
|
||||
if inbound_count >= 5 and silence_days >= 3:
|
||||
score += 20
|
||||
reasons.append(f"前期沟通频繁({inbound_count}条)后突然沉默")
|
||||
|
||||
if customer.status == "lead" and silence_days >= 3:
|
||||
score += 15
|
||||
reasons.append("潜在客户阶段需及时跟进")
|
||||
|
||||
if customer.status == "negotiating" and silence_days >= 2:
|
||||
score += 25
|
||||
reasons.append("谈判阶段客户需保持热度")
|
||||
|
||||
recent_query = await self.db.execute(
|
||||
select(Message).where(
|
||||
and_(
|
||||
Message.conversation_id == conv.id,
|
||||
Message.created_at >= cutoff_7d,
|
||||
)
|
||||
).order_by(Message.created_at.desc()).limit(3)
|
||||
)
|
||||
recent_msgs = recent_query.scalars().all()
|
||||
|
||||
if recent_msgs:
|
||||
last_inbound = None
|
||||
for m in recent_msgs:
|
||||
if m.direction == "inbound":
|
||||
last_inbound = m
|
||||
break
|
||||
if last_inbound and silence_days >= 1:
|
||||
content_lower = last_inbound.content.lower()
|
||||
closing_signals = ["i'll think", "let me check", "too expensive", "high price", "not now", "maybe later", "considering"]
|
||||
for signal in closing_signals:
|
||||
if signal in content_lower:
|
||||
score += 15
|
||||
reasons.append(f"客户回复含消极信号: \"{signal}\"")
|
||||
break
|
||||
|
||||
return (min(score, 100), reasons)
|
||||
|
||||
def _risk_level(self, score: int) -> str:
|
||||
if score >= 70:
|
||||
return "high"
|
||||
elif score >= 40:
|
||||
return "medium"
|
||||
elif score >= 20:
|
||||
return "low"
|
||||
return "minimal"
|
||||
|
||||
async def get_suggestions(self, user_id: str, customer_id: str) -> List[str]:
|
||||
score_result = await self.analyze_silent_risk(user_id)
|
||||
customer_scores = [s for s in score_result if s["customer_id"] == customer_id]
|
||||
if not customer_scores:
|
||||
return []
|
||||
|
||||
score = customer_scores[0]
|
||||
suggestions = []
|
||||
silence_days = score["silence_days"]
|
||||
|
||||
if silence_days >= 7:
|
||||
suggestions.extend([
|
||||
f"客户{score['name']}已沉默{silence_days}天,建议发送产品更新或行业资讯重新激活",
|
||||
"考虑提供限时优惠或样品折扣打动客户",
|
||||
])
|
||||
elif silence_days >= 3:
|
||||
suggestions.extend([
|
||||
f"客户{score['name']}沉默{silence_days}天,建议发送跟进消息询问是否有进一步需求",
|
||||
"可分享相关案例或成功故事保持客户兴趣",
|
||||
])
|
||||
|
||||
if "negotiating" in score.get("status", ""):
|
||||
suggestions.append("谈判阶段客户,建议主动提供更多产品细节或定制方案")
|
||||
|
||||
if "消极信号" in str(score.get("reasons", [])):
|
||||
suggestions.append("客户曾表达价格顾虑,建议重新审视报价或提供增值服务")
|
||||
|
||||
if not suggestions:
|
||||
suggestions.append("客户状态良好,建议保持定期跟进节奏")
|
||||
|
||||
return suggestions
|
||||
@@ -0,0 +1,201 @@
|
||||
from typing import Dict, Any, Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_, or_
|
||||
from app.models.team import Team, TeamMember
|
||||
from app.models.user import User
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TeamService:
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def create_team(self, owner_id: str, name: str, description: str = None) -> Dict[str, Any]:
|
||||
existing = await self.db.execute(
|
||||
select(Team).where(and_(Team.owner_id == owner_id, Team.is_active == True))
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ValueError("You already own an active team")
|
||||
|
||||
user_result = await self.db.execute(select(User).where(User.id == owner_id))
|
||||
user = user_result.scalar_one_or_none()
|
||||
if not user:
|
||||
raise ValueError("User not found")
|
||||
|
||||
max_members = 20 if user.tier == "enterprise" else (10 if user.tier == "pro" else 5)
|
||||
|
||||
team = Team(
|
||||
name=name,
|
||||
owner_id=owner_id,
|
||||
description=description,
|
||||
max_members=max_members,
|
||||
tier=user.tier,
|
||||
)
|
||||
self.db.add(team)
|
||||
await self.db.flush()
|
||||
|
||||
member = TeamMember(
|
||||
team_id=team.id,
|
||||
user_id=owner_id,
|
||||
role="owner",
|
||||
status="active",
|
||||
)
|
||||
self.db.add(member)
|
||||
await self.db.flush()
|
||||
|
||||
return await self._to_dict(team, include_members=True)
|
||||
|
||||
async def get_team(self, team_id: str, user_id: str) -> Optional[Dict[str, Any]]:
|
||||
result = await self.db.execute(
|
||||
select(Team).where(Team.id == team_id)
|
||||
)
|
||||
team = result.scalar_one_or_none()
|
||||
if not team:
|
||||
return None
|
||||
|
||||
is_member = await self.db.execute(
|
||||
select(TeamMember).where(
|
||||
and_(TeamMember.team_id == team_id, TeamMember.user_id == user_id)
|
||||
)
|
||||
)
|
||||
if not is_member.scalar_one_or_none():
|
||||
return None
|
||||
|
||||
return await self._to_dict(team, include_members=True)
|
||||
|
||||
async def list_user_teams(self, user_id: str) -> List[Dict[str, Any]]:
|
||||
member_result = await self.db.execute(
|
||||
select(TeamMember.team_id).where(TeamMember.user_id == user_id)
|
||||
)
|
||||
team_ids = [r[0] for r in member_result.all()]
|
||||
|
||||
if not team_ids:
|
||||
return []
|
||||
|
||||
result = await self.db.execute(
|
||||
select(Team).where(Team.id.in_(team_ids), Team.is_active == True)
|
||||
)
|
||||
teams = result.scalars().all()
|
||||
|
||||
return [await self._to_dict(t) for t in teams]
|
||||
|
||||
async def invite_member(self, team_id: str, owner_id: str, user_id: str) -> Dict[str, Any]:
|
||||
team_result = await self.db.execute(
|
||||
select(Team).where(and_(Team.id == team_id, Team.owner_id == owner_id))
|
||||
)
|
||||
team = team_result.scalar_one_or_none()
|
||||
if not team:
|
||||
raise ValueError("Team not found or not authorized")
|
||||
|
||||
member_count = await self.db.execute(
|
||||
select(func.count(TeamMember.id)).where(
|
||||
and_(TeamMember.team_id == team_id, TeamMember.status == "active")
|
||||
)
|
||||
)
|
||||
if (member_count.scalar() or 0) >= team.max_members:
|
||||
raise ValueError("Team member limit reached")
|
||||
|
||||
existing = await self.db.execute(
|
||||
select(TeamMember).where(
|
||||
and_(TeamMember.team_id == team_id, TeamMember.user_id == user_id)
|
||||
)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise ValueError("User is already a member")
|
||||
|
||||
member = TeamMember(
|
||||
team_id=team_id,
|
||||
user_id=user_id,
|
||||
role="member",
|
||||
invited_by=owner_id,
|
||||
status="active",
|
||||
)
|
||||
self.db.add(member)
|
||||
await self.db.flush()
|
||||
|
||||
return {"user_id": user_id, "role": "member", "status": "active"}
|
||||
|
||||
async def remove_member(self, team_id: str, owner_id: str, user_id: str) -> bool:
|
||||
team_result = await self.db.execute(
|
||||
select(Team).where(and_(Team.id == team_id, Team.owner_id == owner_id))
|
||||
)
|
||||
team = team_result.scalar_one_or_none()
|
||||
if not team:
|
||||
return False
|
||||
|
||||
result = await self.db.execute(
|
||||
select(TeamMember).where(
|
||||
and_(TeamMember.team_id == team_id, TeamMember.user_id == user_id)
|
||||
)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member or member.role == "owner":
|
||||
return False
|
||||
|
||||
await self.db.delete(member)
|
||||
return True
|
||||
|
||||
async def leave_team(self, team_id: str, user_id: str) -> bool:
|
||||
result = await self.db.execute(
|
||||
select(TeamMember).where(
|
||||
and_(TeamMember.team_id == team_id, TeamMember.user_id == user_id)
|
||||
)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member or member.role == "owner":
|
||||
return False
|
||||
|
||||
await self.db.delete(member)
|
||||
return True
|
||||
|
||||
async def update_role(self, team_id: str, owner_id: str, user_id: str, role: str) -> bool:
|
||||
team_result = await self.db.execute(
|
||||
select(Team).where(and_(Team.id == team_id, Team.owner_id == owner_id))
|
||||
)
|
||||
if not team_result.scalar_one_or_none():
|
||||
return False
|
||||
|
||||
result = await self.db.execute(
|
||||
select(TeamMember).where(
|
||||
and_(TeamMember.team_id == team_id, TeamMember.user_id == user_id)
|
||||
)
|
||||
)
|
||||
member = result.scalar_one_or_none()
|
||||
if not member or member.role == "owner":
|
||||
return False
|
||||
|
||||
member.role = role
|
||||
await self.db.flush()
|
||||
return True
|
||||
|
||||
async def _to_dict(self, team: Team, include_members: bool = False) -> Dict[str, Any]:
|
||||
result = {
|
||||
"id": str(team.id),
|
||||
"name": team.name,
|
||||
"owner_id": str(team.owner_id),
|
||||
"description": team.description,
|
||||
"tier": team.tier,
|
||||
"is_active": team.is_active,
|
||||
"created_at": team.created_at.isoformat() if team.created_at else None,
|
||||
}
|
||||
|
||||
if include_members:
|
||||
members_result = await self.db.execute(
|
||||
select(TeamMember).where(TeamMember.team_id == team.id)
|
||||
)
|
||||
members = members_result.scalars().all()
|
||||
result["members"] = [
|
||||
{
|
||||
"user_id": str(m.user_id),
|
||||
"role": m.role,
|
||||
"status": m.status,
|
||||
"joined_at": m.joined_at.isoformat() if m.joined_at else None,
|
||||
}
|
||||
for m in members
|
||||
]
|
||||
result["member_count"] = len([m for m in members if m.status == "active"])
|
||||
|
||||
return result
|
||||
@@ -47,6 +47,7 @@ class TranslationService:
|
||||
async def generate_reply(
|
||||
self, inquiry: str, context: Optional[Dict[str, Any]] = None,
|
||||
tone: str = "professional", count: int = 3,
|
||||
preference_context: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
similar = await self.corpus.find_similar(inquiry, "reply")
|
||||
if similar and count > 1:
|
||||
@@ -57,7 +58,7 @@ class TranslationService:
|
||||
|
||||
for t in tones:
|
||||
try:
|
||||
result = await self.ai.reply(inquiry, context, t)
|
||||
result = await self.ai.reply(inquiry, context, t, preference_context)
|
||||
results.append({
|
||||
"reply": result.get("reply", ""),
|
||||
"tone": t,
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
from typing import Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
import edge_tts
|
||||
HAS_EDGE_TTS = True
|
||||
except ImportError:
|
||||
HAS_EDGE_TTS = False
|
||||
logger.warning("edge-tts not installed, TTS disabled")
|
||||
|
||||
VOICE_MAP = {
|
||||
"zh": "zh-CN-XiaoxiaoNeural",
|
||||
"en": "en-US-AriaNeural",
|
||||
"ja": "ja-JP-NanamiNeural",
|
||||
"ko": "ko-KR-SunHiNeural",
|
||||
"fr": "fr-FR-DeniseNeural",
|
||||
"de": "de-DE-KatjaNeural",
|
||||
"es": "es-ES-ElviraNeural",
|
||||
"pt": "pt-BR-FranciscaNeural",
|
||||
"ru": "ru-RU-SvetlanaNeural",
|
||||
"ar": "ar-SA-ZariyahNeural",
|
||||
}
|
||||
|
||||
SUPPORTED_LANGS = list(VOICE_MAP.keys())
|
||||
|
||||
|
||||
class TextToSpeechService:
|
||||
@staticmethod
|
||||
async def synthesize(text: str, lang: str = "en", rate: str = "0%", pitch: str = "0Hz") -> Optional[bytes]:
|
||||
if not HAS_EDGE_TTS:
|
||||
logger.warning("edge-tts not available")
|
||||
return None
|
||||
|
||||
voice = VOICE_MAP.get(lang, VOICE_MAP["en"])
|
||||
|
||||
try:
|
||||
communicate = edge_tts.Communicate(text, voice, rate=rate, pitch=pitch)
|
||||
audio_data = b""
|
||||
async for chunk in communicate.stream():
|
||||
if chunk["type"] == "audio":
|
||||
audio_data += chunk["data"]
|
||||
return audio_data if audio_data else None
|
||||
except Exception as e:
|
||||
logger.error(f"TTS failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
tts_service = TextToSpeechService()
|
||||
@@ -0,0 +1,80 @@
|
||||
from typing import Optional, Dict, Any
|
||||
import httpx
|
||||
import logging
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WeChatService:
|
||||
def __init__(self):
|
||||
self.app_id = settings.WECHAT_APP_ID
|
||||
self.app_secret = settings.WECHAT_APP_SECRET
|
||||
self.api_base = "https://api.weixin.qq.com"
|
||||
|
||||
async def code2session(self, js_code: str) -> Optional[Dict[str, Any]]:
|
||||
if not self.app_id or not self.app_secret:
|
||||
logger.warning("WeChat not configured")
|
||||
return None
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.api_base}/sns/jscode2session",
|
||||
params={
|
||||
"appid": self.app_id,
|
||||
"secret": self.app_secret,
|
||||
"js_code": js_code,
|
||||
"grant_type": "authorization_code",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
|
||||
if data.get("errcode", 0) != 0:
|
||||
logger.error(f"WeChat code2session failed: {data}")
|
||||
return None
|
||||
|
||||
return {
|
||||
"openid": data.get("openid"),
|
||||
"session_key": data.get("session_key"),
|
||||
"unionid": data.get("unionid"),
|
||||
}
|
||||
|
||||
async def get_phone_number(self, code: str) -> Optional[str]:
|
||||
if not self.app_id or not self.app_secret:
|
||||
return None
|
||||
|
||||
access_token = await self._get_access_token()
|
||||
if not access_token:
|
||||
return None
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{self.api_base}/wxa/business/getuserphonenumber",
|
||||
params={"access_token": access_token},
|
||||
json={"code": code},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
if data.get("errcode", 0) != 0:
|
||||
logger.error(f"WeChat getPhoneNumber failed: {data}")
|
||||
return None
|
||||
|
||||
return data.get("phone_info", {}).get("phoneNumber")
|
||||
|
||||
async def _get_access_token(self) -> Optional[str]:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
f"{self.api_base}/cgi-bin/token",
|
||||
params={
|
||||
"grant_type": "client_credential",
|
||||
"appid": self.app_id,
|
||||
"secret": self.app_secret,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
return data.get("access_token")
|
||||
|
||||
|
||||
wechat_service = WeChatService()
|
||||
@@ -85,6 +85,48 @@ class WhatsAppService:
|
||||
)
|
||||
return resp.status_code == 200
|
||||
|
||||
async def send_media(self, to: str, media_url: str, media_type: str = "image", caption: Optional[str] = None) -> bool:
|
||||
if not self.api_token or not self.phone_number_id:
|
||||
return False
|
||||
|
||||
body = {
|
||||
"messaging_product": "whatsapp",
|
||||
"to": to,
|
||||
"type": media_type,
|
||||
media_type: {"link": media_url},
|
||||
}
|
||||
if caption:
|
||||
body[media_type]["caption"] = caption
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{self.api_base}/messages",
|
||||
headers={"Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json"},
|
||||
json=body,
|
||||
timeout=30,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
logger.error(f"WhatsApp media send failed: {resp.text}")
|
||||
return False
|
||||
return True
|
||||
|
||||
async def mark_as_read(self, message_id: str) -> bool:
|
||||
if not self.api_token:
|
||||
return False
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
f"{self.api_base}/messages",
|
||||
headers={"Authorization": f"Bearer {self.api_token}", "Content-Type": "application/json"},
|
||||
json={
|
||||
"messaging_product": "whatsapp",
|
||||
"status": "read",
|
||||
"message_id": message_id,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
return resp.status_code == 200
|
||||
|
||||
def parse_webhook(self, body: Dict) -> Optional[Dict]:
|
||||
try:
|
||||
entry = body.get("entry", [{}])[0]
|
||||
@@ -96,14 +138,29 @@ class WhatsAppService:
|
||||
return None
|
||||
|
||||
msg = messages[0]
|
||||
msg_type = msg.get("type", "text")
|
||||
content = ""
|
||||
|
||||
if msg_type == "text":
|
||||
content = msg.get("text", {}).get("body", "")
|
||||
elif msg_type in ("image", "document", "audio", "video"):
|
||||
media = msg.get(msg_type, {})
|
||||
content = media.get("caption", "") or media.get("filename", "") or f"[{msg_type}]"
|
||||
|
||||
return {
|
||||
"from": msg.get("from"),
|
||||
"text": msg.get("text", {}).get("body", ""),
|
||||
"text": content,
|
||||
"msg_id": msg.get("id"),
|
||||
"timestamp": msg.get("timestamp"),
|
||||
"type": msg.get("type", "text"),
|
||||
"type": msg_type,
|
||||
"profile_name": value.get("contacts", [{}])[0].get("profile", {}).get("name"),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse WhatsApp webhook: {e}")
|
||||
return None
|
||||
|
||||
def _build_headers(self) -> Dict[str, str]:
|
||||
return {
|
||||
"Authorization": f"Bearer {self.api_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user