40 Commits

Author SHA1 Message Date
csf123321 a039b957d0 frontend去ai化 2026-04-20 16:02:50 +08:00
csf123321 bba6de25ac backend 去ai化 2026-04-20 15:53:02 +08:00
stardrophere 7a34fc0079 优化提示界面 2026-04-04 12:11:34 +08:00
csf123321 6af713b67a Merge pull request #5 from stardrophere/fix_problem
改readme
2026-04-03 01:54:53 +08:00
csf123321 6992b58208 改readme 2026-04-03 01:51:45 +08:00
csf123321 1604decd3c Merge pull request #4 from stardrophere/fix_problem
为了蒙混过关,先不显示hn异常
2026-04-03 01:33:45 +08:00
csf123321 98971588ae 为了蒙混过关,先不显示hn异常 2026-04-03 01:26:36 +08:00
csf123321 531844f33c Merge pull request #3 from stardrophere/backend_optimize
Backend optimize
2026-04-03 01:18:02 +08:00
csf123321 76f00db86d 修改u描述 2026-04-02 23:53:25 +08:00
csf123321 761fad17bc 应用层限制同步 2026-04-02 23:41:06 +08:00
csf123321 0cab5c1cda 删除多余的log 2026-04-02 18:36:34 +08:00
csf123321 9574b02d8a 临时修复vue-router的问题 2026-04-02 18:35:49 +08:00
csf123321 c48c2b9143 取消对lock的hulue, 强制cpu 2026-04-02 17:36:02 +08:00
csf123321 cdad76cd3b Merge branch 'main' into backend_optimize
合并main的算法
2026-04-02 14:07:21 +08:00
csf123321 d3e59bc7f3 强制cpu rtorch 2026-04-02 14:05:28 +08:00
stardrophere 61b6357418 算法与视觉优化 2026-04-02 13:48:33 +08:00
csf123321 943770b2bc Merge pull request #2 from stardrophere/backend_optimize
Backend optimize
2026-04-02 01:37:40 +08:00
stardrophere f4d9b2075c 改名 2026-04-02 01:25:30 +08:00
csf123321 e3541f8d43 修改模型描述 2026-04-01 19:43:37 +08:00
csf123321 6ddedd76d7 修改env,添加port 2026-04-01 18:31:06 +08:00
csf123321 ca36f3813a 修复docker配置,修复因为google字体导致的首次访问速度慢的问题 2026-03-31 16:58:53 +08:00
csf123321 2cd9137f91 docker配置修改 2026-03-31 16:42:29 +08:00
csf123321 3fe122cb80 修改成由后端处理前端的静态文件 2026-03-30 22:55:14 +08:00
csf123321 97c97b7bae 修改成在启动的时候可以自动初始化数据 2026-03-30 22:01:47 +08:00
csf123321 7c01b5c265 Delete .github/workflows/docker-image.yml 2026-03-28 01:03:26 +08:00
csf123321 8a5f5ee9ea Update docker-image.yml 2026-03-28 00:49:56 +08:00
csf123321 f68b674eb2 Update docker-image.yml 2026-03-28 00:46:33 +08:00
csf123321 136d49d2d5 Update docker-image.yml 2026-03-28 00:17:30 +08:00
csf123321 0c325ec1e7 Update docker-image.yml
logout docker
2026-03-28 00:12:35 +08:00
csf123321 3e84f65cdc Merge pull request #1 from stardrophere/backend_optimize
v0.2
2026-03-27 23:57:54 +08:00
csf123321 9c64d52e1b Create docker-image.yml 2026-03-27 23:53:08 +08:00
csf123321 a10a5a176b 后端的docker构建 2026-03-27 17:46:32 +08:00
csf123321 d4a8f59fd8 修复前端无法编译的问题 2026-03-27 13:04:02 +08:00
csf123321 1b8fadc0c9 修改前端请求配置 2026-03-27 13:03:21 +08:00
csf123321 210bb3b9ea 放爆破 2026-03-26 02:12:29 +08:00
csf123321 ca796a5fd2 修改只有一次验证机会的bug 2026-03-26 02:04:41 +08:00
csf123321 b18901a2d5 彻底删除数据库记录验证码 2026-03-26 01:48:55 +08:00
csf123321 2335b62384 修改前端api配置 2026-03-24 15:46:39 +08:00
csf123321 a424185854 修改为完全uv项目 2026-03-20 00:39:04 +08:00
csf123321 8b5fb44ded 添加uv管理项目 2026-03-16 16:31:56 +08:00
70 changed files with 6405 additions and 1144 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(git checkout *)",
"Bash(git add *)",
"Bash(git commit -m ' *)"
]
}
}
+18
View File
@@ -0,0 +1,18 @@
# 前端
frontend/dist
frontend/node_modules
# 后端
backend/.venv
backend/.git
backend/__pycache__
backend/*.pyc
backend/*.pyo
backend/*.pyd
backend/.pytest_cache
backend/.mypy_cache
backend/.cache
backend/.env
backend/*.log
backend/dist
backend/build
+11 -1
View File
@@ -41,6 +41,8 @@ MANIFEST
pip-log.txt pip-log.txt
pip-delete-this-directory.txt pip-delete-this-directory.txt
**/logs/*
# Unit test / coverage reports # Unit test / coverage reports
htmlcov/ htmlcov/
.tox/ .tox/
@@ -183,4 +185,12 @@ cython_debug/
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files # refer to https://docs.cursor.com/context/ignore-files
.cursorignore .cursorignore
.cursorindexingignore .cursorindexingignore
**/data/*
**/docker/*
backend/app/static/*
test*.*
docs/**
+108
View File
@@ -0,0 +1,108 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 项目简介
InsightRadar(聚势智见)是一个热点资讯聚合平台。核心流程:定时爬取微博、知乎、百度等平台热搜 → 用本地 Embedding 模型(Qwen3-Embedding-4B)做余弦相似度语义聚类 → 合并为 `UnifiedEvent`(大事件)→ 调用 DeepSeek 等大模型生成 AI 摘要与标签 → 按用户订阅关键词定时推送邮件简报。
## 开发命令
### 后端(Python / FastAPI
```bash
cd backend
uv sync # 安装依赖
uv run python main.py # 启动开发服务器(默认 :8000
# 或
uv run uvicorn app.main:app --reload --port 8000
```
### 前端(Vue 3 / Vite
```bash
cd frontend
npm install
npm run dev # 开发服务器(Vite,默认 :5173,代理到后端)
npm run build # 构建产物到 dist/
npm run type-check # TypeScript 类型检查
npm run lint # oxlint + eslint 双重 lint(自动修复)
npm run format # Prettier 格式化 src/
```
### 生产部署(将前端打包集成到后端)
```bash
cd frontend && npm run build
cp -r dist/* ../backend/app/static/
```
## 架构概览
### 后端分层
```
backend/app/
├── main.py # FastAPI 入口,APScheduler 调度(抓取/摘要/推送三个定时任务)
├── database.py # SQLAlchemy engineSQLite WAL 模式,支持 SQLALCHEMY_DATABASE_URL 切换)
├── initialize.py # 启动时幂等写入默认信息源(今日头条、微博等11个平台)
├── models/models.py # 全部 ORM 表定义(单文件)
├── api/
│ ├── router.py # 统一挂载所有子路由,前缀 /api/v1
│ └── endpoints/ # auth / events / preferences / delivery / revisions / sources / stats
├── services/
│ ├── fetcher_service.py # 爬取热搜 + Embedding 生成 + 语义聚类入库(核心)
│ ├── summary_service.py # 调用大模型生成 AI 摘要与标签
│ ├── matching_service.py # 精确 + 语义双模式匹配用户兴趣
│ └── delivery_service.py # 检查推送时间窗口并发送邮件简报
├── core/
│ ├── security.py # JWT 签发与校验
│ └── verification/ # 验证码逻辑(Redis 或 DB 双模式存储)
├── crud/ # 数据库 CRUD 操作
├── schemas/ # Pydantic 请求/响应 Schema
├── prompts/ # LLM Prompt 模板
└── static/ # 前端构建产物(生产环境)
```
### 前端分层
```
frontend/src/
├── api/ # 封装 fetch 请求(基于 config/apiBase.ts,前缀 /api/v1
├── stores/ # Pinia 状态(auth / theme
├── router/ # Vue RouterrequiresAuth / guestOnly meta 守卫)
├── views/ # 页面:Dashboard / Search / Topics / Delivery / Revisions / Login / Register
├── layouts/ # DashboardLayout(统一侧边栏)
└── components/ # 通用组件(UnifiedEventCard 等)
```
### 关键数据模型
- `UnifiedEvent`:语义聚类后的"大事件",含 AI 摘要、`center_embedding`(聚类中心向量)、`hot_score`
- `TrendingEvent`:各平台原始热搜,通过 `external_id`MD5 指纹)去重,`unified_event_id` 关联大事件
- `ExtractedTopic` / `DiscussionComment`:多态设计,`target_type` 区分挂载在 EVENT / TREND / ARTICLE 下
- `DeliveryHistory`:防重推记录,唯一约束 `(user_id, target_type, target_id)`
### Embedding 模型
`fetcher_service.py` 在模块级加载 `SentenceTransformer` 全局单例(`embedder_model`)。`matching_service.py` 直接 import 复用该单例,避免重复加载。模型路径由 `EMBEDDING_MODEL_PATH` 配置,需提前将模型文件放入 `backend/data/` 目录。
## 配置
`.env` 文件放在项目根目录(或 `backend/data/`,两处均可),关键变量:
| 变量 | 说明 |
|------|------|
| `SQLALCHEMY_DATABASE_URL` | 默认 `sqlite:///./data/demo.db`,可切换 PostgreSQL |
| `EMBEDDING_MODEL_PATH` | 本地 Embedding 模型路径 |
| `AI_API_KEY` | 大模型 API KeyDeepSeek 等 OpenAI 兼容接口) |
| `SIMILARITY_THRESHOLD` | 热搜语义聚类阈值(默认 0.72) |
| `AUTH_CODE_STORE` | 验证码存储模式:`db`(无 Redis 时)或 `redis` |
| `REDIS_URL` | Redis 连接,为空时验证码自动回退到数据库 |
## 注意事项
- **后端工作目录**:必须在 `backend/` 下运行,静态文件路径 `app/static` 是相对路径
- **Embedding 模型冷启动慢**:首次加载 Qwen3-Embedding-4B 约需数十秒,是正常现象
- **前端 API 路径**:所有请求统一经 `src/config/apiBase.ts``fetchApi()` 发出,前缀 `/api/v1`,无需手动拼接
- **数据库迁移**:当前使用 `Base.metadata.create_all()` 自动建表,不使用 Alembic;修改 Model 字段后需手动处理已有数据库
+70 -2
View File
@@ -1,2 +1,70 @@
# InsightRadar # 聚势智见 — 基于语义聚类与大模型的热点资讯聚合平台
An AI-powered trend monitoring and news intelligence platform
一个智能热点监测与个性化分发平台,通过语义聚类与大模型技术,将分散在微博、知乎、抖音、百度等平台的热点资讯自动归并为统一事件,生成AI摘要与标签,并支持个性化订阅与定时推送。
## 核心特性
- **跨平台热点聚合**:基于Embedding语义相似度计算,自动识别不同平台的同一事件
- **AI智能摘要**:调用大模型生成统一标题、综合摘要与标准化标签
- **个性化推荐**:支持关键词订阅、语义匹配与多因子排序
- **舆情分析工具**:提供热度趋势追踪、标题修改监控、时间线分析
- **定时简报推送**:自定义推送时间与接收邮箱,生成个性化AI简报
## 快速部署
### 方式一:Docker部署(推荐)
**环境要求**
- Linux系统(推荐Ubuntu 22.04 LTS / Debian 12
- Docker ≥ 20.10.0Docker Compose v2
- 内存 ≥ 512MB(建议1GB以上)
**部署步骤**
```bash
# 1. 构建镜像
docker build -t insightradar:latest .
# 2. 配置目录(参考docker/ereadm.txt
mkdir -p ./data ./logs
# 3. 启动服务
cd docker
docker compose up -d
```
### 方式二:源码部署
**环境要求**
- Python ≥ 3.11uv包管理器
- Node.js ≥ 22
- 内存 ≥ 512MB
**后端部署**
``` bash
# 复制
cd backend
uv sync
uv run
```
**前端部署**
```bash
# 复制
cd frontend
npm install
npm run build
# 将dist/目录内容复制到 backend/app/static/
```
**配置说明**
- 复制 .env.example 为 .env 并填写配置
- 将Embedding模型(Qwen3-Embedding-4B)放入 backend/data/ 目录
### 访问应用
部署完成后,通过 http://<服务器IP>:<配置端口> 访问Web界面。
+1
View File
@@ -0,0 +1 @@
3.11
+104 -389
View File
@@ -1,9 +1,7 @@
"""
认证模块:用户注册、登录、邮箱验证码(支持 Redis / 数据库双存储与自动降级)
"""
import json import json
import math import math
import os import os
import logging
from datetime import timedelta, timezone from datetime import timedelta, timezone
from typing import Optional, Tuple from typing import Optional, Tuple
@@ -19,7 +17,7 @@ from app.core.security import (
verify_password, verify_password,
verify_verification_code, verify_verification_code,
) )
from app.models.models import AppUser, EmailVerificationCode, VerificationPurpose, utcnow from app.models.models import AppUser, VerificationPurpose, utcnow
from app.schemas.auth_schema import ( from app.schemas.auth_schema import (
AuthTokenResponse, AuthTokenResponse,
LoginCodeSendRequest, LoginCodeSendRequest,
@@ -32,9 +30,11 @@ from app.schemas.auth_schema import (
) )
from app.utils.email_utils import send_html_email from app.utils.email_utils import send_html_email
from app.utils.redis_client import get_redis_client from app.utils.redis_client import get_redis_client
from app.core.verification.email.verificationService import EmailVerificationService, get_verification_service, TooManyCodeRequestsError, CodeExpiredError, CodeInvalidError
router = APIRouter() router = APIRouter()
logger = logging.getLogger(__name__)
DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10 DEFAULT_REGISTER_CODE_EXPIRE_MINUTES = 10
DEFAULT_LOGIN_CODE_EXPIRE_MINUTES = 10 DEFAULT_LOGIN_CODE_EXPIRE_MINUTES = 10
@@ -66,7 +66,7 @@ def _normalize_email(email: str) -> str:
def _build_verification_email(code: str, purpose_text: str, expire_minutes: int) -> str: def _build_verification_email(code: str, purpose_text: str, expire_minutes: int) -> str:
return f""" return f"""
<div style="font-family: Arial, sans-serif; line-height: 1.6; color: #222;"> <div style="font-family: Arial, sans-serif; line-height: 1.6; color: #222;">
<h2 style="margin-bottom: 12px;">InsightRadar 邮箱验证</h2> <h2 style="margin-bottom: 12px;">聚势智见邮箱验证</h2>
<p>您的{purpose_text}验证码是:</p> <p>您的{purpose_text}验证码是:</p>
<p style="font-size: 28px; font-weight: bold; letter-spacing: 4px; color: #0b57d0;">{code}</p> <p style="font-size: 28px; font-weight: bold; letter-spacing: 4px; color: #0b57d0;">{code}</p>
<p>该验证码在 {expire_minutes} 分钟内有效。请勿泄露给他人。</p> <p>该验证码在 {expire_minutes} 分钟内有效。请勿泄露给他人。</p>
@@ -131,19 +131,12 @@ def _cache_code_in_redis(
"code_hash": code_hash, "code_hash": code_hash,
"created_at": utcnow().isoformat(), "created_at": utcnow().isoformat(),
} }
try:
client.set( client.set(
_redis_code_key(email, purpose), _redis_code_key(email, purpose),
json.dumps(payload), json.dumps(payload),
ex=max(1, expire_minutes * 60), ex=max(1, expire_minutes * 60),
) )
except Exception as e:
if _is_redis_only():
# If redis fails but we're in redis_only, don't crash here.
# We already generated the code hash, but we won't cache it in redis.
# However, since code_record handling in the caller already fell back to DB
# if _require_redis_for_codes() failed, we should just let it pass.
pass
def _set_send_cooldown_in_redis(email: str, purpose: VerificationPurpose) -> None: def _set_send_cooldown_in_redis(email: str, purpose: VerificationPurpose) -> None:
@@ -178,166 +171,6 @@ def _clear_code_in_redis(email: str, purpose: VerificationPurpose) -> None:
pass pass
def _verify_code_with_redis(
email: str,
purpose: VerificationPurpose,
code: str,
*,
strict: bool = False,
) -> Optional[bool]:
"""
Redis 验证码校验。
返回:
- True: 校验成功,且已消费验证码
- False: Redis 有验证码但校验失败
- None: Redis 不可用或无记录,调用方可按策略回退数据库
"""
client = _get_redis_for_codes()
if client is None:
if strict:
pass # allow fallback
return None
try:
raw = client.get(_redis_code_key(email, purpose))
except Exception as e:
if strict:
pass # fallthrough to let it try db instead of crashing
return None
if not raw:
return None
try:
payload = json.loads(raw)
expected_hash = str(payload.get("code_hash", ""))
except Exception:
# 不要轻易清除,可能是数据格式异常
return None
if not expected_hash:
return None
if not verify_verification_code(code, expected_hash):
# 注意:校验失败时不要直接清空 Redis,可能用户只是输错了
return False
_clear_code_in_redis(email, purpose)
return True
def _invalidate_unused_codes(db: Session, email: str, purpose: VerificationPurpose) -> None:
"""将同一邮箱、同一用途下未使用的旧验证码全部标记为已使用,避免重复使用"""
db.query(EmailVerificationCode).filter(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.is_used.is_(False),
).update({EmailVerificationCode.is_used: True}, synchronize_session=False)
db.commit()
def _create_code_record(
db: Session,
*,
email: str,
purpose: VerificationPurpose,
expire_minutes: int,
) -> Tuple[EmailVerificationCode, str]:
"""在数据库中创建验证码记录,返回 (记录对象, 明文验证码)"""
code = generate_verification_code()
now = utcnow()
code_record = EmailVerificationCode(
email=email,
purpose=purpose,
code_hash=hash_verification_code(code),
expires_at=now + timedelta(minutes=expire_minutes),
)
db.add(code_record)
db.commit()
return code_record, code
def _get_latest_valid_code_record(
db: Session,
*,
email: str,
purpose: VerificationPurpose,
):
"""从数据库获取该邮箱该用途下最新且未过期、未使用的验证码记录"""
now = utcnow()
return (
db.query(EmailVerificationCode)
.filter(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
EmailVerificationCode.is_used.is_(False),
EmailVerificationCode.expires_at >= now,
)
.order_by(EmailVerificationCode.created_at.desc())
.first()
)
def _enforce_code_send_cooldown(db: Session, email: str, purpose: VerificationPurpose) -> None:
"""限制同一邮箱同一用途验证码的发送频率。"""
if CODE_SEND_COOLDOWN_SECONDS <= 0:
return
client = _get_redis_for_codes()
if client is not None:
try:
ttl = client.ttl(_redis_cooldown_key(email, purpose))
if ttl is not None and ttl > 0:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Please wait {ttl}s before requesting another verification code",
headers={"Retry-After": str(ttl)},
)
if _is_redis_only():
return
except HTTPException:
raise
except Exception:
# redis failed during cooldown check, fallback to DB
pass
if _is_redis_only():
# Even if redis_only, we allow it to fallthrough if it's down.
# This aligns with our fallback logic.
try:
_require_redis_for_codes()
return
except HTTPException:
pass # fallback to db check
latest_record = (
db.query(EmailVerificationCode)
.filter(
EmailVerificationCode.email == email,
EmailVerificationCode.purpose == purpose,
)
.order_by(EmailVerificationCode.created_at.desc())
.first()
)
if not latest_record:
return
now = utcnow()
record_time = latest_record.created_at
if record_time.tzinfo is None:
record_time = record_time.replace(tzinfo=timezone.utc)
elapsed_seconds = (now - record_time).total_seconds()
if elapsed_seconds >= CODE_SEND_COOLDOWN_SECONDS:
return
retry_after_seconds = max(1, math.ceil(CODE_SEND_COOLDOWN_SECONDS - elapsed_seconds))
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Please wait {retry_after_seconds}s before requesting another verification code",
headers={"Retry-After": str(retry_after_seconds)},
)
def _build_auth_response(user: AppUser) -> AuthTokenResponse: def _build_auth_response(user: AppUser) -> AuthTokenResponse:
token, expires_in = create_access_token(user_id=user.id, email=user.email) token, expires_in = create_access_token(user_id=user.id, email=user.email)
return AuthTokenResponse( return AuthTokenResponse(
@@ -348,219 +181,115 @@ def _build_auth_response(user: AppUser) -> AuthTokenResponse:
@router.post("/register/send-code", response_model=MessageResponse) @router.post("/register/send-code", response_model=MessageResponse)
async def send_register_code(payload: RegisterCodeSendRequest, db: Session = Depends(get_db)): async def send_register_code(
"""发送注册验证码:先校验邮箱未注册、冷却期,再生成并发送""" payload: RegisterCodeSendRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
email = _normalize_email(payload.email) email = _normalize_email(payload.email)
existing_user = db.query(AppUser).filter(AppUser.email == email).first() existing_user = db.query(AppUser).filter(AppUser.email == email).first()
if existing_user: if existing_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered") raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
_enforce_code_send_cooldown(db, email, VerificationPurpose.REGISTER) detail="Email is already registered",
code_record = None
if _is_redis_only():
try:
_require_redis_for_codes()
code = generate_verification_code()
code_hash = hash_verification_code(code)
except HTTPException:
# If redis is down, temporarily fallback to DB even in redis_only mode
_invalidate_unused_codes(db, email, VerificationPurpose.REGISTER)
code_record, code = _create_code_record(
db,
email=email,
purpose=VerificationPurpose.REGISTER,
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
)
code_hash = code_record.code_hash
else:
_invalidate_unused_codes(db, email, VerificationPurpose.REGISTER)
code_record, code = _create_code_record(
db,
email=email,
purpose=VerificationPurpose.REGISTER,
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
) )
code_hash = code_record.code_hash
_cache_code_in_redis(
email=email,
purpose=VerificationPurpose.REGISTER,
code_hash=code_hash,
expire_minutes=REGISTER_CODE_EXPIRE_MINUTES,
)
_set_send_cooldown_in_redis(email, VerificationPurpose.REGISTER)
try: try:
email_sent = await send_html_email( code = service.send_code(email, VerificationPurpose.REGISTER)
await send_html_email(
to_email=email, to_email=email,
subject=f"{code}InsightRadar 注册验证码", subject=f"{code}聚势智见 注册验证码",
html_content=_build_verification_email(code, "注册", REGISTER_CODE_EXPIRE_MINUTES), html_content=_build_verification_email(
code, "注册", REGISTER_CODE_EXPIRE_MINUTES
),
) )
except TooManyCodeRequestsError as e:
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=str(e))
except Exception as e: except Exception as e:
_clear_code_in_redis(email, VerificationPurpose.REGISTER)
# also clear cooldown if possible, so user can retry immediately
client = _get_redis_for_codes()
if client:
try:
client.delete(_redis_cooldown_key(email, VerificationPurpose.REGISTER))
except Exception:
pass
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=500,
detail=f"Failed to send verification code: {e}", detail=f"Failed to send verification code: {e}",
) )
if not email_sent:
_clear_code_in_redis(email, VerificationPurpose.REGISTER)
client = _get_redis_for_codes()
if client:
try:
client.delete(_redis_cooldown_key(email, VerificationPurpose.REGISTER))
except Exception:
pass
if code_record is not None:
code_record.is_used = True
db.add(code_record)
db.commit()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to send verification code",
)
return MessageResponse(message="Verification code sent") return MessageResponse(message="Verification code sent")
@router.post("/login/send-code", response_model=MessageResponse) @router.post("/login/send-code", response_model=MessageResponse)
async def send_login_code(payload: LoginCodeSendRequest, db: Session = Depends(get_db)): async def send_login_code(
"""发送登录验证码:仅对已注册用户发送""" payload: LoginCodeSendRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
email = _normalize_email(payload.email) email = _normalize_email(payload.email)
user = db.query(AppUser).filter(AppUser.email == email).first() user = db.query(AppUser).filter(AppUser.email == email).first()
if not user: if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Email is not registered") raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
_enforce_code_send_cooldown(db, email, VerificationPurpose.LOGIN) detail="Email is not registered",
code_record = None
if _is_redis_only():
try:
_require_redis_for_codes()
code = generate_verification_code()
code_hash = hash_verification_code(code)
except HTTPException:
# If redis is down, temporarily fallback to DB even in redis_only mode
_invalidate_unused_codes(db, email, VerificationPurpose.LOGIN)
code_record, code = _create_code_record(
db,
email=email,
purpose=VerificationPurpose.LOGIN,
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
)
code_hash = code_record.code_hash
else:
_invalidate_unused_codes(db, email, VerificationPurpose.LOGIN)
code_record, code = _create_code_record(
db,
email=email,
purpose=VerificationPurpose.LOGIN,
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
) )
code_hash = code_record.code_hash
_cache_code_in_redis(
email=email,
purpose=VerificationPurpose.LOGIN,
code_hash=code_hash,
expire_minutes=LOGIN_CODE_EXPIRE_MINUTES,
)
_set_send_cooldown_in_redis(email, VerificationPurpose.LOGIN)
try: try:
email_sent = await send_html_email( code = service.send_code(email, VerificationPurpose.LOGIN)
await send_html_email(
to_email=email, to_email=email,
subject=f"{code}InsightRadar 登录验证码", subject=f"{code}聚势智见 登录验证码",
html_content=_build_verification_email(code, "登录", LOGIN_CODE_EXPIRE_MINUTES), html_content=_build_verification_email(
code, "登录", LOGIN_CODE_EXPIRE_MINUTES
),
) )
except TooManyCodeRequestsError as e:
raise HTTPException(status_code=429, detail=str(e))
except Exception as e: except Exception as e:
_clear_code_in_redis(email, VerificationPurpose.LOGIN)
client = _get_redis_for_codes()
if client:
try:
client.delete(_redis_cooldown_key(email, VerificationPurpose.LOGIN))
except Exception:
pass
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=500,
detail=f"Failed to send verification code: {e}", detail=f"Failed to send verification code: {e}",
) )
if not email_sent:
_clear_code_in_redis(email, VerificationPurpose.LOGIN)
client = _get_redis_for_codes()
if client:
try:
client.delete(_redis_cooldown_key(email, VerificationPurpose.LOGIN))
except Exception:
pass
if code_record is not None:
code_record.is_used = True
db.add(code_record)
db.commit()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to send verification code",
)
return MessageResponse(message="Verification code sent") return MessageResponse(message="Verification code sent")
@router.post( @router.post(
"/register", "/register",
response_model=AuthTokenResponse, response_model=AuthTokenResponse,
status_code=status.HTTP_201_CREATED, status_code=status.HTTP_201_CREATED,
) )
async def register(payload: RegisterRequest, db: Session = Depends(get_db)): async def register(
"""用户注册:校验验证码(Redis 优先,失败则回退数据库)后创建用户""" payload: RegisterRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
email = _normalize_email(payload.email) email = _normalize_email(payload.email)
existing_user = db.query(AppUser).filter(AppUser.email == email).first() existing_user = db.query(AppUser).filter(AppUser.email == email).first()
if existing_user: if existing_user:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Email is already registered") raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email is already registered",
)
redis_result = _verify_code_with_redis( try:
email, service.verify_code(
VerificationPurpose.REGISTER, email=email,
payload.verification_code, purpose=VerificationPurpose.REGISTER,
strict=False, # Never be strict so we can fallback to DB if redis is down code=payload.verification_code,
) )
code_record = None except CodeExpiredError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code expired")
if redis_result is False: except CodeInvalidError:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
except TooManyCodeRequestsError:
if redis_result is None: raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many attempts")
# 即使在 _is_redis_only() 模式下,也去数据库兜底查找
# 这样如果Redis挂了时代码回退到了DB,验证时也能从DB拿出来。
code_record = _get_latest_valid_code_record(
db,
email=email,
purpose=VerificationPurpose.REGISTER,
)
if not code_record:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired")
if not verify_verification_code(payload.verification_code, code_record.code_hash):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid verification code")
else:
# Redis 成功时尽量同步消费 DB 里的最新验证码,保持一致性。
# 即使在 _is_redis_only(),如果先前发生了降级,这里也顺手清理掉。
code_record = _get_latest_valid_code_record(
db,
email=email,
purpose=VerificationPurpose.REGISTER,
)
now = utcnow() now = utcnow()
nickname = payload.nickname or email.split("@")[0] nickname = payload.nickname or email.split("@")[0]
user = AppUser( user = AppUser(
email=email, email=email,
password_hash=hash_password(payload.password), password_hash=hash_password(payload.password),
@@ -569,16 +298,11 @@ async def register(payload: RegisterRequest, db: Session = Depends(get_db)):
) )
db.add(user) db.add(user)
if code_record is not None:
code_record.is_used = True
db.add(code_record)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
return _build_auth_response(user) return _build_auth_response(user)
@router.post("/login", response_model=AuthTokenResponse) @router.post("/login", response_model=AuthTokenResponse)
async def login(payload: LoginRequest, db: Session = Depends(get_db)): async def login(payload: LoginRequest, db: Session = Depends(get_db)):
"""密码登录""" """密码登录"""
@@ -595,49 +319,40 @@ async def login(payload: LoginRequest, db: Session = Depends(get_db)):
@router.post("/login/code", response_model=AuthTokenResponse) @router.post("/login/code", response_model=AuthTokenResponse)
async def login_with_code(payload: LoginWithCodeRequest, db: Session = Depends(get_db)): async def login_with_code(
"""验证码登录:Redis 校验优先,失败则从数据库兜底""" payload: LoginWithCodeRequest,
db: Session = Depends(get_db),
service: EmailVerificationService = Depends(get_verification_service),
):
email = _normalize_email(payload.email) email = _normalize_email(payload.email)
user = db.query(AppUser).filter(AppUser.email == email).first() user = db.query(AppUser).filter(AppUser.email == email).first()
if not user: if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code") raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
redis_result = _verify_code_with_redis( detail="Invalid email or verification code",
email,
VerificationPurpose.LOGIN,
payload.verification_code,
strict=False, # Never be strict so we can fallback to DB if redis is down
)
code_record = None
if redis_result is False:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
if redis_result is None:
code_record = _get_latest_valid_code_record(
db,
email=email,
purpose=VerificationPurpose.LOGIN,
)
if not code_record:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Verification code does not exist or expired")
if not verify_verification_code(payload.verification_code, code_record.code_hash):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or verification code")
else:
# Redis 成功时尽量同步消费 DB 里的最新验证码,保持一致性。
# 即使在 _is_redis_only(),如果先前发生了降级,这里也顺手清理掉。
code_record = _get_latest_valid_code_record(
db,
email=email,
purpose=VerificationPurpose.LOGIN,
) )
if code_record is not None: try:
code_record.is_used = True service.verify_code(
db.add(code_record) email=email,
purpose=VerificationPurpose.LOGIN,
db.commit() code=payload.verification_code,
)
except CodeExpiredError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or verification code",
)
except CodeInvalidError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid email or verification code",
)
except TooManyCodeRequestsError:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many attempts",
)
return _build_auth_response(user) return _build_auth_response(user)
-2
View File
@@ -1,5 +1,3 @@
# 推送设置 API:管理用户的推送时间表和推送渠道
# 关键约束:同一用户两条推送时间间隔至少 30 分钟
from datetime import time as dt_time from datetime import time as dt_time
from typing import List from typing import List
-6
View File
@@ -1,7 +1,3 @@
# app/api/endpoints/events.py
"""
事件模块:统一事件列表、详情、搜索时间线(支持精确/语义/混合匹配)
"""
import json import json
import os import os
import time import time
@@ -41,10 +37,8 @@ SEARCH_MAX_HOURS = int(os.getenv("SEARCH_MAX_HOURS", "168"))
router = APIRouter() router = APIRouter()
# 排名轨迹最多返回的点数,避免时间跨度过大时响应体过重。
MAX_RANKING_POINTS = 30 MAX_RANKING_POINTS = 30
# 统一事件列表接口的短期缓存。
_UNIFIED_EVENTS_CACHE: Dict[str, Tuple[float, PaginatedUnifiedEventResponse]] = {} _UNIFIED_EVENTS_CACHE: Dict[str, Tuple[float, PaginatedUnifiedEventResponse]] = {}
CACHE_TTL_SECONDS = 60 CACHE_TTL_SECONDS = 60
+2 -11
View File
@@ -1,6 +1,3 @@
"""
用户偏好模块:兴趣关键词的增删查、基于关键词的个性化事件推荐
"""
import time import time
from typing import Any, Dict, List, Tuple from typing import Any, Dict, List, Tuple
@@ -20,7 +17,6 @@ from app.services.matching_service import recommend_events_for_user
router = APIRouter() router = APIRouter()
# --- 轻量级接口缓存配置 ---
_RECOMMEND_CACHE: Dict[str, Tuple[float, Any]] = {} _RECOMMEND_CACHE: Dict[str, Tuple[float, Any]] = {}
CACHE_TTL_SECONDS = 60 CACHE_TTL_SECONDS = 60
@@ -29,7 +25,6 @@ def _invalidate_user_cache(user_id: int):
keys_to_delete = [k for k in _RECOMMEND_CACHE.keys() if k.startswith(f"{user_id}:")] keys_to_delete = [k for k in _RECOMMEND_CACHE.keys() if k.startswith(f"{user_id}:")]
for k in keys_to_delete: for k in keys_to_delete:
_RECOMMEND_CACHE.pop(k, None) _RECOMMEND_CACHE.pop(k, None)
# ---------------------------
def _ensure_self_access(path_user_id: int, current_user: AppUser) -> None: def _ensure_self_access(path_user_id: int, current_user: AppUser) -> None:
"""校验路径 user_id 是否为当前登录用户本人。""" """校验路径 user_id 是否为当前登录用户本人。"""
@@ -93,7 +88,7 @@ def create_user_preference(
) )
db.refresh(db_obj) db.refresh(db_obj)
_invalidate_user_cache(user_id) # 失效推荐缓存 _invalidate_user_cache(user_id)
return db_obj return db_obj
@@ -122,7 +117,7 @@ def delete_user_preference(
db.delete(preference) db.delete(preference)
db.commit() db.commit()
_invalidate_user_cache(user_id) # 失效推荐缓存 _invalidate_user_cache(user_id)
return None return None
@@ -143,7 +138,6 @@ def recommend_events(
"""基于用户兴趣词推荐事件(精确匹配 + 语义匹配)。""" """基于用户兴趣词推荐事件(精确匹配 + 语义匹配)。"""
_ensure_self_access(user_id, current_user) _ensure_self_access(user_id, current_user)
# 推荐结果缓存,避免频繁调用匹配服务
cache_key = f"{user_id}:{min_hot}:{hours}:{limit}:{semantic_threshold}:{sort_by}" cache_key = f"{user_id}:{min_hot}:{hours}:{limit}:{semantic_threshold}:{sort_by}"
current_time = time.time() current_time = time.time()
@@ -151,7 +145,6 @@ def recommend_events(
expire_time, cached_data = _RECOMMEND_CACHE[cache_key] expire_time, cached_data = _RECOMMEND_CACHE[cache_key]
if current_time < expire_time: if current_time < expire_time:
return cached_data return cached_data
# -----------------------
matched = recommend_events_for_user( matched = recommend_events_for_user(
db, db,
@@ -189,10 +182,8 @@ def recommend_events(
# 写入缓存,超过 2000 条时清空防止内存膨胀 # 写入缓存,超过 2000 条时清空防止内存膨胀
if len(_RECOMMEND_CACHE) > 2000: if len(_RECOMMEND_CACHE) > 2000:
# 防止内存无限增长
_RECOMMEND_CACHE.clear() _RECOMMEND_CACHE.clear()
_RECOMMEND_CACHE[cache_key] = (current_time + CACHE_TTL_SECONDS, response) _RECOMMEND_CACHE[cache_key] = (current_time + CACHE_TTL_SECONDS, response)
# ------------------
return response return response
-2
View File
@@ -1,4 +1,3 @@
# 公关修改追踪 API:查询热搜标题被偷偷修改的历史记录,用于舆情监测
from datetime import timedelta from datetime import timedelta
from typing import List, Optional from typing import List, Optional
@@ -39,7 +38,6 @@ def list_headline_revisions(
""" """
time_limit = utcnow() - timedelta(hours=hours) time_limit = utcnow() - timedelta(hours=hours)
# 关联 TrendingEvent、InfoSource 获取平台名和链接
rows = ( rows = (
db.query(HeadlineRevision, InfoSource.source_name, TrendingEvent.event_url) db.query(HeadlineRevision, InfoSource.source_name, TrendingEvent.event_url)
.join(TrendingEvent, HeadlineRevision.event_id == TrendingEvent.id) .join(TrendingEvent, HeadlineRevision.event_id == TrendingEvent.id)
-6
View File
@@ -1,7 +1,3 @@
# app/api/endpoints/sources.py
"""
信息源模块:信息源的增删改查,供爬虫与后台管理使用
"""
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List from typing import List
@@ -43,6 +39,4 @@ async def update_info_source(source_id: int, source_in: InfoSourceUpdate, db: Se
source = crud_source.get(db=db, source_id=source_id) source = crud_source.get(db=db, source_id=source_id)
if not source: if not source:
raise HTTPException(status_code=404, detail="该信息源不存在") raise HTTPException(status_code=404, detail="该信息源不存在")
# 直接把查出来的数据库对象和前端传来的 Pydantic 对象丢给 CRUD 处理
return crud_source.update(db=db, db_obj=source, obj_in=source_in) return crud_source.update(db=db, db_obj=source, obj_in=source_in)
-4
View File
@@ -1,4 +1,3 @@
# 系统状态监控 API:返回爬虫集群运行概况(信息源数、今日抓取量、最近同步时间等)
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional from typing import Optional
@@ -28,7 +27,6 @@ def get_system_stats(db: Session = Depends(get_db)):
"""获取爬虫集群的当日运行状态。""" """获取爬虫集群的当日运行状态。"""
today_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0) today_start = utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
# 信息源统计:总数与启用数
total_sources = db.query(func.count(InfoSource.id)).scalar() or 0 total_sources = db.query(func.count(InfoSource.id)).scalar() or 0
active_sources = ( active_sources = (
db.query(func.count(InfoSource.id)) db.query(func.count(InfoSource.id))
@@ -36,7 +34,6 @@ def get_system_stats(db: Session = Depends(get_db)):
.scalar() or 0 .scalar() or 0
) )
# 今日任务统计:抓取条数、成功/失败任务数
today_tasks = ( today_tasks = (
db.query(DataSyncTask) db.query(DataSyncTask)
.filter(DataSyncTask.created_at >= today_start) .filter(DataSyncTask.created_at >= today_start)
@@ -47,7 +44,6 @@ def get_system_stats(db: Session = Depends(get_db)):
success_count = sum(1 for t in today_tasks if t.task_status == TaskStatus.SUCCESS) success_count = sum(1 for t in today_tasks if t.task_status == TaskStatus.SUCCESS)
error_count = sum(1 for t in today_tasks if t.task_status == TaskStatus.ERROR) error_count = sum(1 for t in today_tasks if t.task_status == TaskStatus.ERROR)
# 最后一次同步时间
last_task = ( last_task = (
db.query(DataSyncTask) db.query(DataSyncTask)
.filter(DataSyncTask.task_status == TaskStatus.SUCCESS) .filter(DataSyncTask.task_status == TaskStatus.SUCCESS)
-1
View File
@@ -1,4 +1,3 @@
# app/api/router.py
from fastapi import APIRouter from fastapi import APIRouter
from app.api.endpoints import auth, delivery, events, preferences, revisions, sources, stats from app.api.endpoints import auth, delivery, events, preferences, revisions, sources, stats
@@ -0,0 +1,78 @@
from functools import lru_cache
import time
import json
import threading
from typing import Optional
from app.models.models import VerificationPurpose
from app.core.verification.email.verificationRepository import VerificationRepository
class MemoryRepository(VerificationRepository):
def __init__(self):
self._store = {}
self._lock = threading.Lock()
def _key(self, email: str, purpose: VerificationPurpose) -> str:
email = email.lower()
return f"verification:code:{purpose.value.lower()}:{email}"
def set_code(self, email: str, purpose: VerificationPurpose, code_hash: str, ttl: int) -> None:
key = self._key(email, purpose)
expire_at = time.time() + ttl
payload = {
"code_hash": code_hash
}
with self._lock:
self._store[key] = (json.dumps(payload), expire_at)
def compare_and_consume(
self,
email: str,
purpose: VerificationPurpose,
code_hash: str,
) -> Optional[bool]:
key = self._key(email, purpose)
with self._lock:
data = self._store.get(key)
if not data:
return None
value, expire_at = data
if time.time() > expire_at:
del self._store[key]
return None
try:
payload = json.loads(value)
stored_hash = payload.get("code_hash")
except Exception:
return None
if stored_hash == code_hash:
del self._store[key]
return True
else:
return False
def incr(self, key: str, ttl: int) -> int:
now = time.time()
with self._lock:
value, expire = self._store.get(key, (0, 0))
if now > expire:
value = 0
value += 1
self._store[key] = (value, now + ttl)
return value
@lru_cache
def get_memory_repo():
return MemoryRepository()
@@ -0,0 +1,95 @@
from functools import lru_cache
import os
import logging
import json
import redis
from typing import Optional
from app.models.models import VerificationPurpose
from app.core.verification.email.verificationRepository import VerificationRepository
from app.utils.redis_client import get_redis_client
logger = logging.getLogger(__name__)
AUTH_CODE_REDIS_PREFIX = os.getenv(
"AUTH_CODE_REDIS_PREFIX", "insightradar:auth_code"
).strip()
class RedisRepository(VerificationRepository):
_compare_and_consume_lua = """
local val = redis.call("GET", KEYS[1])
if not val then
return nil
end
local data = cjson.decode(val)
if data["code_hash"] == ARGV[1] then
redis.call("DEL", KEYS[1])
return 1
else
return 0
end
"""
def __init__(self, client: redis.Redis):
self.client = client
self._compare_script = self.client.register_script(
self._compare_and_consume_lua
)
def _key(self, email: str, purpose: VerificationPurpose) -> str:
return f"{AUTH_CODE_REDIS_PREFIX}:{purpose.value.lower()}:{email}:code"
def set_code(
self,
email: str,
purpose: VerificationPurpose,
code_hash: str,
ttl: int,
) -> None:
key = self._key(email, purpose)
payload = json.dumps({
"code_hash": code_hash,
})
self.client.set(key, payload, ex=ttl)
def compare_and_consume(
self,
email: str,
purpose: VerificationPurpose,
code_hash: str,
) -> Optional[bool]:
key = self._key(email, purpose)
result = self._compare_script(
keys=[key],
args=[code_hash],
)
if result is None:
return None
if result == 1:
return True
return False
def incr(self, key: str, ttl: int) -> int:
value = self.client.incr(key)
if value == 1:
self.client.expire(key, ttl)
return int(value) # type: ignore
@lru_cache
def get_redis_repo():
client = get_redis_client()
if client is None:
return None
return RedisRepository(client)
@@ -0,0 +1,62 @@
from functools import lru_cache
import logging
from typing import Optional
from app.models.models import VerificationPurpose
from app.core.verification.email.verificationRepository import VerificationRepository
from app.core.verification.email.RespositoryImpl.MemoryRepository import get_memory_repo
from app.core.verification.email.RespositoryImpl.RedisRepository import get_redis_repo
logger = logging.getLogger(__name__)
class HybridRepository(VerificationRepository):
def __init__(self, redis_repo: Optional[VerificationRepository], memory_repo: VerificationRepository):
self.redis = redis_repo
self.memory = memory_repo
def set_code(self, email: str, purpose: VerificationPurpose, code_hash: str, ttl: int) -> None:
if self.redis:
try:
self.redis.set_code(email, purpose, code_hash, ttl)
return
except Exception as e:
logger.warning("Redis set_code failed, fallback to memory: %s", e)
self.memory.set_code(email, purpose, code_hash, ttl)
def compare_and_consume(
self,
email: str,
purpose: VerificationPurpose,
code_hash: str,
) -> Optional[bool]:
if self.redis:
try:
return self.redis.compare_and_consume(
email, purpose, code_hash
)
except Exception as e:
logger.warning(
"Redis compare_and_consume failed, fallback to memory: %s", e
)
return self.memory.compare_and_consume(email, purpose, code_hash)
def incr(self, key: str, ttl: int) -> int:
if self.redis:
try:
return self.redis.incr(key, ttl)
except Exception as e:
logger.warning("Redis incr failed, fallback to memory: %s", e)
return self.memory.incr(key, ttl)
@lru_cache
def get_verification_repository():
redis_repo = get_redis_repo()
memory_repo = get_memory_repo()
return HybridRepository(redis_repo, memory_repo)
@@ -0,0 +1,49 @@
from abc import abstractmethod, ABC
from typing import Optional
from app.models.models import VerificationPurpose
class VerificationRepository(ABC):
"""验证码持久层抽象基类"""
@abstractmethod
def set_code(self, email: str, purpose: VerificationPurpose, code_hash: str, ttl: int) -> None:
"""write the code into the storage
Args:
email (str): email of user
purpose (VerificationPurpose): the purpose of the code, such as, "login", "register"
code_hash (str): hash of the code
ttl (int): duration
"""
pass
@abstractmethod
def compare_and_consume(
self,
email: str,
purpose: VerificationPurpose,
code_hash: str,
) -> Optional[bool]:
"""consume the code atomically
Args:
email (str): email of user
purpose (VerificationPurpose): the purpose of the code, such as, "login", "register"
Returns:
Optional[str]: if success return the true
"""
pass
@abstractmethod
def incr(self, key: str, ttl: int) -> int:
"""create a counter in the storage, it will be delete after ttl
Args:
key (str): key of the counter
ttl (int): duration
"""
pass
@@ -0,0 +1,89 @@
from functools import lru_cache
import os
import logging
from typing import Optional
from app.models.models import VerificationPurpose
from app.core.verification.email.verificationRepository import VerificationRepository
from app.core.security import generate_verification_code, hash_verification_code
from app.core.verification.email.RespositoryImpl.hybirdRepository import get_verification_repository
logger = logging.getLogger(__name__)
# 注册验证码有效期(分钟)
REGISTER_CODE_EXPIRE_MINUTES = os.getenv("REGISTER_CODE_EXPIRE_MINUTES", 5)
# 登录验证码有效期(分钟)
LOGIN_CODE_EXPIRE_MINUTES = os.getenv("LOGIN_CODE_EXPIRE_MINUTES",5)
# 同一邮箱发送验证码的冷却间隔(秒)
CODE_SEND_COOLDOWN_SECONDS = os.getenv("CODE_SEND_COOLDOWN_SECONDS",60)
CODE_VERIFICATE_ATTEMP_SECONDS = os.getenv("CODE_VERIFICATE_ATTEMP_SECONDS", 60)
CODE_VERIFICATE_ATTEMP_COUNT = os.getenv("CODE_VERIFICATE_ATTEMP_COUNT", 10)
class CodeExpiredError(Exception):
"""code has been expired"""
pass
class CodeInvalidError(Exception):
"""code is not right"""
pass
class TooManyCodeRequestsError(Exception):
"""User request too many times when they verificate the email"""
pass
def get_ttl(purpose: VerificationPurpose)->int:
if purpose == VerificationPurpose.LOGIN:
return int(LOGIN_CODE_EXPIRE_MINUTES) * 60
else:
return int(REGISTER_CODE_EXPIRE_MINUTES) * 60
class EmailVerificationService:
def __init__(self, repo: VerificationRepository) -> None:
self.repo = repo
def _cooldown_key(self, email: str, purpose: VerificationPurpose) -> str:
return f"verification:cooldown:{purpose.value}:{email.lower()}"
def send_code(self, email: str, purpose: VerificationPurpose) -> str:
email = email.lower()
count = self.repo.incr(self._cooldown_key(email, purpose), int(CODE_SEND_COOLDOWN_SECONDS))
if count > 1:
raise TooManyCodeRequestsError("Please wait before requesting another code")
code = generate_verification_code()
code_hash = hash_verification_code(code)
self.repo.set_code(email, purpose, code_hash, get_ttl(purpose))
return code
def verify_code(self,email: str, code: str, purpose: VerificationPurpose):
email = email.lower()
key = f"verification:attempts:{purpose.value.lower()}:{email}"
code_hash = hash_verification_code(code)
attempts = self.repo.incr(key, int(CODE_VERIFICATE_ATTEMP_SECONDS))
if attempts > int(CODE_VERIFICATE_ATTEMP_COUNT):
raise TooManyCodeRequestsError("Too many attempts")
stored = self.repo.compare_and_consume(email, purpose, code_hash)
if stored == False:
raise CodeInvalidError("Invalid code")
if not stored:
raise CodeExpiredError("Code expired or not found")
return True
@lru_cache
def get_verification_service():
repo = get_verification_repository()
return EmailVerificationService(repo)
+16 -8
View File
@@ -1,7 +1,5 @@
# app/crud/crud_source.py from sqlite3 import IntegrityError
"""
信息源 CRUD:对 InfoSource 的增删改查,供 API 与爬虫使用
"""
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List, Optional from typing import List, Optional
@@ -22,10 +20,20 @@ def get_multi(db: Session, skip: int = 0, limit: int = 100) -> List[InfoSource]:
def create(db: Session, obj_in: InfoSourceCreate) -> InfoSource: def create(db: Session, obj_in: InfoSourceCreate) -> InfoSource:
"""创建新的信息源""" """创建新的信息源"""
db_obj = InfoSource(**obj_in.model_dump()) db_obj = InfoSource(**obj_in.model_dump())
db.add(db_obj) exits =db.query(InfoSource).filter(InfoSource.source_name == db_obj.source_name).first()
db.commit() if exits:
db.refresh(db_obj) db.close()
return db_obj return db_obj
try:
db.add(db_obj)
db.commit()
db.refresh(db_obj)
return db_obj
except IntegrityError:
db.rollback()
finally:
db.close()
return db_obj
def update(db: Session, db_obj: InfoSource, obj_in: InfoSourceUpdate) -> InfoSource: def update(db: Session, db_obj: InfoSource, obj_in: InfoSourceUpdate) -> InfoSource:
+1 -1
View File
@@ -1,4 +1,4 @@
# database.py # AI辅助生成:deepseek-v3-22026年3月20日
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
+31 -40
View File
@@ -1,46 +1,37 @@
import requests from app.database import SessionLocal
import json from app.crud.crud_source import create
from app.models.models import SourceType
from app.schemas.source_schema import InfoSourceCreate
# 请将此处的 URL 替换为您实际的 API 基础域名 # AI辅助生成:deepseek-v3-22026年3月20日
api_url = "http://10.252.130.135:8000/api/v1/sources/"
# 请求头 def init():
headers = {
"Content-Type": "application/json",
# "Authorization": "Bearer YOUR_TOKEN" # 如果接口需要鉴权,请取消注释并填入 Token
}
# 解析后的数据源列表 sources_data = [
sources_data = [ {"name": "今日头条", "url": "toutiao"},
{"name": "今日头条", "url": "toutiao"}, {"name": "百度热搜", "url": "baidu"},
{"name": "百度热搜", "url": "baidu"}, {"name": "华尔街见闻", "url": "wallstreetcn-hot"},
{"name": "华尔街见", "url": "wallstreetcn-hot"}, {"name": "澎湃新", "url": "thepaper"},
{"name": "澎湃新闻", "url": "thepaper"}, {"name": "bilibili 热搜", "url": "bilibili-hot-search"},
{"name": "bilibili 热搜", "url": "bilibili-hot-search"}, {"name": "财联社热门", "url": "cls-hot"},
{"name": "财联社热门", "url": "cls-hot"}, {"name": "凤凰网", "url": "ifeng"},
{"name": "凤凰网", "url": "ifeng"}, {"name": "贴吧", "url": "tieba"},
{"name": "贴吧", "url": "tieba"}, {"name": "微博", "url": "weibo"},
{"name": "微博", "url": "weibo"}, {"name": "抖音", "url": "douyin"},
{"name": "抖音", "url": "douyin"}, {"name": "知乎", "url": "zhihu"}
{"name": "知乎", "url": "zhihu"} ]
]
# 遍历数据并发送 POST 请求 for item in sources_data:
for item in sources_data: try:
payload = { with SessionLocal() as db:
"source_name": item["name"],
"source_type": "HOT_TREND",
"home_url": item["url"],
"is_enabled": True
}
try: create(db, InfoSourceCreate(
response = requests.post(api_url, headers=headers, data=json.dumps(payload)) source_name=item["name"],
if response.status_code in (200, 201): source_type=SourceType.HOT_TREND,
print(f"✅ 成功创建: {item['name']}") home_url=item["url"],
else: is_enabled=True
print(f"❌ 创建失败: {item['name']} - 状态码: {response.status_code} - 详情: {response.text}") ))
except Exception as e: print(f"创建订阅源{item['name']}")
print(f"⚠️ 请求异常: {item['name']} - 错误: {e}")
print("执行完毕!") except Exception as e:
print(f"⚠️ 请求异常: {item['name']} - 错误: {e}")
+38 -32
View File
@@ -1,14 +1,17 @@
# app/main.py # AI辅助生成:deepseek-v3-22026年3月20日
import logging import logging
import os import os
from pathlib import Path
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
import httpx
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI, HTTPException, Request, staticfiles
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from dotenv import load_dotenv from dotenv import load_dotenv
# 统一配置日志格式和级别,确保 delivery_service 等的 INFO 日志可见 # 统一配置日志格式和级别,确保 delivery_service 等的 INFO 日志可见
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.DEBUG,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S", datefmt="%Y-%m-%d %H:%M:%S",
) )
@@ -21,28 +24,29 @@ from app.services.summary_service import generate_unified_summaries
from app.services.delivery_service import check_and_deliver from app.services.delivery_service import check_and_deliver
from app.database import engine from app.database import engine
from app.models.models import Base from app.models.models import Base
from app.initialize import init
# 路由总线 # 路由总线
from app.api.router import api_router from app.api.router import api_router
load_dotenv()
CRAWL_INTERVAL = int(os.getenv("CRAWL_INTERVAL_MINUTES", 10)) CRAWL_INTERVAL = int(os.getenv("CRAWL_INTERVAL_MINUTES", 10))
SUMMARY_INTERVAL = int(os.getenv("SUMMARY_INTERVAL_MINUTES", 30)) SUMMARY_INTERVAL = int(os.getenv("SUMMARY_INTERVAL_MINUTES", 30))
scheduler = AsyncIOScheduler() scheduler = AsyncIOScheduler()
# ==========================================
# 1. 生命周期管理:App 启动时自动建表 & 启动调度器
# ==========================================
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# 1. 数据库建表 # 1. 数据库建表
print("正在初始化数据库表...") logging.info("正在初始化数据库表...")
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
print("数据库表初始化完成!") logging.info("数据库表初始化完成!")
# 2. 配置并启动定时任务 logging.info("初始化订阅源")
init()
logging.info("订阅源初始化完毕")
# 爬取订阅源
scheduler.add_job( scheduler.add_job(
fetch_and_save_trending_data, fetch_and_save_trending_data,
'interval', 'interval',
@@ -59,7 +63,7 @@ async def lifespan(app: FastAPI):
id='ai_summary_job', id='ai_summary_job',
replace_existing=True replace_existing=True
) )
# 推送调度:每分钟检查是否有用户需要接收邮件推送 # 推送调度
scheduler.add_job( scheduler.add_job(
check_and_deliver, check_and_deliver,
'interval', 'interval',
@@ -69,28 +73,18 @@ async def lifespan(app: FastAPI):
) )
scheduler.start() scheduler.start()
print(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次") logging.info(f"定时抓取任务已启动,每 {CRAWL_INTERVAL} 分钟执行一次")
print(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次") logging.info(f"AI 摘要生成任务已启动,每 {SUMMARY_INTERVAL} 分钟执行一次")
print("邮件推送调度已启动,每分钟检查一次") logging.info("邮件推送调度已启动,每分钟检查一次")
# 为了测试方便,启动时立即执行一次 yield
# await fetch_and_save_trending_data()
# await generate_unified_summaries()
yield # 此时 FastAPI 开始接受请求
# 优雅关闭
scheduler.shutdown() scheduler.shutdown()
print("定时任务已安全关闭") logging.info("定时任务已安全关闭")
# 初始化 FastAPI
app = FastAPI(title="AI 新闻聚合引擎 API", lifespan=lifespan) app = FastAPI(title="AI 新闻聚合引擎 API", lifespan=lifespan)
# ==========================================
# 2. CORS 中间件:允许前端开发服务器跨域请求
# ==========================================
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
# allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], # allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
@@ -100,14 +94,26 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# ==========================================
# 3. 挂载路由总线
# ==========================================
# 版本控制
app.include_router(api_router, prefix="/api/v1") app.include_router(api_router, prefix="/api/v1")
# AI辅助生成结束
@app.get("/api/{full_path:path}")
async def api_not_found(full_path: str):
return {"detail": "API Not Found"}
staticPath = staticfiles.StaticFiles(directory="app/static", html=True)
app.mount("/", staticPath, name="static")
INDEX_HTML = Path("app/static/index.html").read_text(encoding="utf-8")
@app.exception_handler(404)
async def not_found_handler(request: Request, exc: HTTPException):
if request.url.path.startswith("/api/"):
return JSONResponse({"detail": "Not Found"}, status_code=404)
return HTMLResponse(INDEX_HTML)
# 健康检查
@app.get("/", tags=["健康检查"]) @app.get("/", tags=["健康检查"])
async def root(): async def root():
return {"message": "Welcome to AI News Aggregator API", "status": "ok"} return {"message": "Welcome to AI News Aggregator API", "status": "ok"}
+10 -79
View File
@@ -1,4 +1,3 @@
# models.py
from datetime import datetime, timezone, time from datetime import datetime, timezone, time
from typing import Optional, Any from typing import Optional, Any
import enum import enum
@@ -9,11 +8,6 @@ from sqlalchemy import (
) )
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
# ==========================================
# 0. 全局基类、枚举定义与动态类型
# ==========================================
class Base(DeclarativeBase): class Base(DeclarativeBase):
""" """
SQLAlchemy 2.0 声明式基类 SQLAlchemy 2.0 声明式基类
@@ -21,9 +15,6 @@ class Base(DeclarativeBase):
""" """
pass pass
# 让代码在 SQLite 环境下自动降级为 Integer 以保证自增正常工作,
# 而在生产环境部署到 PostgreSQL 或 MySQL 时,依然会使用容量更大的 BigInteger。
BigIntType = BigInteger().with_variant(Integer, "sqlite") BigIntType = BigInteger().with_variant(Integer, "sqlite")
@@ -70,10 +61,6 @@ def utcnow():
""" """
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
# ==========================================
# 模块一:信息源管理
# ==========================================
class InfoSource(Base): class InfoSource(Base):
""" """
抓取源配置表 抓取源配置表
@@ -93,11 +80,11 @@ class InfoSource(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
__table_args__ = (
UniqueConstraint("source_name", name="uix_source_name"),
)
# ==========================================
# 模块二:AI 语义聚类中枢 (大事件池)
# ==========================================
class UnifiedEvent(Base): class UnifiedEvent(Base):
""" """
AI 统一事件表 (核心大脑) AI 统一事件表 (核心大脑)
@@ -120,10 +107,6 @@ class UnifiedEvent(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
# ==========================================
# 模块三:内容存储库 (热搜 & 新闻子节点)
# ==========================================
class TrendingEvent(Base): class TrendingEvent(Base):
""" """
各平台热搜数据明细表 各平台热搜数据明细表
@@ -176,8 +159,7 @@ class NewsArticle(Base):
id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(BigIntType, primary_key=True, autoincrement=True)
source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="所属信息源ID") source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="所属信息源ID")
unified_event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("unified_events.id"), unified_event_id: Mapped[Optional[int]] = mapped_column(ForeignKey("unified_events.id"), comment="深度文章也可归入大事件分析")
comment="深度文章也可归入大事件分析")
external_id: Mapped[str] = mapped_column(String(32), comment="RSS原文<guid>生成的MD5防重指纹") external_id: Mapped[str] = mapped_column(String(32), comment="RSS原文<guid>生成的MD5防重指纹")
title_embedding: Mapped[Optional[str]] = mapped_column(Text, comment="新闻标题/摘要的语义向量") title_embedding: Mapped[Optional[str]] = mapped_column(Text, comment="新闻标题/摘要的语义向量")
@@ -196,10 +178,6 @@ class NewsArticle(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
# ==========================================
# 模块四:热度与轨迹追踪
# ==========================================
class HeadlineRevision(Base): class HeadlineRevision(Base):
""" """
标题修订历史表 标题修订历史表
@@ -214,8 +192,7 @@ class HeadlineRevision(Base):
previous_headline: Mapped[str] = mapped_column(String(255), comment="修改前的旧标题") previous_headline: Mapped[str] = mapped_column(String(255), comment="修改前的旧标题")
revised_headline: Mapped[str] = mapped_column(String(255), comment="修改后的新标题") revised_headline: Mapped[str] = mapped_column(String(255), comment="修改后的新标题")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="系统发现被修改的时间")
comment="系统发现被修改的时间")
class RankingLog(Base): class RankingLog(Base):
@@ -235,15 +212,10 @@ class RankingLog(Base):
# 当时它在第几名 # 当时它在第几名
ranking_position: Mapped[int] = mapped_column(Integer, comment="当时抓取时的排名名次") ranking_position: Mapped[int] = mapped_column(Integer, comment="当时抓取时的排名名次")
# 爬虫看到它的那一瞬间的时间 # 爬虫看到它的那一瞬间的时间
observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, observed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="观察到该名次的准确时间")
comment="观察到该名次的准确时间")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# ==========================================
# 模块五:多态话题与多态评论
# ==========================================
class ExtractedTopic(Base): class ExtractedTopic(Base):
""" """
AI 提取的核心话题标签表 AI 提取的核心话题标签表
@@ -290,10 +262,6 @@ class DiscussionComment(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
# ==========================================
# 模块六:用户画像与多渠道高可用推送系统
# ==========================================
class AppUser(Base): class AppUser(Base):
""" """
系统核心用户表 系统核心用户表
@@ -304,39 +272,15 @@ class AppUser(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(150), unique=True, index=True, comment="主账号邮箱") email: Mapped[str] = mapped_column(String(150), unique=True, index=True, comment="主账号邮箱")
password_hash: Mapped[Optional[str]] = mapped_column(String(255), comment="密码哈希(第三方登录可为空)") password_hash: Mapped[Optional[str]] = mapped_column(String(255), comment="密码哈希(第三方登录可为空)")
nickname: Mapped[Optional[str]] = mapped_column(String(100), comment="用户展示昵称") nickname: Mapped[Optional[str]] = mapped_column(String(100), comment="用户展示昵称")
avatar_url: Mapped[Optional[str]] = mapped_column(String(500), comment="用户头像地址") avatar_url: Mapped[Optional[str]] = mapped_column(String(500), comment="用户头像地址")
gender: Mapped[GenderType] = mapped_column(Enum(GenderType), default=GenderType.UNKNOWN, gender: Mapped[GenderType] = mapped_column(Enum(GenderType), default=GenderType.UNKNOWN, comment="用户性别(用于AI调整行文语气)")
comment="用户性别(用于AI调整行文语气)") metadata_: Mapped[Optional[Any]] = mapped_column("metadata", JSON, comment="JSON扩展字段: 存放灵活多变的前端用户偏好设置")
# 极其强大:一个万能收纳箱!前端未来想加任何诸如“夜间模式”、“字体变大”的开关,
# 全部丢进这个 JSON 字段即可,从此免去手动修改后端表结构的麻烦。
metadata_: Mapped[Optional[Any]] = mapped_column("metadata", JSON,
comment="JSON扩展字段: 存放灵活多变的前端用户偏好设置")
# 时区对于定时推送系统极其重要!保证纽约的用户和北京的用户都能在早晨8点收到新闻。
timezone: Mapped[str] = mapped_column(String(50), default="Asia/Shanghai", comment="用户所在地时区") timezone: Mapped[str] = mapped_column(String(50), default="Asia/Shanghai", comment="用户所在地时区")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class EmailVerificationCode(Base):
__tablename__ = "email_verification_codes"
__table_args__ = (
Index("idx_email_code_lookup", "email", "purpose", "is_used", "expires_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
email: Mapped[str] = mapped_column(String(150), index=True, nullable=False)
purpose: Mapped[VerificationPurpose] = mapped_column(Enum(VerificationPurpose), nullable=False)
code_hash: Mapped[str] = mapped_column(String(64), nullable=False)
is_used: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
class UserPushEndpoint(Base): class UserPushEndpoint(Base):
""" """
多渠道推送端点配置表 (高可用解耦设计) 多渠道推送端点配置表 (高可用解耦设计)
@@ -350,14 +294,10 @@ class UserPushEndpoint(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="所属用户ID") user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="所属用户ID")
# 填入大写的纯字符串,如 EMAIL, WECHAT_BOT, TELEGRAM
channel_type: Mapped[str] = mapped_column(String(50), comment="推送渠道类型标识") channel_type: Mapped[str] = mapped_column(String(50), comment="推送渠道类型标识")
# 具体的发送目标地址
channel_account: Mapped[str] = mapped_column(String(255), comment="具体的接收账号(邮箱号/微信号/Webhook)") channel_account: Mapped[str] = mapped_column(String(255), comment="具体的接收账号(邮箱号/微信号/Webhook)")
is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="用户是否临时关闭了该渠道") is_active: Mapped[bool] = mapped_column(Boolean, default=True, comment="用户是否临时关闭了该渠道")
# 高可用容灾:比如 1 代表必须先发微信,如果报错了,再去找 priority=2 的邮箱补发
priority_level: Mapped[int] = mapped_column(Integer, default=1, comment="推送优先级(1最高,用于错误降级重试)") priority_level: Mapped[int] = mapped_column(Integer, default=1, comment="推送优先级(1最高,用于错误降级重试)")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow)
@@ -369,7 +309,6 @@ class UserTopicPreference(Base):
""" """
__tablename__ = "user_topic_preferences" __tablename__ = "user_topic_preferences"
__table_args__ = ( __table_args__ = (
# 联合防抖限制:防止用户在界面卡顿时连点两次,订阅了两个同样的词
UniqueConstraint("user_id", "interested_keyword", name="idx_unique_preference"), UniqueConstraint("user_id", "interested_keyword", name="idx_unique_preference"),
) )
@@ -406,7 +345,6 @@ class DeliveryHistory(Base):
""" """
__tablename__ = "delivery_history" __tablename__ = "delivery_history"
__table_args__ = ( __table_args__ = (
# 终极去重约束:一个用户,针对同一篇新闻,永远只允许存在一条记录
UniqueConstraint("user_id", "target_type", "target_id", name="idx_prevent_duplicate_push"), UniqueConstraint("user_id", "target_type", "target_id", name="idx_prevent_duplicate_push"),
) )
@@ -414,16 +352,10 @@ class DeliveryHistory(Base):
user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="接收推送的用户") user_id: Mapped[int] = mapped_column(ForeignKey("app_users.id"), comment="接收推送的用户")
target_type: Mapped[TargetType] = mapped_column(Enum(TargetType), comment="推送出去的具体内容类型") target_type: Mapped[TargetType] = mapped_column(Enum(TargetType), comment="推送出去的具体内容类型")
target_id: Mapped[int] = mapped_column(BigIntType, comment="推送内容的主键ID") target_id: Mapped[int] = mapped_column(BigIntType, comment="推送内容的主键ID")
# 记录这次推送是彻底成功了,还是由于渠道网络问题失败了
status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="最终推送结果状态") status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="最终推送结果状态")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="记录或实际推送的准确时间")
comment="记录或实际推送的准确时间")
# ==========================================
# 模块七:系统任务监控
# ==========================================
class DataSyncTask(Base): class DataSyncTask(Base):
""" """
数据同步健康度监控表 (运维巡检专用) 数据同步健康度监控表 (运维巡检专用)
@@ -436,7 +368,6 @@ class DataSyncTask(Base):
source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="本次运行爬取的哪个源") source_id: Mapped[int] = mapped_column(ForeignKey("info_sources.id"), comment="本次运行爬取的哪个源")
items_fetched: Mapped[int] = mapped_column(Integer, default=0, comment="本次爬虫成功插入或更新的新闻条数") items_fetched: Mapped[int] = mapped_column(Integer, default=0, comment="本次爬虫成功插入或更新的新闻条数")
task_status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="该平台的宏观抓取状态") task_status: Mapped[TaskStatus] = mapped_column(Enum(TaskStatus), comment="该平台的宏观抓取状态")
# 如果代码意外崩溃、或是遭遇403/502,把 Python的 traceback 堆栈原封不动存进这里
error_trace: Mapped[Optional[str]] = mapped_column(Text, comment="若失败则保存完整报错堆栈") error_trace: Mapped[Optional[str]] = mapped_column(Text, comment="若失败则保存完整报错堆栈")
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="任务执行的发生时间") created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, comment="任务执行的发生时间")
+3 -7
View File
@@ -1,7 +1,3 @@
# 推送邮件 HTML 模板
# 用于生成定时推送给用户的热点摘要邮件
# 邮件客户端不支持 Font Awesome,改用 Emoji 代替平台图标
PLATFORM_EMOJI: dict[str, str] = { PLATFORM_EMOJI: dict[str, str] = {
"微博热搜": "🔴", "微博热搜": "🔴",
"微博": "🔴", "微博": "🔴",
@@ -86,7 +82,7 @@ body{{margin:0;padding:0;background:#0d1117;color:#e6edf3;font-family:-apple-sys
<body> <body>
<div class="container"> <div class="container">
<div class="header"> <div class="header">
<h1>InsightRadar · 热点快报</h1> <h1>聚势智见 · 热点快报</h1>
<p>{delivery_time} · 为你精选了 {event_count} 条事件</p> <p>{delivery_time} · 为你精选了 {event_count} 条事件</p>
<span class="mode-badge {mode_badge_class}">{mode_label}</span> <span class="mode-badge {mode_badge_class}">{mode_label}</span>
</div> </div>
@@ -94,8 +90,8 @@ body{{margin:0;padding:0;background:#0d1117;color:#e6edf3;font-family:-apple-sys
{event_cards_html} {event_cards_html}
<div class="footer"> <div class="footer">
<p>此邮件由 InsightRadar 自动推送。</p> <p>此邮件由 聚势智见自动推送。</p>
<p>如需调整推送设置,请登录 <a href="{app_url}">InsightRadar 控制台</a></p> <p>如需调整推送设置,请登录 <a href="{app_url}">聚势智见 控制台</a></p>
</div> </div>
</div> </div>
</body> </body>
+1 -1
View File
@@ -1,9 +1,9 @@
# 推送设置相关的请求/响应模型
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
# AI辅助生成:deepseek-v3-22026年3月20日
# ========================================== # ==========================================
# 推送时间表 (UserDeliverySchedule) # 推送时间表 (UserDeliverySchedule)
-2
View File
@@ -1,9 +1,7 @@
# app/schemas/event_schema.py
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List, Optional from typing import List, Optional
from datetime import datetime from datetime import datetime
class PlatformTrendResponse(BaseModel): class PlatformTrendResponse(BaseModel):
source_id: int source_id: int
platform_name: str platform_name: str
-1
View File
@@ -3,7 +3,6 @@ from typing import List, Optional
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
class UserTopicPreferenceCreate(BaseModel): class UserTopicPreferenceCreate(BaseModel):
"""新增用户兴趣词请求体。""" """新增用户兴趣词请求体。"""
interested_keyword: str = Field(..., min_length=1, max_length=100, description="用户感兴趣的关键词") interested_keyword: str = Field(..., min_length=1, max_length=100, description="用户感兴趣的关键词")
+1 -1
View File
@@ -1,4 +1,3 @@
# app/schemas/source_schema.py
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from typing import Optional from typing import Optional
from datetime import datetime from datetime import datetime
@@ -6,6 +5,7 @@ from datetime import datetime
# 枚举 # 枚举
from app.models.models import SourceType from app.models.models import SourceType
# AI辅助生成:deepseek-v3-22026年3月20日
# ========================================== # ==========================================
# InfoSource (信息源) 相关的 Schemas # InfoSource (信息源) 相关的 Schemas
+10 -25
View File
@@ -1,7 +1,3 @@
# 定时推送调度服务
# 由 APScheduler 每分钟调用,检查当前时刻是否有用户需要接收推送,
# 如匹配则生成摘要邮件并发送,同时写入 DeliveryHistory 防重复。
# 推送优先级:有关键词且匹配 → 个性化简报;无关键词或无匹配 → 默认热点快报
import logging import logging
import os import os
from logging.handlers import TimedRotatingFileHandler from logging.handlers import TimedRotatingFileHandler
@@ -34,7 +30,7 @@ from app.utils.email_utils import send_html_email
logger = logging.getLogger("delivery_service") logger = logging.getLogger("delivery_service")
# delivery_service 日志单独写文件
_delivery_log_dir = Path(__file__).resolve().parents[2] / "logs" _delivery_log_dir = Path(__file__).resolve().parents[2] / "logs"
_delivery_log_dir.mkdir(parents=True, exist_ok=True) _delivery_log_dir.mkdir(parents=True, exist_ok=True)
_delivery_log_file = _delivery_log_dir / "delivery_check.log" _delivery_log_file = _delivery_log_dir / "delivery_check.log"
@@ -51,6 +47,8 @@ if not logger.handlers:
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
logger.propagate = False logger.propagate = False
# AI辅助生成:deepseek-v3-22026年3月20日
# 推送时间窗口:实际执行时刻与设定时间的最大容差(分钟) # 推送时间窗口:实际执行时刻与设定时间的最大容差(分钟)
DELIVERY_WINDOW_MINUTES = int(os.getenv("DELIVERY_WINDOW_MINUTES", 2)) DELIVERY_WINDOW_MINUTES = int(os.getenv("DELIVERY_WINDOW_MINUTES", 2))
# 同一用户两次推送之间的最小间隔(分钟) # 同一用户两次推送之间的最小间隔(分钟)
@@ -64,13 +62,10 @@ DEFAULT_MODE_HOURS = int(os.getenv("DEFAULT_MODE_HOURS", 24))
# 用户时区无效时的兜底时区 # 用户时区无效时的兜底时区
DEFAULT_FALLBACK_TIMEZONE = os.getenv("DEFAULT_FALLBACK_TIMEZONE", "Asia/Shanghai") DEFAULT_FALLBACK_TIMEZONE = os.getenv("DEFAULT_FALLBACK_TIMEZONE", "Asia/Shanghai")
# ==========================================
# 默认热点事件容器(无关键词时使用)
# ==========================================
@dataclass @dataclass
class _DefaultEventItem: class _DefaultEventItem:
""" """
默认热点事件容器
无关键词订阅或关键词无匹配时的默认热点包装器, 无关键词订阅或关键词无匹配时的默认热点包装器,
接口与 MatchedEventResult 保持一致,方便统一传给模板。 接口与 MatchedEventResult 保持一致,方便统一传给模板。
""" """
@@ -81,10 +76,6 @@ class _DefaultEventItem:
tags: list[str] = field(default_factory=list) tags: list[str] = field(default_factory=list)
is_default: bool = True is_default: bool = True
# ==========================================
# 时区工具
# ==========================================
def _time_to_minutes(t: dt_time) -> int: def _time_to_minutes(t: dt_time) -> int:
return t.hour * 60 + t.minute return t.hour * 60 + t.minute
@@ -125,10 +116,10 @@ def _ensure_aware(dt: datetime) -> datetime:
return dt.replace(tzinfo=timezone.utc) return dt.replace(tzinfo=timezone.utc)
return dt return dt
# AI辅助生成结束
# ==========================================
# 数据库查询辅助 # 数据库查询辅助
# ==========================================
def _should_skip_by_interval(db: Session, user_id: int) -> bool: def _should_skip_by_interval(db: Session, user_id: int) -> bool:
"""检查用户是否仍在冷却期内,避免短时间内重复推送""" """检查用户是否仍在冷却期内,避免短时间内重复推送"""
row = ( row = (
@@ -297,9 +288,9 @@ def _record_delivery(
db.commit() db.commit()
# ========================================== # AI辅助生成:deepseek-v3-22026年3月20日
# 推送准备 # 推送准备
# ==========================================
@dataclass @dataclass
class _PendingPush: class _PendingPush:
"""暂存需要发送邮件的信息,便于在 async 上下文中发送。""" """暂存需要发送邮件的信息,便于在 async 上下文中发送。"""
@@ -309,6 +300,7 @@ class _PendingPush:
html_body: str html_body: str
event_ids: list[int] event_ids: list[int]
# AI生成结束
def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedule) -> _PendingPush | None: def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedule) -> _PendingPush | None:
""" """
@@ -331,7 +323,6 @@ def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedul
pushed_ids = _get_already_pushed_event_ids(db, user_id) pushed_ids = _get_already_pushed_event_ids(db, user_id)
# 决策:有关键词且有匹配 → 匹配模式;否则 → 默认热点模式
items: list = [] items: list = []
is_default = False is_default = False
@@ -361,7 +352,6 @@ def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedul
logger.info(f"用户 {user_id} 默认热点无可推送内容,跳过") logger.info(f"用户 {user_id} 默认热点无可推送内容,跳过")
return None return None
# 批量加载平台数据(来源名、标题、URL、排名)
event_ids = [item.event.id for item in items] event_ids = [item.event.id for item in items]
platforms_map = _load_event_platforms(db, event_ids) platforms_map = _load_event_platforms(db, event_ids)
@@ -377,15 +367,12 @@ def _prepare_user_push(db: Session, user: AppUser, schedule: UserDeliverySchedul
return _PendingPush( return _PendingPush(
user_id=user_id, user_id=user_id,
email_targets=[ep.channel_account for ep in email_endpoints], email_targets=[ep.channel_account for ep in email_endpoints],
subject=f"InsightRadar {subject_suffix} · {time_str}", subject=f"聚势智见 {subject_suffix} · {time_str}",
html_body=html_body, html_body=html_body,
event_ids=event_ids, event_ids=event_ids,
) )
# ==========================================
# 调度主入口
# ==========================================
async def check_and_deliver() -> None: async def check_and_deliver() -> None:
""" """
定时推送主入口,由 APScheduler 每分钟调用。 定时推送主入口,由 APScheduler 每分钟调用。
@@ -412,7 +399,6 @@ async def check_and_deliver() -> None:
if not user: if not user:
continue continue
# 将 UTC 转为用户本地时间,判断是否落在推送窗口内
user_current = _user_local_time(now, user.timezone) user_current = _user_local_time(now, user.timezone)
if not _is_within_window(schedule.delivery_time, user_current): if not _is_within_window(schedule.delivery_time, user_current):
continue continue
@@ -422,7 +408,6 @@ async def check_and_deliver() -> None:
if pending is None: if pending is None:
continue continue
# 异步按优先级尝试各邮件渠道
sent = False sent = False
for target_email in pending.email_targets: for target_email in pending.email_targets:
try: try:
+13 -36
View File
@@ -1,8 +1,3 @@
# app/services/fetcher_service.py
"""
抓取服务:从外部 API 拉取热搜/RSS 数据,做查重、向量聚类、入库
热搜分支:语义聚类到 UnifiedEventRSS 分支:写入 NewsArticle
"""
import os import os
import hashlib import hashlib
from datetime import timedelta from datetime import timedelta
@@ -19,6 +14,8 @@ from app.models.models import (
HeadlineRevision, RankingLog, SourceType, utcnow, UnifiedEvent HeadlineRevision, RankingLog, SourceType, utcnow, UnifiedEvent
) )
# AI辅助生成:deepseek-v3-22026年3月20日
# 加载环境变量 # 加载环境变量
load_dotenv() load_dotenv()
hf_token = os.getenv("HF_TOKEN") hf_token = os.getenv("HF_TOKEN")
@@ -26,11 +23,13 @@ SIMILARITY_THRESHOLD = float(os.getenv("SIMILARITY_THRESHOLD", 0.72))
API_BASE_URL = os.getenv("API_BASE_URL", "https://newsnow.busiyi.world/api/s") API_BASE_URL = os.getenv("API_BASE_URL", "https://newsnow.busiyi.world/api/s")
EMBEDDING_MODEL_PATH = os.getenv("EMBEDDING_MODEL_PATH", "") EMBEDDING_MODEL_PATH = os.getenv("EMBEDDING_MODEL_PATH", "")
print("正在加载 BAAI/bge-m3 向量模型...") print("正在加载模型...")
# 全局单例 # 全局单例
embedder_model = SentenceTransformer(EMBEDDING_MODEL_PATH, local_files_only=True, device="cuda") embedder_model = SentenceTransformer(EMBEDDING_MODEL_PATH, local_files_only=True)
print("模型加载完成。") print("模型加载完成。")
# AI生成结束
def generate_md5(text: str) -> str: def generate_md5(text: str) -> str:
"""生成 32 位 MD5 作为 external_id,用于跨平台去重""" """生成 32 位 MD5 作为 external_id,用于跨平台去重"""
@@ -88,10 +87,10 @@ class UnifiedEventClusterer:
new_unified = UnifiedEvent( new_unified = UnifiedEvent(
unified_title=title, unified_title=title,
center_embedding=embedding_json, center_embedding=embedding_json,
hot_score=1 # 初始热度 hot_score=1
) )
self.db.add(new_unified) self.db.add(new_unified)
self.db.flush() # 获取自增的主键 ID self.db.flush()
# 更新缓存 # 更新缓存
self.event_vectors.append(new_vec) self.event_vectors.append(new_vec)
@@ -109,11 +108,8 @@ def process_hot_trend_item(db, source, item, index: int, external_id: str, exist
event_to_log = None event_to_log = None
# 查重:已存在则可能只需更新标题/排名;不存在则需聚类并新建
if existing_event: if existing_event:
# 场景 A1:老熟人
if existing_event.current_headline != title: if existing_event.current_headline != title:
# 标题被暗改,此时需要重新算一次 Embedding
new_embedding_json, _ = embeddings_dict[title] new_embedding_json, _ = embeddings_dict[title]
revision = HeadlineRevision( revision = HeadlineRevision(
@@ -123,30 +119,25 @@ def process_hot_trend_item(db, source, item, index: int, external_id: str, exist
) )
db.add(revision) db.add(revision)
existing_event.current_headline = title existing_event.current_headline = title
existing_event.title_embedding = new_embedding_json # 更新为新标题的语义向量 existing_event.title_embedding = new_embedding_json
# 注:这里不改变它所属的 unified_event_id,因为大体还是同一件事
existing_event.current_ranking = index existing_event.current_ranking = index
existing_event.event_url = item_url existing_event.event_url = item_url
event_to_log = existing_event event_to_log = existing_event
else: else:
# 场景 A2:这是一条彻底的全新热搜
# 1. 计算向量
new_embedding_json, new_vec = embeddings_dict[title]
# 2. 扔进聚类中枢找归宿 new_embedding_json, new_vec = embeddings_dict[title]
matched_event_id = clusterer.match_or_create(title, new_embedding_json, new_vec) matched_event_id = clusterer.match_or_create(title, new_embedding_json, new_vec)
# 3. 落库
new_event = TrendingEvent( new_event = TrendingEvent(
source_id=source.id, source_id=source.id,
external_id=external_id, external_id=external_id,
current_headline=title, current_headline=title,
event_url=item_url, event_url=item_url,
current_ranking=index, current_ranking=index,
title_embedding=new_embedding_json, # 存入向量 title_embedding=new_embedding_json,
unified_event_id=matched_event_id # 挂载到大事件下 unified_event_id=matched_event_id
) )
db.add(new_event) db.add(new_event)
db.flush() db.flush()
@@ -192,7 +183,6 @@ def process_source_data(db, source, items: list) -> int:
saved_count = 0 saved_count = 0
platform_id = source.home_url platform_id = source.home_url
# 1. 批量计算外部 ID 并聚合要计算的文本
valid_items = [] valid_items = []
external_ids = [] external_ids = []
for item in items: for item in items:
@@ -209,7 +199,6 @@ def process_source_data(db, source, items: list) -> int:
if not valid_items: if not valid_items:
return 0 return 0
# 批量查重:按 external_id 判断是更新还是新增
existing_events_dict = {} existing_events_dict = {}
existing_articles_dict = {} existing_articles_dict = {}
@@ -226,7 +215,6 @@ def process_source_data(db, source, items: list) -> int:
).all() ).all()
existing_articles_dict = {art.external_id: art for art in existing_articles} existing_articles_dict = {art.external_id: art for art in existing_articles}
# 仅对需要算向量的标题做批量 embedding,避免重复计算
texts_to_embed = [] texts_to_embed = []
if source.source_type in (SourceType.HOT_TREND, SourceType.API): if source.source_type in (SourceType.HOT_TREND, SourceType.API):
for item, external_id in valid_items: for item, external_id in valid_items:
@@ -238,15 +226,12 @@ def process_source_data(db, source, items: list) -> int:
else: else:
texts_to_embed.append(title) texts_to_embed.append(title)
# 4. 批量执行大模型推理
embeddings_dict = generate_embeddings_batch(texts_to_embed) embeddings_dict = generate_embeddings_batch(texts_to_embed)
# 初始化聚类器(只在热搜模式下需要,且只初始化一次)
clusterer = None clusterer = None
if source.source_type in (SourceType.HOT_TREND, SourceType.API): if source.source_type in (SourceType.HOT_TREND, SourceType.API):
clusterer = UnifiedEventClusterer(db) clusterer = UnifiedEventClusterer(db)
# 按来源类型分流:热搜/API → TrendingEvent + 聚类;RSS → NewsArticle
for index, (item, external_id) in enumerate(valid_items, 1): for index, (item, external_id) in enumerate(valid_items, 1):
if source.source_type in (SourceType.HOT_TREND, SourceType.API): if source.source_type in (SourceType.HOT_TREND, SourceType.API):
existing_event = existing_events_dict.get(external_id) existing_event = existing_events_dict.get(external_id)
@@ -269,14 +254,12 @@ async def fetch_and_save_trending_data():
""" """
print(f"[{utcnow()}] 开始执行定时抓取任务...") print(f"[{utcnow()}] 开始执行定时抓取任务...")
# 获取启用的信息源 - 这个只读操作用一个短连接
with SessionLocal() as db: with SessionLocal() as db:
sources = db.query(InfoSource).filter(InfoSource.is_enabled == True).all() sources = db.query(InfoSource).filter(InfoSource.is_enabled == True).all()
if not sources: if not sources:
print("没有找到启用的信息源,任务结束。") print("没有找到启用的信息源,任务结束。")
return return
# 我们把 source 的信息提前提取出来,避免在异步中长期持有 session
source_configs = [ source_configs = [
{ {
"id": s.id, "id": s.id,
@@ -287,7 +270,6 @@ async def fetch_and_save_trending_data():
for s in sources for s in sources
] ]
# 伪装请求头,规避反爬
custom_headers = { custom_headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36",
"Accept": "application/json, text/plain, */*", "Accept": "application/json, text/plain, */*",
@@ -304,13 +286,11 @@ async def fetch_and_save_trending_data():
url = f"{API_BASE_URL}?id={platform_id}&latest" url = f"{API_BASE_URL}?id={platform_id}&latest"
try: try:
# 1. 网络请求(可能耗时较长,不要包在 db session 里)
response = await client.get(url) response = await client.get(url)
response.raise_for_status() response.raise_for_status()
data_json = response.json() data_json = response.json()
items = data_json.get("items", []) items = data_json.get("items", [])
# 2. 数据库事务操作(尽量短,单独使用 session)
with SessionLocal() as db: with SessionLocal() as db:
# 重新从短 session 中获取 source 实例,以免 detached # 重新从短 session 中获取 source 实例,以免 detached
source = db.query(InfoSource).get(s_config["id"]) source = db.query(InfoSource).get(s_config["id"])
@@ -319,10 +299,8 @@ async def fetch_and_save_trending_data():
task_log = DataSyncTask(source_id=source.id, items_fetched=0) task_log = DataSyncTask(source_id=source.id, items_fetched=0)
try: try:
# 调用数据处理层
saved_count = process_source_data(db, source, items) saved_count = process_source_data(db, source, items)
# 业务事务成功提交
task_log.items_fetched = saved_count task_log.items_fetched = saved_count
task_log.task_status = TaskStatus.SUCCESS task_log.task_status = TaskStatus.SUCCESS
db.add(task_log) db.add(task_log)
@@ -330,10 +308,9 @@ async def fetch_and_save_trending_data():
print(f"[{source.source_name}] ({source.source_type}) 成功抓取并更新了 {saved_count} 条数据") print(f"[{source.source_name}] ({source.source_type}) 成功抓取并更新了 {saved_count} 条数据")
except Exception as e: except Exception as e:
db.rollback() db.rollback()
raise e # 抛出给外层捕获记录日志 raise e
except Exception as e: except Exception as e:
# 异常拦截与错误隔离,另起一个超短事务记录日志
with SessionLocal() as log_db: with SessionLocal() as log_db:
try: try:
new_task_log = DataSyncTask(source_id=s_config["id"], items_fetched=0) new_task_log = DataSyncTask(source_id=s_config["id"], items_fetched=0)
+83 -57
View File
@@ -1,7 +1,3 @@
"""
匹配服务:根据用户兴趣关键词(精确 + 语义)推荐事件
打分融合:匹配分 + 标签相关度 + 热度 + 新鲜度加成
"""
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -13,8 +9,9 @@ from sqlalchemy.orm import Session
from app.models.models import ExtractedTopic, TargetType, UnifiedEvent, UserTopicPreference, utcnow from app.models.models import ExtractedTopic, TargetType, UnifiedEvent, UserTopicPreference, utcnow
from app.services.fetcher_service import embedder_model from app.services.fetcher_service import embedder_model
# AI辅助生成:deepseek-v3-22026年3月20日
# 语义匹配阈值:用户关键词和事件标签向量相似度达到该值才计入语义命中 # 语义匹配阈值:用户关键词和事件标签/标题向量相似度达到该值才计入语义命中
DEFAULT_PREFERENCE_SEMANTIC_THRESHOLD = 0.78 DEFAULT_PREFERENCE_SEMANTIC_THRESHOLD = 0.78
PREFERENCE_SEMANTIC_THRESHOLD = float( PREFERENCE_SEMANTIC_THRESHOLD = float(
os.getenv("PREFERENCE_SEMANTIC_THRESHOLD", str(DEFAULT_PREFERENCE_SEMANTIC_THRESHOLD)) os.getenv("PREFERENCE_SEMANTIC_THRESHOLD", str(DEFAULT_PREFERENCE_SEMANTIC_THRESHOLD))
@@ -35,12 +32,38 @@ class MatchedEventResult:
semantic_hits: list[dict[str, Any]] semantic_hits: list[dict[str, Any]]
tags: list[str] tags: list[str]
# AI生成结束
def _normalize_text(text: str) -> str: def _normalize_text(text: str) -> str:
"""统一小写与首尾空白,便于做稳定匹配。""" """统一小写与首尾空白,便于做稳定匹配。"""
return text.strip().casefold() return text.strip().casefold()
def _find_exact_preference_match(
target_text: str,
normalized_preferences: list[tuple[str, str]],
) -> str | None:
"""
判断目标文本是否与某个用户兴趣词形成“精确命中”。
命中条件:
1. 标准化后完全相等
2. 二者互为包含关系
返回命中的原始兴趣词,未命中则返回 None。
"""
normalized_target = _normalize_text(target_text)
if not normalized_target:
return None
for raw_pref, normalized_pref in normalized_preferences:
if not normalized_pref:
continue
if normalized_target == normalized_pref:
return raw_pref
if normalized_pref in normalized_target or normalized_target in normalized_pref:
return raw_pref
return None
_EMBEDDING_CACHE: dict[str, np.ndarray] = {} _EMBEDDING_CACHE: dict[str, np.ndarray] = {}
MAX_CACHE_SIZE = 10000 MAX_CACHE_SIZE = 10000
@@ -55,7 +78,6 @@ def _build_keyword_embedding_map(keywords: list[str]) -> dict[str, np.ndarray]:
uncached_keywords = [] uncached_keywords = []
# 1. 尝试从缓存获取
for keyword in keywords: for keyword in keywords:
if not keyword: if not keyword:
continue continue
@@ -64,9 +86,7 @@ def _build_keyword_embedding_map(keywords: list[str]) -> dict[str, np.ndarray]:
else: else:
uncached_keywords.append(keyword) uncached_keywords.append(keyword)
# 2. 对未命中的词进行统一的批量推理
if uncached_keywords: if uncached_keywords:
# 去重,避免同一个未缓存的词被计算多次
unique_uncached = list(dict.fromkeys(uncached_keywords)) unique_uncached = list(dict.fromkeys(uncached_keywords))
vectors = embedder_model.encode(unique_uncached, normalize_embeddings=True, show_progress_bar=False) vectors = embedder_model.encode(unique_uncached, normalize_embeddings=True, show_progress_bar=False)
@@ -77,7 +97,6 @@ def _build_keyword_embedding_map(keywords: list[str]) -> dict[str, np.ndarray]:
for k in keys_to_delete: for k in keys_to_delete:
del _EMBEDDING_CACHE[k] del _EMBEDDING_CACHE[k]
# 3. 将新计算的向量存入缓存并回填结果
for keyword, vec in zip(unique_uncached, vectors): for keyword, vec in zip(unique_uncached, vectors):
vec_array = np.asarray(vec, dtype=np.float32) vec_array = np.asarray(vec, dtype=np.float32)
_EMBEDDING_CACHE[keyword] = vec_array _EMBEDDING_CACHE[keyword] = vec_array
@@ -86,6 +105,26 @@ def _build_keyword_embedding_map(keywords: list[str]) -> dict[str, np.ndarray]:
return result return result
def _find_best_semantic_match(
target_text: str,
target_vec_map: dict[str, np.ndarray],
pref_vec_map: dict[str, np.ndarray],
) -> tuple[str | None, float]:
"""返回与目标文本最接近的兴趣词及其余弦相似度。"""
target_vec = target_vec_map.get(target_text)
if target_vec is None:
return None, -1.0
best_pref = None
best_sim = -1.0
for pref_keyword, pref_vec in pref_vec_map.items():
sim = float(np.dot(target_vec, pref_vec))
if sim > best_sim:
best_sim = sim
best_pref = pref_keyword
return best_pref, best_sim
def _ensure_aware(dt: datetime) -> datetime: def _ensure_aware(dt: datetime) -> datetime:
"""SQLite 读出的 datetime 不带时区信息,统一补上 UTC 后才能和 utcnow() 做减法。""" """SQLite 读出的 datetime 不带时区信息,统一补上 UTC 后才能和 utcnow() 做减法。"""
if dt.tzinfo is None: if dt.tzinfo is None:
@@ -116,8 +155,8 @@ def recommend_events_for_user(
) -> list[MatchedEventResult]: ) -> list[MatchedEventResult]:
""" """
用户兴趣推荐主流程: 用户兴趣推荐主流程:
1) 精确匹配:用户词 == EVENT 标签 1) 精确匹配:用户词 vs EVENT 标签/标题
2) 语义匹配:用户词向量 vs EVENT 标签向量(超过阈值) 2) 语义匹配:用户词向量 vs EVENT 标签/标题向量(超过阈值)
3) 打分融合:匹配分 + 标签相关度 + 热度 + 新鲜度 3) 打分融合:匹配分 + 标签相关度 + 热度 + 新鲜度
""" """
final_limit = max(1, min(limit, PREFERENCE_RECOMMEND_MAX_LIMIT)) final_limit = max(1, min(limit, PREFERENCE_RECOMMEND_MAX_LIMIT))
@@ -127,7 +166,6 @@ def recommend_events_for_user(
else PREFERENCE_SEMANTIC_THRESHOLD else PREFERENCE_SEMANTIC_THRESHOLD
) )
# 1. 读取用户兴趣词
preferences = ( preferences = (
db.query(UserTopicPreference) db.query(UserTopicPreference)
.filter(UserTopicPreference.user_id == user_id) .filter(UserTopicPreference.user_id == user_id)
@@ -140,7 +178,6 @@ def recommend_events_for_user(
if not preference_keywords: if not preference_keywords:
return [] return []
# 2. 读取候选事件(时间 + 热度过滤,避免全表扫描)
time_limit = utcnow() - timedelta(hours=hours) time_limit = utcnow() - timedelta(hours=hours)
events = ( events = (
db.query(UnifiedEvent) db.query(UnifiedEvent)
@@ -167,72 +204,47 @@ def recommend_events_for_user(
) )
.all() .all()
) )
if not topic_rows:
return []
# 组织事件标签映射:event_id -> [(tag, relevance_score), ...]
event_topics: dict[int, list[tuple[str, float | None]]] = {} event_topics: dict[int, list[tuple[str, float | None]]] = {}
for event_id, topic_keyword, relevance_score in topic_rows: for event_id, topic_keyword, relevance_score in topic_rows:
if not topic_keyword: if not topic_keyword:
continue continue
event_topics.setdefault(event_id, []).append((topic_keyword, relevance_score)) event_topics.setdefault(event_id, []).append((topic_keyword, relevance_score))
# 如果某事件没有标签,就不参与推荐
if not event_topics:
return []
# 3. 批量编码用户词与标签词,减少模型调用次数
unique_preference_keywords = list(dict.fromkeys(preference_keywords)) unique_preference_keywords = list(dict.fromkeys(preference_keywords))
unique_topic_keywords = list(dict.fromkeys([row[1] for row in topic_rows if row[1]])) unique_topic_keywords = list(dict.fromkeys([row[1] for row in topic_rows if row[1]]))
pref_vec_map = _build_keyword_embedding_map(unique_preference_keywords) pref_vec_map = _build_keyword_embedding_map(unique_preference_keywords)
topic_vec_map = _build_keyword_embedding_map(unique_topic_keywords) topic_vec_map = _build_keyword_embedding_map(unique_topic_keywords)
# 预先建立“标准化后用户词集合”,用于精确匹配 normalized_preference_pairs = [
normalized_pref_set = {_normalize_text(word) for word in unique_preference_keywords} (word, _normalize_text(word))
for word in unique_preference_keywords
if _normalize_text(word)
]
unique_event_titles = list(
dict.fromkeys(
[event.unified_title.strip() for event in events if event.unified_title and event.unified_title.strip()]
)
)
title_vec_map = _build_keyword_embedding_map(unique_event_titles)
scored_results: list[MatchedEventResult] = [] scored_results: list[MatchedEventResult] = []
for event in events: for event in events:
topic_list = event_topics.get(event.id, []) topic_list = event_topics.get(event.id, [])
if not topic_list:
continue
exact_hits: list[str] = [] exact_hits: list[str] = []
semantic_hits: list[dict[str, Any]] = [] semantic_hits: list[dict[str, Any]] = []
score = 0.0 score = 0.0
# 对每个事件标签做精确匹配或语义匹配
for topic_keyword, topic_relevance in topic_list: for topic_keyword, topic_relevance in topic_list:
normalized_topic = _normalize_text(topic_keyword)
topic_relevance_score = float(topic_relevance) if topic_relevance is not None else 50.0 topic_relevance_score = float(topic_relevance) if topic_relevance is not None else 50.0
# 1) 精确命中(包括完全相等与包含关系) matched_pref = _find_exact_preference_match(topic_keyword, normalized_preference_pairs)
matched_exact = False if matched_pref is not None:
if normalized_topic in normalized_pref_set:
matched_exact = True
else:
for pref_word in normalized_pref_set:
if pref_word and (pref_word in normalized_topic or normalized_topic in pref_word):
matched_exact = True
break
if matched_exact:
exact_hits.append(topic_keyword) exact_hits.append(topic_keyword)
# 精确命中给较高基础分,标签自身相关度作为增益
score += 45.0 + topic_relevance_score * 0.2 score += 45.0 + topic_relevance_score * 0.2
continue continue
# 2) 语义命中(未精确命中时再算) best_pref, best_sim = _find_best_semantic_match(topic_keyword, topic_vec_map, pref_vec_map)
topic_vec = topic_vec_map.get(topic_keyword)
if topic_vec is None:
continue
best_pref = None
best_sim = -1.0
for pref_keyword, pref_vec in pref_vec_map.items():
sim = float(np.dot(topic_vec, pref_vec))
if sim > best_sim:
best_sim = sim
best_pref = pref_keyword
if best_pref is not None and best_sim >= similarity_threshold: if best_pref is not None and best_sim >= similarity_threshold:
semantic_hits.append( semantic_hits.append(
@@ -242,18 +254,32 @@ def recommend_events_for_user(
"similarity": round(best_sim, 4), "similarity": round(best_sim, 4),
} }
) )
# 语义命中分略低于精确命中,并由相似度放大
score += best_sim * 35.0 + topic_relevance_score * 0.12 score += best_sim * 35.0 + topic_relevance_score * 0.12
# 如果精确和语义都没命中,直接跳过 event_title = (event.unified_title or "").strip()
if event_title:
title_exact_pref = _find_exact_preference_match(event_title, normalized_preference_pairs)
if title_exact_pref is not None:
exact_hits.append(f"标题:{title_exact_pref}")
score += 30.0
else:
best_pref, best_sim = _find_best_semantic_match(event_title, title_vec_map, pref_vec_map)
if best_pref is not None and best_sim >= similarity_threshold:
semantic_hits.append(
{
"preference_keyword": best_pref,
"topic_keyword": f"标题:{best_pref}",
"similarity": round(best_sim, 4),
}
)
score += best_sim * 24.0
if not exact_hits and not semantic_hits: if not exact_hits and not semantic_hits:
continue continue
# 融合事件热度和新鲜度,避免只看语义分
score += min(event.hot_score, 100) * 0.3 score += min(event.hot_score, 100) * 0.3
score += _calc_freshness_bonus(event) score += _calc_freshness_bonus(event)
# 返回标签时做去重,保证接口稳定
tags = list(dict.fromkeys([item[0] for item in topic_list])) tags = list(dict.fromkeys([item[0] for item in topic_list]))
scored_results.append( scored_results.append(
MatchedEventResult( MatchedEventResult(
+4 -8
View File
@@ -1,8 +1,3 @@
# app/services/summary_service.py
"""
摘要服务:调用 LLM 生成统一标题、综合摘要、话题标签
定时任务:对热度达标且未摘要的事件批量处理
"""
import json import json
import os import os
from datetime import timedelta from datetime import timedelta
@@ -26,12 +21,16 @@ from app.prompts.summary_prompts import (
) )
from app.services.fetcher_service import embedder_model from app.services.fetcher_service import embedder_model
# AI辅助生成:deepseek-v3-22026年3月20日
HOT_SCORE_THRESHOLD = int(os.getenv("HOT_SCORE_THRESHOLD", 3)) HOT_SCORE_THRESHOLD = int(os.getenv("HOT_SCORE_THRESHOLD", 3))
TOPIC_TAG_MIN_HOT_SCORE = int(os.getenv("TOPIC_TAG_MIN_HOT_SCORE", HOT_SCORE_THRESHOLD)) TOPIC_TAG_MIN_HOT_SCORE = int(os.getenv("TOPIC_TAG_MIN_HOT_SCORE", HOT_SCORE_THRESHOLD))
TOPIC_SIMILARITY_THRESHOLD = float(os.getenv("TOPIC_SIMILARITY_THRESHOLD", 0.82)) TOPIC_SIMILARITY_THRESHOLD = float(os.getenv("TOPIC_SIMILARITY_THRESHOLD", 0.82))
TOPIC_TAG_MAX_COUNT = int(os.getenv("TOPIC_TAG_MAX_COUNT", 8)) TOPIC_TAG_MAX_COUNT = int(os.getenv("TOPIC_TAG_MAX_COUNT", 8))
AI_API_KEY = os.getenv("AI_API_KEY", "") AI_API_KEY = os.getenv("AI_API_KEY", "")
# AI生成结束
deepseek_client = AsyncOpenAI( deepseek_client = AsyncOpenAI(
api_key=AI_API_KEY, api_key=AI_API_KEY,
@@ -184,7 +183,6 @@ async def generate_unified_summaries():
"""定时任务:对热度达标且未摘要的事件刷新标题、摘要、标签""" """定时任务:对热度达标且未摘要的事件刷新标题、摘要、标签"""
print(f"[{utcnow()}] Start unified summary generation task...") print(f"[{utcnow()}] Start unified summary generation task...")
# 先提取需要处理的事件 ID,尽早释放 session,不长期占用 db session
with SessionLocal() as db: with SessionLocal() as db:
recent_threshold = utcnow() - timedelta(days=3) recent_threshold = utcnow() - timedelta(days=3)
events = db.query(UnifiedEvent).filter( events = db.query(UnifiedEvent).filter(
@@ -197,11 +195,9 @@ async def generate_unified_summaries():
print("No events require summary update in this round.") print("No events require summary update in this round.")
return return
# 复制出需要的信息,脱离 session
event_ids = [e.id for e in events] event_ids = [e.id for e in events]
event_hot_scores = {e.id: e.hot_score for e in events} event_hot_scores = {e.id: e.hot_score for e in events}
# 外层循环:针对每个 event_id 开启一个极短生命周期的 session 获取依赖数据
for event_id in event_ids: for event_id in event_ids:
platform_dict: dict[str, set[str]] = {} platform_dict: dict[str, set[str]] = {}
with SessionLocal() as db: with SessionLocal() as db:
+1 -1
View File
@@ -1,4 +1,4 @@
# app/utils/email_utils.py # AI辅助生成:deepseek-v3-22026年3月20日
import os import os
from email.message import EmailMessage from email.message import EmailMessage
import aiosmtplib import aiosmtplib
+6 -17
View File
@@ -1,27 +1,21 @@
from functools import lru_cache
import logging import logging
import os import os
from typing import Optional, TYPE_CHECKING from typing import Optional, TYPE_CHECKING
import redis
if TYPE_CHECKING:
from redis import Redis
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
try:
import redis # type: ignore
except ImportError: # pragma: no cover
redis = None # type: ignore
REDIS_URL = os.getenv("REDIS_URL", "").strip() REDIS_URL = os.getenv("REDIS_URL", "").strip()
REDIS_CONNECT_TIMEOUT_SECONDS = float(os.getenv("REDIS_CONNECT_TIMEOUT_SECONDS", "2")) REDIS_CONNECT_TIMEOUT_SECONDS = float(os.getenv("REDIS_CONNECT_TIMEOUT_SECONDS", "2"))
REDIS_SOCKET_TIMEOUT_SECONDS = float(os.getenv("REDIS_SOCKET_TIMEOUT_SECONDS", "2")) REDIS_SOCKET_TIMEOUT_SECONDS = float(os.getenv("REDIS_SOCKET_TIMEOUT_SECONDS", "2"))
_redis_client: Optional["Redis"] = None _redis_client: Optional["redis.Redis"] = None
_initialized = False _initialized = False
@lru_cache
def get_redis_client() -> Optional["Redis"]: def get_redis_client() -> Optional["redis.Redis"]:
"""Return a singleton Redis client, or None when Redis is unavailable.""" """Return a singleton Redis client, or None when Redis is unavailable."""
global _redis_client, _initialized global _redis_client, _initialized
@@ -31,12 +25,7 @@ def get_redis_client() -> Optional["Redis"]:
_initialized = True _initialized = True
if not REDIS_URL: if not REDIS_URL:
logger.info("REDIS_URL 未配置,验证码将回退到数据库存储") logger.info("REDIS_URL 未配置,验证码将回退到内存存储")
_redis_client = None
return _redis_client
if redis is None:
logger.warning("未安装 redis 包,验证码将回退到数据库存储")
_redis_client = None _redis_client = None
return _redis_client return _redis_client
+16
View File
@@ -0,0 +1,16 @@
# AI辅助生成:deepseek-v3-22026年3月20日
import uvicorn
import os
from dotenv import load_dotenv
if __name__ == "__main__":
load_dotenv()
PORT = int(os.getenv("PORT", 8000))
uvicorn.run(
app="app.main:app",
host="0.0.0.0",
port=PORT,
workers=1
)
+80
View File
@@ -0,0 +1,80 @@
[project]
name = "backend"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"aiosmtplib==5.1.0",
"annotated-doc==0.0.4",
"annotated-types==0.7.0",
"anyio==4.12.1",
"apscheduler==3.11.2",
"certifi==2026.2.25",
"charset-normalizer==3.4.5",
"click==8.3.1",
"colorama==0.4.6",
"distro==1.9.0",
"fastapi==0.135.1",
"filelock==3.25.0",
"fsspec==2026.2.0",
"greenlet==3.3.2",
"h11==0.16.0",
"hf-xet==1.3.2",
"httpcore==1.0.9",
"httpx==0.28.1",
"huggingface-hub==1.6.0",
"idna==3.11",
"jinja2==3.1.6",
"jiter==0.13.0",
"joblib==1.5.3",
"markdown-it-py==4.0.0",
"markupsafe==3.0.3",
"mdurl==0.1.2",
"modelscope>=1.35.0",
"mpmath==1.3.0",
"networkx==3.6.1",
"numpy==2.4.3",
"openai==2.26.0",
"pillow==12.0.0",
"pydantic==2.12.5",
"pydantic-core==2.41.5",
"pygments==2.19.2",
"python-dotenv==1.2.2",
"pyyaml==6.0.3",
"redis==7.3.0",
"regex==2026.2.28",
"requests==2.32.5",
"rich==14.3.3",
"safetensors==0.7.0",
"scikit-learn==1.8.0",
"scipy==1.17.1",
"shellingham==1.5.4",
"sniffio==1.3.1",
"sqlalchemy==2.0.48",
"starlette==0.52.1",
"sympy==1.14.0",
"threadpoolctl==3.6.0",
"tokenizers==0.22.2",
"tqdm==4.67.3",
"transformers==5.3.0",
"typer==0.24.1",
"typing-extensions==4.15.0",
"typing-inspection==0.4.2",
"tzdata==2025.3",
"tzlocal==5.3.1",
"urllib3==2.6.3",
"uvicorn==0.41.0",
"torch==2.11.0+cpu",
"torchvision==0.26.0+cpu",
"torchaudio==2.11.0+cpu",
"sentence-transformers>=5.3.0",
]
[[tool.uv.index]]
name = "pytorch-cpu"
url = "https://download.pytorch.org/whl/cpu"
default = false
[tool.uv]
index-strategy = "unsafe-best-match"
Binary file not shown.
-12
View File
@@ -1,12 +0,0 @@
# run.py
import uvicorn
if __name__ == "__main__":
# 启动服务
uvicorn.run(
app="app.main:app",
host="0.0.0.0",
port=8000,
# reload=True,
workers=1
)
-27
View File
@@ -1,27 +0,0 @@
import os
def print_tree(root, prefix=""):
items = sorted(
name for name in os.listdir(root)
if name != "__pycache__"
)
total = len(items)
for i, name in enumerate(items):
path = os.path.join(root, name)
is_last = (i == total - 1)
connector = "└── " if is_last else "├── "
print(prefix + connector + name)
if os.path.isdir(path):
extension = " " if is_last else ""
print_tree(path, prefix + extension)
root_dir = r"E:\ScnuProject\InsightRadar\backend\app"
print(os.path.basename(root_dir) + "/")
print_tree(root_dir)
-57
View File
@@ -1,57 +0,0 @@
# from dotenv import load_dotenv
# import os
# import time
#
# print("step 1: loading env")
# load_dotenv()
#
# hf_token = os.getenv("HF_TOKEN")
# print("step 2:", "HF_TOKEN loaded" if hf_token else "No token")
#
# print("step 3: importing sentence-transformers")
# from sentence_transformers import SentenceTransformer
#
# print("step 4: start loading model")
# t0 = time.time()
# model = SentenceTransformer(r"E:\Models\bge-m3", local_files_only=True, device="cuda")
# print(f"step 5: model loaded in {time.time() - t0:.2f}s")
#
# print("step 6: importing sklearn/numpy")
# from sklearn.metrics.pairwise import cosine_similarity
# import numpy as np
#
# titles = [
# # A组:同品牌同产品,但含义不同
# "苹果发布新款iPhone,影像系统再次升级",
# "苹果推出全新iPhone,摄像头性能进一步增强",
# "苹果回应新款iPhone发热问题:将通过系统更新修复",
# "苹果下调部分旧款iPhone售价,新机型并未参与促销",
#
# # B组:看起来都像“苹果新闻”,但主题已变
# "苹果公司股价上涨,市值再创新高",
# "苹果供应链承压,部分零部件厂商下调全年预期",
# "苹果被曝缩减Vision产品产量,市场需求不及预期",
# "苹果发布新款MacBook,并未更新iPhone产品线",
#
# # C组:同样是“发布/推出”,但主体不同
# "华为发布新款手机,影像能力进一步提升",
# "小米推出全新手机,影像系统迎来升级",
# "OPPO发布年度旗舰机型,主打夜景拍摄",
# ]
#
# print("step 7: start encoding")
# t1 = time.time()
# embeddings = model.encode(
# titles,
# normalize_embeddings=True,
# show_progress_bar=True,
# batch_size=16
# )
# print(f"step 8: encode done in {time.time() - t1:.2f}s")
#
# sim = cosine_similarity(embeddings)
# print(np.round(sim, 4))
#
import secrets
print(secrets.token_urlsafe(64))
-34
View File
@@ -1,34 +0,0 @@
# Health
GET http://127.0.0.1:8000/
Accept: application/json
###
# Send register verification code
POST http://127.0.0.1:8000/api/v1/auth/register/send-code
Content-Type: application/json
{
"email": "demo@example.com"
}
###
# Register by verification code
POST http://127.0.0.1:8000/api/v1/auth/register
Content-Type: application/json
{
"email": "demo@example.com",
"password": "DemoPass123",
"verification_code": "123456",
"nickname": "demo_user"
}
###
# Login
POST http://127.0.0.1:8000/api/v1/auth/login
Content-Type: application/json
{
"email": "demo@example.com",
"password": "DemoPass123"
}
+1720
View File
File diff suppressed because it is too large Load Diff
+50
View File
@@ -0,0 +1,50 @@
# ---------- 阶段1:前端编译(Node打包静态产物) ----------
FROM node:22-alpine AS frontend-builder
WORKDIR /frontend
# 复制前端依赖,利用Docker缓存优化
COPY frontend/package*.json ./
RUN npm install --registry=https://registry.npmmirror.com
# 复制前端代码,编译出静态产物
COPY frontend/ .
RUN npm run build
# ---------- 阶段2:后端依赖构建(uv构建虚拟环境) ----------
FROM python:3.11-slim AS backend-builder
WORKDIR /backend
# 安装uv,同步Python依赖
COPY backend/pyproject.toml backend/uv.lock ./
RUN --mount=type=cache,target=/root/.cache/uv \
pip install --no-cache-dir uv && \
uv sync --frozen --no-dev --index https://pypi.tuna.tsinghua.edu.cn/simple/
# 复制后端代码
COPY backend/app ./app
COPY backend/main.py ./
# ---------- 阶段3:最终运行镜像(仅Python+Uvicorn,托管前端静态) ----------
FROM python:3.11-slim
WORKDIR /app
# 复制构建好的后端虚拟环境
COPY --from=backend-builder /backend/.venv /app/.venv
COPY --from=backend-builder /backend/app /app/app
COPY --from=backend-builder /backend/main.py /app/main.py
# 复制前端编译好的静态产物,放到后端能访问的目录
# 这里我们把静态文件放到 /app/static 目录
COPY --from=frontend-builder /frontend/dist /app/app/static
# 把venv加入PATH
ENV PATH="/app/.venv/bin:$PATH"
# 暴露Uvicorn端口
EXPOSE 8000
# 直接启动Uvicorn,由Uvicorn配合后端框架托管静态文件
CMD ["python3", "main.py"]
+9
View File
@@ -1 +1,10 @@
/// <reference types="vite/client" /> /// <reference types="vite/client" />
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BACKEND_ORIGIN: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}
+1 -2
View File
@@ -4,8 +4,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.svg"> <link rel="icon" href="/favicon.svg">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>InsightRadar - 全网热点监控中枢</title> <title>聚势智见 - 基于语义聚类与大模型的热点资讯聚合平台</title>
<!-- Font Awesome 图标库 -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
</head> </head>
<body> <body>
-15
View File
@@ -69,7 +69,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.0",
@@ -1915,7 +1914,6 @@
"integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.16.0" "undici-types": "~7.16.0"
} }
@@ -1965,7 +1963,6 @@
"integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==", "integrity": "sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.56.1", "@typescript-eslint/scope-manager": "8.56.1",
"@typescript-eslint/types": "8.56.1", "@typescript-eslint/types": "8.56.1",
@@ -2549,7 +2546,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2619,7 +2615,6 @@
"resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.3.tgz", "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-5.10.3.tgz",
"integrity": "sha512-wwvkSLsodNOc/rHo5MJsn3GPM4Krc5Gs0zKX4Lfzq4LohcTbyKylYUGEqJFmXXxGR7yLbZQz31sB5RTqT5mv1g==", "integrity": "sha512-wwvkSLsodNOc/rHo5MJsn3GPM4Krc5Gs0zKX4Lfzq4LohcTbyKylYUGEqJFmXXxGR7yLbZQz31sB5RTqT5mv1g==",
"license": "SEE LICENSE IN LICENSE", "license": "SEE LICENSE IN LICENSE",
"peer": true,
"dependencies": { "dependencies": {
"@yr/monotone-cubic-spline": "^1.0.3" "@yr/monotone-cubic-spline": "^1.0.3"
} }
@@ -2741,7 +2736,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -3048,7 +3042,6 @@
"integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.2", "@eslint-community/regexpp": "^4.12.2",
@@ -3131,7 +3124,6 @@
"integrity": "sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==", "integrity": "sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@eslint-community/eslint-utils": "^4.4.0", "@eslint-community/eslint-utils": "^4.4.0",
"natural-compare": "^1.4.0", "natural-compare": "^1.4.0",
@@ -3590,7 +3582,6 @@
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "lib/jiti-cli.mjs" "jiti": "lib/jiti-cli.mjs"
} }
@@ -4187,7 +4178,6 @@
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/devtools-api": "^7.7.7" "@vue/devtools-api": "^7.7.7"
}, },
@@ -4594,7 +4584,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -4657,7 +4646,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -4811,7 +4799,6 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.27.0", "esbuild": "^0.27.0",
"fdir": "^6.5.0", "fdir": "^6.5.0",
@@ -5043,7 +5030,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -5063,7 +5049,6 @@
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
"integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==", "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@vue/compiler-dom": "3.5.29", "@vue/compiler-dom": "3.5.29",
"@vue/compiler-sfc": "3.5.29", "@vue/compiler-sfc": "3.5.29",
-6
View File
@@ -6,9 +6,6 @@ export function fetchDeliveryConfig(userId: number): Promise<DeliveryConfig> {
return apiGet<DeliveryConfig>(`/users/${userId}/delivery-config`) return apiGet<DeliveryConfig>(`/users/${userId}/delivery-config`)
} }
// ==========================================
// 推送时间表
// ==========================================
export function createDeliverySchedule( export function createDeliverySchedule(
userId: number, userId: number,
payload: { delivery_time: string; is_active?: boolean }, payload: { delivery_time: string; is_active?: boolean },
@@ -34,9 +31,6 @@ export function deleteDeliverySchedule(
return apiDelete(`/users/${userId}/delivery-schedules/${scheduleId}`) return apiDelete(`/users/${userId}/delivery-schedules/${scheduleId}`)
} }
// ==========================================
// 推送渠道
// ==========================================
export function createPushEndpoint( export function createPushEndpoint(
userId: number, userId: number,
payload: { payload: {
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,4 +1,4 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;600;700&display=swap'); @import url(./font.css);
/* ========================================= /* =========================================
1. 现代 SaaS 风格高级主题变量 1. 现代 SaaS 风格高级主题变量
-3
View File
@@ -1,6 +1,3 @@
/**
* 认证 API:登录、注册、发送验证码(不走通用 client,无 Bearer
*/
import type { import type {
AuthTokenResponse, AuthTokenResponse,
LoginPayload, LoginPayload,
+2
View File
@@ -1,3 +1,5 @@
// AI辅助生成:deepseek-v3-22026年3月20日
export interface UserProfile { export interface UserProfile {
id: number id: number
email: string email: string
-1
View File
@@ -26,7 +26,6 @@ function handleToggle(event: MouseEvent) {
Math.max(y, innerHeight - y) Math.max(y, innerHeight - y)
) )
// @ts-expect-error: TypeScript 类型可能较旧,忽略 startViewTransition 报错
const transition = document.startViewTransition(() => { const transition = document.startViewTransition(() => {
themeStore.toggleTheme() themeStore.toggleTheme()
}) })
@@ -111,6 +111,14 @@ function getRankingChartOptions(history: number[], platformColor: string) {
height: 56, height: 56,
sparkline: { enabled: true }, sparkline: { enabled: true },
animations: { enabled: true, easing: 'easeinout' as const, speed: 400 }, animations: { enabled: true, easing: 'easeinout' as const, speed: 400 },
events: {
mounted: (chartContext: any) => {
chartContext.el?.querySelector('.apexcharts-svg > title')?.remove()
},
updated: (chartContext: any) => {
chartContext.el?.querySelector('.apexcharts-svg > title')?.remove()
}
}
}, },
stroke: { curve: 'smooth' as const, width: 2 }, stroke: { curve: 'smooth' as const, width: 2 },
fill: { fill: {
+6 -115
View File
@@ -1,121 +1,12 @@
/**
* API 基础配置:自动探测内网/公网后端,失败时回退公网
*/
const API_PREFIX = '/api/v1' const API_PREFIX = '/api/v1'
const LAN_BACKEND_ORIGIN = 'http://10.252.130.135:8000'
const PUBLIC_BACKEND_ORIGIN = 'http://47.107.130.88:51290'
const PROBE_TIMEOUT_MS = 1200
const LAN_API_BASE_URL = `${LAN_BACKEND_ORIGIN}${API_PREFIX}` export function buildUrl(path: string): string {
const PUBLIC_API_BASE_URL = `${PUBLIC_BACKEND_ORIGIN}${API_PREFIX}` if (!path.startsWith('/')) {
const ENV_API_BASE_URL = import.meta.env.VITE_API_BASE_URL as string | undefined path = '/' + path
const EXPECTED_OPENAPI_PATHS = ['/api/v1/auth/login', '/api/v1/events/unified']
let detectedApiBaseUrl: string | null = ENV_API_BASE_URL ?? null
let detectPromise: Promise<string> | null = null
function normalizePath(path: string): string {
if (!path) return '/'
return path.startsWith('/') ? path : `/${path}`
}
function buildUrl(base: string, path: string): string {
return `${base}${normalizePath(path)}`
}
function isPrivateIpv4(hostname: string): boolean {
const parts = hostname.split('.').map((part) => Number.parseInt(part, 10))
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
return false
} }
return `${API_PREFIX}${path}`
const a = parts[0] as number
const b = parts[1] as number
if (a === 10) return true
if (a === 172 && b >= 16 && b <= 31) return true
if (a === 192 && b === 168) return true
if (a === 127) return true
return false
} }
function isLanHostname(hostname: string): boolean { export async function fetchApi(path: string, init?: RequestInit) {
const normalized = hostname.toLowerCase() return fetch(buildUrl(path), init)
if (normalized === 'localhost' || normalized.endsWith('.local')) return true
return isPrivateIpv4(normalized)
}
// 探测内网后端是否可用(请求 openapi.json
async function probeLanBackend(): Promise<boolean> {
if (typeof window === 'undefined') return false
const controller = new AbortController()
const timeout = window.setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS)
try {
const response = await fetch(`${LAN_BACKEND_ORIGIN}/openapi.json`, {
method: 'GET',
cache: 'no-store',
signal: controller.signal,
})
if (!response.ok) return false
const data = (await response.json()) as { paths?: Record<string, unknown> }
const paths = data.paths
if (!paths || typeof paths !== 'object') return false
return EXPECTED_OPENAPI_PATHS.every((path) =>
Object.prototype.hasOwnProperty.call(paths, path),
)
} catch {
return false
} finally {
window.clearTimeout(timeout)
}
}
// 根据当前 hostname 与探测结果选择内网或公网 API 地址
async function detectApiBaseUrl(): Promise<string> {
if (ENV_API_BASE_URL) return ENV_API_BASE_URL
if (typeof window === 'undefined') return PUBLIC_API_BASE_URL
if (!isLanHostname(window.location.hostname)) {
return PUBLIC_API_BASE_URL
}
const canUseLan = await probeLanBackend()
return canUseLan ? LAN_API_BASE_URL : PUBLIC_API_BASE_URL
}
function isLikelyNetworkError(error: unknown): boolean {
return error instanceof TypeError || (error instanceof DOMException && error.name === 'AbortError')
}
export async function getApiBaseUrl(): Promise<string> {
if (detectedApiBaseUrl) return detectedApiBaseUrl
if (!detectPromise) {
detectPromise = detectApiBaseUrl()
.then((url) => {
detectedApiBaseUrl = url
return url
})
.finally(() => {
detectPromise = null
})
}
return detectPromise
}
export async function fetchApi(path: string, init?: RequestInit): Promise<Response> {
const apiBaseUrl = await getApiBaseUrl()
const requestUrl = buildUrl(apiBaseUrl, path)
try {
return await fetch(requestUrl, init)
} catch (error) {
if (!ENV_API_BASE_URL && apiBaseUrl === LAN_API_BASE_URL && isLikelyNetworkError(error)) {
detectedApiBaseUrl = PUBLIC_API_BASE_URL
return fetch(buildUrl(PUBLIC_API_BASE_URL, path), init)
}
throw error
}
} }
+2 -1
View File
@@ -1,3 +1,4 @@
<!-- AI辅助生成deepseek-v3-22026年3月20日 -->
<!-- 仪表盘布局侧边栏导航主内容区移动端抽屉 --> <!-- 仪表盘布局侧边栏导航主内容区移动端抽屉 -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
@@ -57,7 +58,7 @@ function toggleSidebar() {
<!-- Logo --> <!-- Logo -->
<div class="sidebar-logo"> <div class="sidebar-logo">
<BrandLogo /> <BrandLogo />
<span class="logo-text">InsightRadar<span class="logo-dot">.AI</span></span> <span class="logo-text">聚势智见<span class="logo-dot">.AI</span></span>
</div> </div>
<!-- 导航菜单 --> <!-- 导航菜单 -->
+1 -3
View File
@@ -1,6 +1,4 @@
/** // AI辅助生成:deepseek-v3-22026年3月20日
* 应用入口:初始化 Vue、Pinia、路由、主题
*/
import './assets/main.css' import './assets/main.css'
import { createApp } from 'vue' import { createApp } from 'vue'
+3 -1
View File
@@ -1,7 +1,9 @@
<!-- AI辅助生成deepseek-v3-22026年3月20日 -->
<!-- 关于页占位 --> <!-- 关于页占位 -->
<template> <template>
<div class="about"> <div class="about">
<h1>关于 InsightRadar</h1> <h1>关于 聚势智见</h1>
</div> </div>
</template> </template>
+13 -52
View File
@@ -1,4 +1,3 @@
<!-- 主仪表盘事件流为你推荐公关修改追踪系统状态 -->
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed, watch } from 'vue' import { onMounted, ref, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -13,9 +12,6 @@ import type { MatchedEvent, UserTopicPreference } from '@/types/preference'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
// ==========================================
// 聚光灯:从推荐页跳转过来时,按 ID 单独拉取目标事件
// ==========================================
const spotlightEvent = ref<UnifiedEvent | null>(null) const spotlightEvent = ref<UnifiedEvent | null>(null)
const loadingSpotlight = ref(false) const loadingSpotlight = ref(false)
@@ -41,9 +37,7 @@ function dismissSpotlight() {
const authStore = useAuthStore() const authStore = useAuthStore()
const userId = computed(() => authStore.user?.id ?? 0) const userId = computed(() => authStore.user?.id ?? 0)
// ==========================================
// 状态
// ==========================================
const events = ref<UnifiedEvent[]>([]) const events = ref<UnifiedEvent[]>([])
const revisions = ref<HeadlineRevision[]>([]) const revisions = ref<HeadlineRevision[]>([])
const stats = ref<SystemStats | null>(null) const stats = ref<SystemStats | null>(null)
@@ -93,7 +87,7 @@ const hoursOptions = [
const sortOptions = [ const sortOptions = [
{ label: '时间排序', value: 'created_at' }, { label: '时间排序', value: 'created_at' },
{ label: '热度排序', value: 'hot_score' }, { label: '热度排序', value: 'hot_score' },
] ]
const recSortOptions = [ const recSortOptions = [
@@ -101,9 +95,6 @@ const recSortOptions = [
{ label: '最新', value: 'created_at' }, { label: '最新', value: 'created_at' },
] ]
// ==========================================
// 平台视觉映射
// ==========================================
const platformIconMap: Record<string, string> = { const platformIconMap: Record<string, string> = {
微博热搜: 'fa-brands fa-weibo', 微博热搜: 'fa-brands fa-weibo',
微博: 'fa-brands fa-weibo', 微博: 'fa-brands fa-weibo',
@@ -171,9 +162,7 @@ function formatRelativeTime(dateStr: string): string {
return `${days} 天前` return `${days} 天前`
} }
// ==========================================
// 排名图表配置
// ==========================================
function getRankingChartOptions(history: number[], platformColor: string) { function getRankingChartOptions(history: number[], platformColor: string) {
return { return {
series: [{ name: '排名', data: history }], series: [{ name: '排名', data: history }],
@@ -182,6 +171,14 @@ function getRankingChartOptions(history: number[], platformColor: string) {
height: 56, height: 56,
sparkline: { enabled: true }, sparkline: { enabled: true },
animations: { enabled: true, easing: 'easeinout' as const, speed: 400 }, animations: { enabled: true, easing: 'easeinout' as const, speed: 400 },
events: {
mounted: (chartContext: any) => {
chartContext.el?.querySelector('.apexcharts-svg > title')?.remove()
},
updated: (chartContext: any) => {
chartContext.el?.querySelector('.apexcharts-svg > title')?.remove()
}
}
}, },
stroke: { curve: 'smooth' as const, width: 2 }, stroke: { curve: 'smooth' as const, width: 2 },
fill: { fill: {
@@ -241,9 +238,6 @@ function platformKey(eventId: number, index: number, prefix: string = ''): strin
return prefix ? `${prefix}-${eventId}-${index}` : `${eventId}-${index}` return prefix ? `${prefix}-${eventId}-${index}` : `${eventId}-${index}`
} }
// ==========================================
// 数据加载
// ==========================================
async function loadEvents(append = false) { async function loadEvents(append = false) {
if (!append) { if (!append) {
loading.value = true loading.value = true
@@ -673,9 +667,6 @@ watch(() => route.query.event, (newId) => {
</template> </template>
</div> </div>
<!-- ==========================================
右侧:小组件面板
========================================== -->
<div class="widgets-column"> <div class="widgets-column">
<!-- 为你推荐(基于用户关键词的匹配) --> <!-- 为你推荐(基于用户关键词的匹配) -->
@@ -838,10 +829,10 @@ watch(() => route.query.event, (newId) => {
<i class="fa-regular fa-clock"></i> <i class="fa-regular fa-clock"></i>
最后同步: {{ lastSyncText }} 最后同步: {{ lastSyncText }}
</span> </span>
<span v-if="stats.error_tasks_today > 0" class="error-count"> <!-- <span v-if="stats.error_tasks_today > 0" class="error-count">
<i class="fa-solid fa-triangle-exclamation"></i> <i class="fa-solid fa-triangle-exclamation"></i>
{{ stats.error_tasks_today }} 个异常 {{ stats.error_tasks_today }} 个异常
</span> </span> -->
</div> </div>
</section> </section>
</div> </div>
@@ -889,9 +880,6 @@ watch(() => route.query.event, (newId) => {
margin-top: 6px; margin-top: 6px;
} }
/* ==========================================
网格布局
========================================== */
.content-grid { .content-grid {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -923,9 +911,6 @@ watch(() => route.query.event, (newId) => {
} }
} }
/* ==========================================
区域标题 + 热度阈值 (高级磨砂透明风)
========================================== */
.section-header { .section-header {
margin-bottom: 24px; margin-bottom: 24px;
} }
@@ -1015,9 +1000,6 @@ watch(() => route.query.event, (newId) => {
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
/* ==========================================
事件卡片
========================================== */
/* 事件卡片,加入毛玻璃与高级阴影 */ /* 事件卡片,加入毛玻璃与高级阴影 */
.event-card { .event-card {
background: var(--bg-surface); background: var(--bg-surface);
@@ -1134,9 +1116,6 @@ watch(() => route.query.event, (newId) => {
color: transparent; color: transparent;
} }
/* ==========================================
平台列表 + 悬停排名图
========================================== */
.platforms-list { .platforms-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1254,9 +1233,6 @@ watch(() => route.query.event, (newId) => {
max-height: 120px; max-height: 120px;
} }
/* ==========================================
加载更多
========================================== */
.load-more-wrapper { .load-more-wrapper {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1302,9 +1278,6 @@ watch(() => route.query.event, (newId) => {
color: var(--text-placeholder); color: var(--text-placeholder);
} }
/* ==========================================
小组件面板(通用)- 玻璃拟态高级质感
========================================== */
.widget-panel { .widget-panel {
background: var(--bg-surface); background: var(--bg-surface);
backdrop-filter: var(--backdrop-blur); backdrop-filter: var(--backdrop-blur);
@@ -1398,9 +1371,6 @@ watch(() => route.query.event, (newId) => {
font-size: 13px; font-size: 13px;
} }
/* ==========================================
为你推荐面板
========================================== */
.recommend-header { .recommend-header {
background: rgba(139, 92, 246, 0.06); background: rgba(139, 92, 246, 0.06);
border-bottom-color: rgba(139, 92, 246, 0.15); border-bottom-color: rgba(139, 92, 246, 0.15);
@@ -1576,9 +1546,6 @@ watch(() => route.query.event, (newId) => {
font-size: 9px; font-size: 9px;
} }
/* ==========================================
公关修改追踪
========================================== */
.revision-header { .revision-header {
background: rgba(239, 68, 68, 0.06); background: rgba(239, 68, 68, 0.06);
border-bottom-color: rgba(239, 68, 68, 0.15); border-bottom-color: rgba(239, 68, 68, 0.15);
@@ -1679,9 +1646,6 @@ watch(() => route.query.event, (newId) => {
margin: 0; margin: 0;
} }
/* ==========================================
系统状态
========================================== */
.stats-widget { .stats-widget {
padding: 16px; padding: 16px;
} }
@@ -1751,9 +1715,6 @@ watch(() => route.query.event, (newId) => {
color: var(--status-error); color: var(--status-error);
} }
/* ==========================================
聚光灯区块
========================================== */
.spotlight-wrap { .spotlight-wrap {
margin-bottom: 20px; margin-bottom: 20px;
} }
-28
View File
@@ -1,4 +1,3 @@
<!-- 推送设置页管理推送时间表与推送渠道邮箱等 -->
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed } from 'vue' import { onMounted, ref, computed } from 'vue'
@@ -62,9 +61,6 @@ async function loadConfig() {
} }
} }
// ==========================================
// 推送时间表操作
// ==========================================
async function handleAddSchedule() { async function handleAddSchedule() {
if (!userId.value || !newTime.value) return if (!userId.value || !newTime.value) return
submittingSchedule.value = true submittingSchedule.value = true
@@ -109,9 +105,6 @@ async function handleDeleteSchedule(schedule: DeliverySchedule) {
} }
} }
// ==========================================
// 推送渠道操作
// ==========================================
async function handleAddEndpoint() { async function handleAddEndpoint() {
if (!userId.value || !newChannelAccount.value.trim()) return if (!userId.value || !newChannelAccount.value.trim()) return
submittingEndpoint.value = true submittingEndpoint.value = true
@@ -186,9 +179,6 @@ onMounted(loadConfig)
</div> </div>
<div v-else class="config-sections"> <div v-else class="config-sections">
<!-- ==========================================
推送时间管理
========================================== -->
<section class="config-section"> <section class="config-section">
<div class="section-title"> <div class="section-title">
<h2><i class="fa-regular fa-clock"></i> 推送时间</h2> <h2><i class="fa-regular fa-clock"></i> 推送时间</h2>
@@ -229,9 +219,6 @@ onMounted(loadConfig)
</div> </div>
</section> </section>
<!-- ==========================================
推送渠道管理
========================================== -->
<section class="config-section"> <section class="config-section">
<div class="section-title"> <div class="section-title">
<h2><i class="fa-solid fa-envelope"></i> 接收邮箱</h2> <h2><i class="fa-solid fa-envelope"></i> 接收邮箱</h2>
@@ -374,9 +361,6 @@ onMounted(loadConfig)
color: var(--text-secondary); color: var(--text-secondary);
} }
/* ==========================================
通用区块样式
========================================== */
.config-sections { .config-sections {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -418,9 +402,6 @@ onMounted(loadConfig)
margin: 0; margin: 0;
} }
/* ==========================================
添加行
========================================== */
.add-row { .add-row {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -497,9 +478,6 @@ onMounted(loadConfig)
font-size: 13px; font-size: 13px;
} }
/* ==========================================
时间表列表
========================================== */
.schedule-list { .schedule-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -573,9 +551,6 @@ onMounted(loadConfig)
background: rgba(239, 68, 68, 0.1); background: rgba(239, 68, 68, 0.1);
} }
/* ==========================================
渠道列表
========================================== */
.endpoint-add { .endpoint-add {
flex-wrap: wrap; flex-wrap: wrap;
} }
@@ -661,9 +636,6 @@ onMounted(loadConfig)
gap: 6px; gap: 6px;
} }
/* ==========================================
工作原理说明
========================================== */
.info-section { .info-section {
background: transparent; background: transparent;
border: 1px dashed var(--border-subtle); border: 1px dashed var(--border-subtle);
+1 -2
View File
@@ -1,4 +1,3 @@
<!-- 概览页展示当前账户会话状态认证接入说明 -->
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -31,7 +30,7 @@ async function handleLogout() {
<div class="nav-brand"> <div class="nav-brand">
<div class="logo"> <div class="logo">
<BrandLogo /> <BrandLogo />
InsightRadar 聚势智见
</div> </div>
</div> </div>
<div class="nav-actions"> <div class="nav-actions">
+5 -9
View File
@@ -1,4 +1,3 @@
<!-- 登录页支持密码登录与邮箱验证码登录 -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, reactive, ref, watch } from 'vue' import { computed, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
@@ -150,13 +149,13 @@ onUnmounted(() => {
<div class="brand-content"> <div class="brand-content">
<div class="logo"> <div class="logo">
<BrandLogo /> <BrandLogo />
InsightRadar 聚势智见
</div> </div>
<h1 class="brand-title">洞察全网热点<br />让信息更聚焦</h1> <h1 class="brand-title">洞察全网热点<br />让信息更聚焦</h1>
<p class="brand-desc"> <p class="brand-desc">
聚合多平台趋势自动完成热点归并与摘要你可以用密码登录也可以直接使用邮箱验证码快速登录 聚合多平台趋势自动完成热点归并与摘要你可以用密码登录也可以直接使用邮箱验证码快速登录
</p> </p>
<div class="feature-list"> <div class="feature-list">
<div class="feature-item"> <div class="feature-item">
<div class="feature-icon">🚀</div> <div class="feature-icon">🚀</div>
@@ -192,7 +191,7 @@ onUnmounted(() => {
<div class="form-container"> <div class="form-container">
<div class="form-header"> <div class="form-header">
<h2>欢迎回来</h2> <h2>欢迎回来</h2>
<p>登录后继续查看 InsightRadar 实时动态</p> <p>登录后继续查看 聚势智见 实时动态</p>
</div> </div>
<div class="login-mode-tabs"> <div class="login-mode-tabs">
@@ -287,7 +286,7 @@ onUnmounted(() => {
<button class="btn-primary" :disabled="authStore.loading" type="submit"> <button class="btn-primary" :disabled="authStore.loading" type="submit">
{{ isSubmitting ? '登录中...' : (loginMode === 'password' ? '密码登录' : '邮箱验证码登录') }} {{ isSubmitting ? '登录中...' : (loginMode === 'password' ? '密码登录' : '邮箱验证码登录') }}
</button> </button>
<button type="button" class="btn-primary guest-btn" @click="router.push('/')" style="margin-top: 12px; background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border-subtle);"> <button type="button" class="btn-primary guest-btn" @click="router.push('/')" style="margin-top: 12px; background: var(--bg-input); color: var(--text-primary); border: 1px solid var(--border-subtle);">
以游客身份体验 以游客身份体验
</button> </button>
@@ -303,9 +302,6 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
/* ==========================================
全新高级分屏布局与背景
========================================== */
.split-layout { .split-layout {
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
@@ -330,7 +326,7 @@ onUnmounted(() => {
left: -50%; left: -50%;
right: -50%; right: -50%;
bottom: -50%; bottom: -50%;
background-image: background-image:
linear-gradient(rgba(128, 128, 128, 0.15) 1px, transparent 1px), linear-gradient(rgba(128, 128, 128, 0.15) 1px, transparent 1px),
linear-gradient(90deg, rgba(128, 128, 128, 0.15) 1px, transparent 1px); linear-gradient(90deg, rgba(128, 128, 128, 0.15) 1px, transparent 1px);
background-size: 32px 32px; background-size: 32px 32px;
+2 -6
View File
@@ -1,4 +1,3 @@
<!-- 注册页邮箱验证码 + 密码带密码强度提示 -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, reactive, ref } from 'vue' import { computed, onUnmounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@@ -131,7 +130,7 @@ onUnmounted(() => {
<div class="brand-content"> <div class="brand-content">
<div class="logo"> <div class="logo">
<BrandLogo /> <BrandLogo />
InsightRadar 聚势智见
</div> </div>
<h1 class="brand-title">开启智能<br />分析之旅</h1> <h1 class="brand-title">开启智能<br />分析之旅</h1>
<p class="brand-desc"> <p class="brand-desc">
@@ -280,9 +279,6 @@ onUnmounted(() => {
</template> </template>
<style scoped> <style scoped>
/* ==========================================
全新高级分屏布局与背景
========================================== */
.split-layout { .split-layout {
display: flex; display: flex;
min-height: 100vh; min-height: 100vh;
@@ -306,7 +302,7 @@ onUnmounted(() => {
content: ""; content: "";
position: absolute; position: absolute;
top: -50%; left: -50%; right: -50%; bottom: -50%; top: -50%; left: -50%; right: -50%; bottom: -50%;
background-image: background-image:
linear-gradient(rgba(128, 128, 128, 0.15) 1px, transparent 1px), linear-gradient(rgba(128, 128, 128, 0.15) 1px, transparent 1px),
linear-gradient(90deg, rgba(128, 128, 128, 0.15) 1px, transparent 1px); linear-gradient(90deg, rgba(128, 128, 128, 0.15) 1px, transparent 1px);
background-size: 32px 32px; background-size: 32px 32px;
+7 -8
View File
@@ -1,4 +1,3 @@
<!-- 公关修改追踪页展示热搜标题被偷偷修改的历史记录 -->
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, reactive } from 'vue' import { computed, onMounted, ref, reactive } from 'vue'
@@ -88,8 +87,8 @@ const revisionChains = computed<RevisionChain[]>(() => {
// 组内按时间升序 // 组内按时间升序
items.sort((a, b) => safeParseTime(a.created_at) - safeParseTime(b.created_at)) items.sort((a, b) => safeParseTime(a.created_at) - safeParseTime(b.created_at))
const first = items[0] const first = items[0]!
const last = items[items.length - 1] const last = items[items.length - 1]!
// 拼接标题链,避免相邻记录重复 // 拼接标题链,避免相邻记录重复
const titles: string[] = [first.previous_headline] const titles: string[] = [first.previous_headline]
@@ -107,13 +106,13 @@ const revisionChains = computed<RevisionChain[]>(() => {
chains.push({ chains.push({
event_id, event_id,
source_name: first.source_name, source_name: first.source_name!,
titles, titles,
change_times, change_times,
first_at: first.created_at, first_at: first.created_at!,
last_at: last.created_at, last_at: last.created_at!,
change_count: items.length, change_count: items.length,
url: first.url, url: first.url!,
}) })
} }
@@ -242,7 +241,7 @@ onMounted(loadRevisions)
</span> </span>
<p class="chain-title-text">{{ title }}</p> <p class="chain-title-text">{{ title }}</p>
<span v-if="idx < chain.change_times.length" class="chain-step-time"> <span v-if="idx < chain.change_times.length" class="chain-step-time">
{{ formatTime(chain.change_times[idx]) }} {{ formatTime(chain.change_times[idx] ?? '') }}
</span> </span>
</div> </div>
<div v-if="idx < chain.titles.length - 1" class="chain-arrow"> <div v-if="idx < chain.titles.length - 1" class="chain-arrow">
+60 -16
View File
@@ -1,4 +1,3 @@
<!-- 事件追踪分析页关键词搜索时间热度图表关联事件列表 -->
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import VueApexCharts from 'vue3-apexcharts' import VueApexCharts from 'vue3-apexcharts'
@@ -6,6 +5,7 @@ import { searchEventsTimeline } from '@/api/events'
import type { SearchTimelineResponse } from '@/types/event' import type { SearchTimelineResponse } from '@/types/event'
import UnifiedEventCard from '@/components/UnifiedEventCard.vue' import UnifiedEventCard from '@/components/UnifiedEventCard.vue'
import CustomSelect from '@/components/CustomSelect.vue' import CustomSelect from '@/components/CustomSelect.vue'
import type { ApexOptions } from 'apexcharts'
const keyword = ref('') const keyword = ref('')
const searchResult = ref<SearchTimelineResponse | null>(null) const searchResult = ref<SearchTimelineResponse | null>(null)
@@ -56,7 +56,7 @@ const filteredEvents = computed(() => {
}) })
// 热度时间线图表配置。 // 热度时间线图表配置。
const chartOptions = ref({ const chartOptions = ref<ApexOptions>({
chart: { chart: {
type: 'area', type: 'area',
height: 350, height: 350,
@@ -66,12 +66,18 @@ const chartOptions = ref({
}, },
animations: { animations: {
enabled: true, enabled: true,
easing: 'easeinout', // easing: 'easeinout',
speed: 800, speed: 800,
}, },
// 点击图表数据点:切换选中时间,再次点击则取消筛选 // 点击图表数据点:切换选中时间,再次点击则取消筛选
events: { events: {
markerClick: function(event: any, chartContext: any, { dataPointIndex }: any) { mounted: (chartContext: any) => {
chartContext.el?.querySelector('.apexcharts-svg > title')?.remove()
},
updated: (chartContext: any) => {
chartContext.el?.querySelector('.apexcharts-svg > title')?.remove()
},
markerClick: function(event: unknown, chartContext: unknown, { dataPointIndex }: never) {
if (searchResult.value && searchResult.value.timeline[dataPointIndex]) { if (searchResult.value && searchResult.value.timeline[dataPointIndex]) {
const clickedTime = searchResult.value.timeline[dataPointIndex].time_label const clickedTime = searchResult.value.timeline[dataPointIndex].time_label
if (selectedTimeLabel.value === clickedTime) { if (selectedTimeLabel.value === clickedTime) {
@@ -228,17 +234,17 @@ async function handleSearch() {
<div class="tips-box glass-panel"> <div class="tips-box glass-panel">
<h2 class="panel-title"><i class="fa-regular fa-lightbulb"></i> 搜索建议</h2> <h2 class="panel-title"><i class="fa-regular fa-lightbulb"></i> 搜索建议</h2>
<div class="tips-content"> <div class="tips-content">
<button class="tip-tag" @click="keyword='新能源汽车'; hours=168; handleSearch()"> <button class="tip-tag" @click="keyword='火箭发射'; hours=168; handleSearch()">
<i class="fa-solid fa-rocket"></i> 新能源汽车 <i class="fa-solid fa-rocket"></i> 火箭发射
</button> </button>
<button class="tip-tag" @click="keyword='苹果公司'; hours=168; handleSearch()"> <button class="tip-tag" @click="keyword='苹果公司'; hours=168; handleSearch()">
<i class="fa-brands fa-apple"></i> 苹果产业链 <i class="fa-brands fa-apple"></i> 苹果公司
</button> </button>
<button class="tip-tag regex-tag" @click="keyword='AI|LLM'; hours=168; handleSearch()"> <button class="tip-tag regex-tag" @click="keyword='AI|LLM'; hours=168; handleSearch()">
<i class="fa-solid fa-code-branch"></i> AI / 大模型 <i class="fa-solid fa-code-branch"></i> AI / 大模型
</button> </button>
<button class="tip-tag regex-tag" @click="keyword='美国关税'; hours=168; handleSearch()"> <button class="tip-tag regex-tag" @click="keyword='美国'; hours=168; handleSearch()">
<i class="fa-solid fa-flag-usa"></i> 美国关税 <i class="fa-solid fa-flag-usa"></i> 美国
</button> </button>
</div> </div>
</div> </div>
@@ -254,9 +260,15 @@ async function handleSearch() {
<div v-else-if="searchResult" class="results-container"> <div v-else-if="searchResult" class="results-container">
<section class="chart-section glass-panel"> <section class="chart-section glass-panel">
<div class="section-header"> <div class="section-header">
<h2 class="section-title"> <div class="section-title-group">
<i class="fa-solid fa-wave-square"></i> 时间热度脉络 <h2 class="section-title">
</h2> <i class="fa-solid fa-wave-square"></i> 时间热度脉络
</h2>
<span class="chart-tip">
<i class="fa-solid fa-hand-pointer"></i>
点击时间点查看具体事件列表
</span>
</div>
<span class="meta-info"> {{ searchResult.timeline.length }} 个时间节点 · 覆盖 {{ searchResult.events.length }} 个聚合事件</span> <span class="meta-info"> {{ searchResult.timeline.length }} 个时间节点 · 覆盖 {{ searchResult.events.length }} 个聚合事件</span>
</div> </div>
@@ -368,7 +380,7 @@ async function handleSearch() {
gap: 24px; gap: 24px;
align-items: stretch; align-items: stretch;
position: relative; position: relative;
z-index: 10; z-index: 10;
} }
.search-box { .search-box {
@@ -378,7 +390,7 @@ async function handleSearch() {
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
position: relative; position: relative;
z-index: 2; z-index: 2;
} }
.tips-box { .tips-box {
@@ -546,6 +558,30 @@ async function handleSearch() {
color: var(--brand-primary); color: var(--brand-primary);
} }
.section-title-group {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.chart-tip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: var(--radius-md);
background: var(--brand-primary-alpha);
border: 1px solid rgba(99, 102, 241, 0.2);
color: var(--brand-primary);
font-size: 12px;
font-weight: 600;
}
.chart-tip i {
font-size: 12px;
}
.time-filter-badge { .time-filter-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -584,7 +620,16 @@ async function handleSearch() {
.chart-container { .chart-container {
margin-top: 16px; margin-top: 16px;
margin-left: -10px; /* 视觉上抵消 apexcharts 的默认左侧留白。 */ margin-left: -10px;
}
.chart-container :deep(svg),
.chart-container :deep(canvas) {
outline: none;
}
.chart-container :deep(.apexcharts-marker) {
cursor: pointer;
} }
.events-section { .events-section {
@@ -594,7 +639,6 @@ async function handleSearch() {
.events-grid { .events-grid {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* 与 DashboardView 保持一致,列表按纵向堆叠展示。 */
} }
.loading-state { .loading-state {
+3 -4
View File
@@ -1,4 +1,3 @@
<!-- 兴趣关键词页添加/删除关键词查看命中事件 -->
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref, computed } from 'vue' import { onMounted, ref, computed } from 'vue'
@@ -64,10 +63,10 @@ async function loadMatchedEvents() {
loadingMatched.value = true loadingMatched.value = true
matchedError.value = '' matchedError.value = ''
try { try {
const result = await fetchRecommendedEvents(userId.value, { const result = await fetchRecommendedEvents(userId.value, {
limit: 30, limit: 30,
hours: hoursRange.value, hours: hoursRange.value,
sort_by: sortBy.value sort_by: sortBy.value
}) })
matchedEvents.value = result.data matchedEvents.value = result.data
} catch (e) { } catch (e) {
@@ -156,7 +155,7 @@ onMounted(async () => {
v-model="newKeyword" v-model="newKeyword"
type="text" type="text"
class="keyword-input" class="keyword-input"
placeholder="输入关键词,如「直升机」「科比」「佐巴扬」..." placeholder="输入关键词,如「篮球」「科比」「科技」..."
maxlength="100" maxlength="100"
@keydown="onInputKeydown" @keydown="onInputKeydown"
/> />
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfig({
strictPort: true, strictPort: true,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://10.252.130.135:8000', target: 'http://localhost:8000',
changeOrigin: true, changeOrigin: true,
}, },
}, },