improvement(wrapped): 概览卡片补充年度新增好友统计

- 后端 Card#0 增加 addedFriends 字段(基于系统消息关键字 best-effort 识别新好友)

- 前端概览文案在 addedFriends>0 时展示新增好友数

- bump wrapped cache version,避免旧缓存导致字段缺失
This commit is contained in:
2977094657
2026-02-04 16:25:29 +08:00
parent 2f09aa3dcf
commit 94e6e89f35
3 changed files with 82 additions and 3 deletions

View File

@@ -18,6 +18,11 @@
在与你相伴的 在与你相伴的
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(activeDays) }}</span> <span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(activeDays) }}</span>
天里 天里
<template v-if="addedFriends > 0">
你总共加了
<span class="wrapped-number text-[#07C160] font-semibold">{{ formatInt(addedFriends) }}</span>
位好友
</template>
<template v-if="mostActiveHour !== null && mostActiveWeekdayName"> <template v-if="mostActiveHour !== null && mostActiveWeekdayName">
你最常在 {{ mostActiveWeekdayName }} 你最常在 {{ mostActiveWeekdayName }}
<span class="wrapped-number text-[#07C160] font-semibold">{{ mostActiveHour }}</span> <span class="wrapped-number text-[#07C160] font-semibold">{{ mostActiveHour }}</span>
@@ -116,6 +121,7 @@ const formatFloat = (n, digits = 1) => {
const totalMessages = computed(() => Number(props.card?.data?.totalMessages || 0)) const totalMessages = computed(() => Number(props.card?.data?.totalMessages || 0))
const activeDays = computed(() => Number(props.card?.data?.activeDays || 0)) const activeDays = computed(() => Number(props.card?.data?.activeDays || 0))
const addedFriends = computed(() => Number(props.card?.data?.addedFriends || 0))
const messagesPerDay = computed(() => Number(props.card?.data?.messagesPerDay || 0)) const messagesPerDay = computed(() => Number(props.card?.data?.messagesPerDay || 0))
const mostActiveHour = computed(() => { const mostActiveHour = computed(() => {

View File

@@ -21,6 +21,7 @@ from ...chat_helpers import (
_pick_display_name, _pick_display_name,
_quote_ident, _quote_ident,
_should_keep_session, _should_keep_session,
_to_char_token_text,
) )
from ...logging_config import get_logger from ...logging_config import get_logger
@@ -28,12 +29,22 @@ logger = get_logger(__name__)
_MD5_HEX_RE = re.compile(r"(?i)[0-9a-f]{32}") _MD5_HEX_RE = re.compile(r"(?i)[0-9a-f]{32}")
# Best-effort heuristics for "new friends added" detection: WeChat system messages vary by version.
_ADDED_FRIEND_PATTERNS: tuple[str, ...] = (
"你已添加了",
"你添加了",
"现在可以开始聊天了",
"以上是打招呼的消息",
"通过了你的朋友验证",
"通过你的朋友验证",
)
@dataclass(frozen=True) @dataclass(frozen=True)
class GlobalOverviewStats: class GlobalOverviewStats:
year: int year: int
active_days: int active_days: int
added_friends: int
local_type_counts: dict[int, int] local_type_counts: dict[int, int]
kind_counts: dict[str, int] kind_counts: dict[str, int]
latest_ts: int latest_ts: int
@@ -349,6 +360,37 @@ def compute_global_overview_stats(
top_group = pick_top(group_counts_i) top_group = pick_top(group_counts_i)
top_phrase = pick_top(phrase_counts_i) top_phrase = pick_top(phrase_counts_i)
# New friends added in this year (best-effort via WeChat system messages).
added_friend_usernames: set[str] = set()
try:
like_patterns: list[str] = []
for pat in _ADDED_FRIEND_PATTERNS:
tok = _to_char_token_text(pat)
if tok:
like_patterns.append(f"%{tok}%")
if like_patterns:
where_added = f"{ts_expr} >= ? AND {ts_expr} < ? AND db_stem NOT LIKE 'biz_message%'"
cond_added = " OR ".join(['\"text\" LIKE ?'] * len(like_patterns))
rows_added = conn.execute(
f"SELECT DISTINCT username FROM message_fts "
f"WHERE {where_added} "
"AND CAST(local_type AS INTEGER) = 10000 "
f"AND ({cond_added})",
(start_ts, end_ts, *like_patterns),
).fetchall()
for rr in rows_added:
if not rr or not rr[0]:
continue
u = str(rr[0] or "").strip()
if not u or u.endswith("@chatroom") or (not is_keep_username(u)):
continue
added_friend_usernames.add(u)
except Exception:
added_friend_usernames = set()
added_friends_i = len(added_friend_usernames)
total_messages = int(sum(local_type_counts_i.values())) total_messages = int(sum(local_type_counts_i.values()))
logger.info( logger.info(
"Wrapped card#0 overview computed (search index): account=%s year=%s total=%s active_days=%s sender=%s db=%s elapsed=%.2fs", "Wrapped card#0 overview computed (search index): account=%s year=%s total=%s active_days=%s sender=%s db=%s elapsed=%.2fs",
@@ -364,6 +406,7 @@ def compute_global_overview_stats(
return GlobalOverviewStats( return GlobalOverviewStats(
year=year, year=year,
active_days=active_days_i, active_days=active_days_i,
added_friends=added_friends_i,
local_type_counts={int(k): int(v) for k, v in local_type_counts_i.items()}, local_type_counts={int(k): int(v) for k, v in local_type_counts_i.items()},
kind_counts={str(k): int(v) for k, v in kind_counts_i.items()}, kind_counts={str(k): int(v) for k, v in kind_counts_i.items()},
latest_ts=latest_ts_i, latest_ts=latest_ts_i,
@@ -411,6 +454,8 @@ def compute_global_overview_stats(
active_days: set[str] = set() active_days: set[str] = set()
per_username_counts: Counter[str] = Counter() per_username_counts: Counter[str] = Counter()
phrase_counts: Counter[str] = Counter() phrase_counts: Counter[str] = Counter()
added_friend_usernames: set[str] = set()
added_like_patterns = [f"%{p}%" for p in _ADDED_FRIEND_PATTERNS if str(p or "").strip()]
latest_ts = 0 latest_ts = 0
@@ -425,6 +470,7 @@ def compute_global_overview_stats(
if not tables: if not tables:
continue continue
skip_sender_stats = False
sender_rowid: int | None = None sender_rowid: int | None = None
if sender: if sender:
try: try:
@@ -436,13 +482,38 @@ def compute_global_overview_stats(
sender_rowid = int(r2[0]) sender_rowid = int(r2[0])
except Exception: except Exception:
sender_rowid = None sender_rowid = None
# Can't reliably filter by sender for this shard; skip to avoid mixing directions. # Can't reliably filter by sender for this shard; skip sender-only stats to avoid mixing directions.
if sender_rowid is None: if sender_rowid is None:
continue skip_sender_stats = True
for table_name in tables: for table_name in tables:
qt = _quote_ident(table_name) qt = _quote_ident(table_name)
username = resolve_username_from_table(table_name) username = resolve_username_from_table(table_name)
# New friends added: detect common WeChat system messages within this year.
if (
added_like_patterns
and username
and (not username.endswith("@chatroom"))
and _should_keep_session(username, include_official=False)
):
cond_added = " OR ".join(["CAST(message_content AS TEXT) LIKE ?"] * len(added_like_patterns))
sql_added = (
f"SELECT 1 FROM {qt} "
f"WHERE local_type = 10000 "
f" AND {ts_expr} >= ? AND {ts_expr} < ? "
f" AND ({cond_added}) "
"LIMIT 1"
)
try:
r_added = conn.execute(sql_added, (start_ts, end_ts, *added_like_patterns)).fetchone()
except Exception:
r_added = None
if r_added is not None:
added_friend_usernames.add(username)
if skip_sender_stats:
continue
sender_where = " AND real_sender_id = ?" if sender_rowid is not None else "" sender_where = " AND real_sender_id = ?" if sender_rowid is not None else ""
params = (start_ts, end_ts, sender_rowid) if sender_rowid is not None else (start_ts, end_ts) params = (start_ts, end_ts, sender_rowid) if sender_rowid is not None else (start_ts, end_ts)
@@ -593,6 +664,7 @@ def compute_global_overview_stats(
return GlobalOverviewStats( return GlobalOverviewStats(
year=year, year=year,
active_days=len(active_days), active_days=len(active_days),
added_friends=len(added_friend_usernames),
local_type_counts={int(k): int(v) for k, v in local_type_counts.items()}, local_type_counts={int(k): int(v) for k, v in local_type_counts.items()},
kind_counts={str(k): int(v) for k, v in kind_counts.items()}, kind_counts={str(k): int(v) for k, v in kind_counts.items()},
latest_ts=int(latest_ts), latest_ts=int(latest_ts),
@@ -744,6 +816,7 @@ def build_card_00_global_overview(
"year": int(year), "year": int(year),
"totalMessages": int(heatmap.total_messages), "totalMessages": int(heatmap.total_messages),
"activeDays": int(stats.active_days), "activeDays": int(stats.active_days),
"addedFriends": int(stats.added_friends),
"messagesPerDay": messages_per_day, "messagesPerDay": messages_per_day,
"mostActiveHour": most_active_hour, "mostActiveHour": most_active_hour,
"mostActiveWeekday": most_active_weekday, "mostActiveWeekday": most_active_weekday,

View File

@@ -24,7 +24,7 @@ logger = get_logger(__name__)
# an older partial cache. # an older partial cache.
_IMPLEMENTED_UPTO_ID = 3 _IMPLEMENTED_UPTO_ID = 3
# Bump this when we change card payloads/ordering while keeping the same implemented_upto. # Bump this when we change card payloads/ordering while keeping the same implemented_upto.
_CACHE_VERSION = 8 _CACHE_VERSION = 9
# "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card. # "Manifest" is used by the frontend to render the deck quickly, then lazily fetch each card.