|
|
@@ -0,0 +1,288 @@
|
|
|
+"""文章域 DB 读写 —— demand_search_article_relation + demand_search_article_detail"""
|
|
|
+
|
|
|
+import json
|
|
|
+from typing import Dict, List, Optional
|
|
|
+
|
|
|
+from src.infra.database.mysql.manager import MysqlManager
|
|
|
+
|
|
|
+from ._const import DemandSearchArticleConst
|
|
|
+
|
|
|
+
|
|
|
+class ArticleSearchRelationMapper(DemandSearchArticleConst):
|
|
|
+ """demand_search_article_relation + demand_search_queue 的读写"""
|
|
|
+
|
|
|
+ def __init__(self, pool: MysqlManager):
|
|
|
+ self.pool = pool
|
|
|
+
|
|
|
+ # ═══════════════════════════════════════════════════════════════
|
|
|
+ # demand_search_queue 读取 & 状态更新
|
|
|
+ # ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ async def fetch_queue_pending(self, *, limit: int = 500) -> List[Dict]:
|
|
|
+ """取待搜索的队列项,按 match_score 降序"""
|
|
|
+ query = f"""
|
|
|
+ SELECT *
|
|
|
+ FROM {self.QUEUE_TABLE}
|
|
|
+ WHERE status = %s
|
|
|
+ ORDER BY match_score DESC
|
|
|
+ LIMIT %s
|
|
|
+ """
|
|
|
+ return await self.pool.fetch(
|
|
|
+ query=query,
|
|
|
+ params=(self.QueueStatus.INIT, limit),
|
|
|
+ )
|
|
|
+
|
|
|
+ async def queue_mark_processing(self, ids: List[int]) -> int:
|
|
|
+ """批量标记队列项为处理中"""
|
|
|
+ if not ids:
|
|
|
+ return 0
|
|
|
+ placeholders = ",".join(["%s"] * len(ids))
|
|
|
+ query = f"""
|
|
|
+ UPDATE {self.QUEUE_TABLE}
|
|
|
+ SET status = %s
|
|
|
+ WHERE id IN ({placeholders})
|
|
|
+ """
|
|
|
+ return await self.pool.save(
|
|
|
+ query=query,
|
|
|
+ params=(self.QueueStatus.PROCESSING, *ids),
|
|
|
+ )
|
|
|
+
|
|
|
+ async def queue_mark_done(self, item_id: int) -> int:
|
|
|
+ """标记队列项为成功"""
|
|
|
+ query = f"""
|
|
|
+ UPDATE {self.QUEUE_TABLE}
|
|
|
+ SET status = %s
|
|
|
+ WHERE id = %s
|
|
|
+ """
|
|
|
+ return await self.pool.save(
|
|
|
+ query=query, params=(self.QueueStatus.SUCCESS, item_id),
|
|
|
+ )
|
|
|
+
|
|
|
+ async def queue_mark_failed(self, item_id: int, reason: str = "") -> int:
|
|
|
+ """标记队列项为失败"""
|
|
|
+ query = f"""
|
|
|
+ UPDATE {self.QUEUE_TABLE}
|
|
|
+ SET status = %s, fail_reason = %s
|
|
|
+ WHERE id = %s
|
|
|
+ """
|
|
|
+ return await self.pool.save(
|
|
|
+ query=query,
|
|
|
+ params=(self.QueueStatus.FAIL, reason[:512], item_id),
|
|
|
+ )
|
|
|
+
|
|
|
+ # ═══════════════════════════════════════════════════════════════
|
|
|
+ # relation 表 — 写入
|
|
|
+ # ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ async def insert_batch(self, items: List[Dict]) -> int:
|
|
|
+ """批量写入搜索结果,返回受影响行数
|
|
|
+
|
|
|
+ items 每条须包含:
|
|
|
+ search_key, key_type, experiment_id, search_cursor,
|
|
|
+ url, wx_sn, title, cover_url, publish_time
|
|
|
+ """
|
|
|
+ if not items:
|
|
|
+ return 0
|
|
|
+ query = f"""
|
|
|
+ INSERT IGNORE INTO {self.RELATION_TABLE}
|
|
|
+ (search_key, key_type, experiment_id, search_cursor,
|
|
|
+ url, wx_sn, title, cover_url, publish_time)
|
|
|
+ VALUES
|
|
|
+ (%s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
+ """
|
|
|
+ params = [
|
|
|
+ (
|
|
|
+ it["search_key"],
|
|
|
+ it["key_type"],
|
|
|
+ it["experiment_id"],
|
|
|
+ it["search_cursor"],
|
|
|
+ it["url"],
|
|
|
+ it["wx_sn"],
|
|
|
+ it["title"],
|
|
|
+ it["cover_url"],
|
|
|
+ it["publish_time"],
|
|
|
+ )
|
|
|
+ for it in items
|
|
|
+ ]
|
|
|
+ return await self.pool.save(query=query, params=params, batch=True)
|
|
|
+
|
|
|
+ # ═══════════════════════════════════════════════════════════════
|
|
|
+ # 读取
|
|
|
+ # ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ async def fetch_pending(
|
|
|
+ self,
|
|
|
+ experiment_id: str,
|
|
|
+ *,
|
|
|
+ limit: int = 500,
|
|
|
+ ) -> List[Dict]:
|
|
|
+ """查询待拉详情的行 (status=0)"""
|
|
|
+ query = f"""
|
|
|
+ SELECT *
|
|
|
+ FROM {self.RELATION_TABLE}
|
|
|
+ WHERE experiment_id = %s AND status = %s
|
|
|
+ LIMIT %s
|
|
|
+ """
|
|
|
+ return await self.pool.fetch(
|
|
|
+ query=query,
|
|
|
+ params=(experiment_id, self.SearchStatus.INIT, limit),
|
|
|
+ )
|
|
|
+
|
|
|
+ async def count_by_status(
|
|
|
+ self,
|
|
|
+ experiment_id: str,
|
|
|
+ status: int,
|
|
|
+ ) -> int:
|
|
|
+ """统计指定状态的条目数"""
|
|
|
+ query = f"""
|
|
|
+ SELECT COUNT(*) AS cnt
|
|
|
+ FROM {self.RELATION_TABLE}
|
|
|
+ WHERE experiment_id = %s AND status = %s
|
|
|
+ """
|
|
|
+ row = await self.pool.fetch_one(query=query, params=(experiment_id, status))
|
|
|
+ return row["cnt"] if row else 0
|
|
|
+
|
|
|
+ # ═══════════════════════════════════════════════════════════════
|
|
|
+ # 状态更新
|
|
|
+ # ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ async def mark_processing(self, ids: List[int]) -> int:
|
|
|
+ """批量标记为拉取中 (0→1)"""
|
|
|
+ if not ids:
|
|
|
+ return 0
|
|
|
+ placeholders = ",".join(["%s"] * len(ids))
|
|
|
+ query = f"""
|
|
|
+ UPDATE {self.RELATION_TABLE}
|
|
|
+ SET status = %s
|
|
|
+ WHERE id IN ({placeholders}) AND status = %s
|
|
|
+ """
|
|
|
+ return await self.pool.save(
|
|
|
+ query=query,
|
|
|
+ params=(self.SearchStatus.PROCESSING, *ids, self.SearchStatus.INIT),
|
|
|
+ )
|
|
|
+
|
|
|
+ async def mark_success(self, item_id: int, *, channel_content_id: str = "") -> int:
|
|
|
+ """标记为成功,回填 channel_content_id (→2)"""
|
|
|
+ query = f"""
|
|
|
+ UPDATE {self.RELATION_TABLE}
|
|
|
+ SET status = %s, channel_content_id = %s
|
|
|
+ WHERE id = %s
|
|
|
+ """
|
|
|
+ return await self.pool.save(
|
|
|
+ query=query,
|
|
|
+ params=(self.SearchStatus.SUCCESS, channel_content_id, item_id),
|
|
|
+ )
|
|
|
+
|
|
|
+ async def mark_failed(self, ids: List[int]) -> int:
|
|
|
+ """批量标记为失败 (→99)"""
|
|
|
+ if not ids:
|
|
|
+ return 0
|
|
|
+ placeholders = ",".join(["%s"] * len(ids))
|
|
|
+ query = f"""
|
|
|
+ UPDATE {self.RELATION_TABLE}
|
|
|
+ SET status = %s
|
|
|
+ WHERE id IN ({placeholders})
|
|
|
+ """
|
|
|
+ return await self.pool.save(
|
|
|
+ query=query,
|
|
|
+ params=(self.SearchStatus.FAIL, *ids),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+ # ═══════════════════════════════════════════════════════════════
|
|
|
+ # 搜索缓存
|
|
|
+ # ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ async def cache_get(self, keyword: str, page: int) -> Optional[Dict]:
|
|
|
+ """读取缓存 (keyword, page),TTL 过期返回 None"""
|
|
|
+ query = f"""
|
|
|
+ SELECT response
|
|
|
+ FROM {self.CACHE_TABLE}
|
|
|
+ WHERE keyword = %s
|
|
|
+ AND page = %s
|
|
|
+ AND updated_at > DATE_SUB(NOW(), INTERVAL %s DAY)
|
|
|
+ """
|
|
|
+ row = await self.pool.fetch_one(
|
|
|
+ query=query,
|
|
|
+ params=(keyword, page, self.CACHE_TTL_DAYS),
|
|
|
+ )
|
|
|
+ if not row:
|
|
|
+ return None
|
|
|
+ resp = row["response"]
|
|
|
+ if isinstance(resp, str):
|
|
|
+ return json.loads(resp)
|
|
|
+ return resp
|
|
|
+
|
|
|
+ async def cache_set(
|
|
|
+ self, keyword: str, page: int, response: Dict,
|
|
|
+ ) -> int:
|
|
|
+ """写入缓存(upsert)"""
|
|
|
+ query = f"""
|
|
|
+ INSERT INTO {self.CACHE_TABLE} (keyword, page, response)
|
|
|
+ VALUES (%s, %s, %s)
|
|
|
+ ON DUPLICATE KEY UPDATE
|
|
|
+ response = VALUES(response),
|
|
|
+ updated_at = CURRENT_TIMESTAMP
|
|
|
+ """
|
|
|
+ return await self.pool.save(
|
|
|
+ query=query,
|
|
|
+ params=(keyword, page, json.dumps(response, ensure_ascii=False)),
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+class ArticleDetailMapper(DemandSearchArticleConst):
|
|
|
+ """demand_search_article_detail 表的数据访问层"""
|
|
|
+
|
|
|
+ def __init__(self, pool: MysqlManager):
|
|
|
+ self.pool = pool
|
|
|
+
|
|
|
+ # ═══════════════════════════════════════════════════════════════
|
|
|
+ # 写入
|
|
|
+ # ═══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ async def insert_one(self, detail: Dict) -> int:
|
|
|
+ """写入单篇详情,channel_content_id 冲突则跳过
|
|
|
+
|
|
|
+ detail 须包含:
|
|
|
+ channel_content_id, wx_sn, url, title, body_text, content_type,
|
|
|
+ channel_account_id, publish_timestamp, publish_at, is_original,
|
|
|
+ view_count, like_count, looking_count, comment_count, share_count,
|
|
|
+ image_url_list, video_url_list, is_cache, extra
|
|
|
+ """
|
|
|
+ query = f"""
|
|
|
+ INSERT IGNORE INTO {self.DETAIL_TABLE}
|
|
|
+ (channel_content_id, wx_sn, url, title, body_text, content_type,
|
|
|
+ channel_account_id, publish_timestamp, publish_at, is_original,
|
|
|
+ view_count, like_count, looking_count, comment_count, share_count,
|
|
|
+ image_url_list, video_url_list, is_cache, extra)
|
|
|
+ VALUES
|
|
|
+ (%s, %s, %s, %s, %s, %s,
|
|
|
+ %s, %s, %s, %s,
|
|
|
+ %s, %s, %s, %s, %s,
|
|
|
+ %s, %s, %s, %s)
|
|
|
+ """
|
|
|
+ params = (
|
|
|
+ detail["channel_content_id"],
|
|
|
+ detail["wx_sn"],
|
|
|
+ detail.get("url", ""),
|
|
|
+ detail.get("title", ""),
|
|
|
+ detail.get("body_text", ""),
|
|
|
+ detail.get("content_type", ""),
|
|
|
+ detail.get("channel_account_id", ""),
|
|
|
+ detail.get("publish_timestamp", 0),
|
|
|
+ detail.get("publish_at"),
|
|
|
+ detail.get("is_original", 0),
|
|
|
+ detail.get("view_count", 0),
|
|
|
+ detail.get("like_count", 0),
|
|
|
+ detail.get("looking_count", 0),
|
|
|
+ detail.get("comment_count", 0),
|
|
|
+ detail.get("share_count", 0),
|
|
|
+ json.dumps(detail.get("image_url_list") or [], ensure_ascii=False),
|
|
|
+ json.dumps(detail.get("video_url_list") or [], ensure_ascii=False),
|
|
|
+ detail.get("is_cache", 0),
|
|
|
+ json.dumps(detail.get("extra") or {}, ensure_ascii=False),
|
|
|
+ )
|
|
|
+ return await self.pool.save(query=query, params=params)
|
|
|
+
|
|
|
+
|
|
|
+__all__ = ["ArticleSearchRelationMapper", "ArticleDetailMapper"]
|