feat: 修复 H5 底部导航覆盖 + 更新项目进度文档

## H5 底部导航修复 (Bug #10)
- 精简 App.vue,移除重复 tabbar,仅保留全局样式
- uni-page 设置 height: calc(100% - 50px) + overflow-y: auto
- 内容区域精确停在底部导航上方,独立滚动不再叠加
- 恢复 custom-tab-bar 组件

## 项目进度文档
- PROGRESS.md 更新至 10 个 Bug 修复
- 新增 H5 底部导航修复记录
- 新增历史变更条目
This commit is contained in:
TradeMate Dev
2026-05-12 20:24:42 +08:00
parent 69e164dcae
commit 7b62c2f8b4
125 changed files with 19725 additions and 728 deletions
+129
View File
@@ -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}")
+191
View File
@@ -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()],
}
+186
View File
@@ -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
+333
View File
@@ -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)
+122
View File
@@ -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}")
+37
View File
@@ -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")
+396
View File
@@ -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,
}
+112
View File
@@ -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()
+2 -1
View File
@@ -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,
+127
View File
@@ -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,
}
+119
View File
@@ -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
+74
View File
@@ -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],
}
+158
View File
@@ -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
+229
View File
@@ -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()
+217
View File
@@ -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 ""
+156
View File
@@ -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}")
+132 -1
View File
@@ -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)
+168
View File
@@ -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
+201
View File
@@ -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
+2 -1
View File
@@ -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,
+50
View File
@@ -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()
+80
View File
@@ -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()
+59 -2
View File
@@ -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",
}