统一聊天排序并支持公众号跳转小程序

This commit is contained in:
stu2not
2026-06-24 14:59:44 +08:00
parent 31804a78ff
commit e75a9015fa
16 changed files with 668 additions and 72 deletions

View File

@@ -3,6 +3,7 @@ export default defineAppConfig({
"pages/login/index",
"pages/bind/index",
"pages/device/index",
"pages/notification/index",
"pages/chat/index",
"pages/chat/detail/index",
"pages/location/index",

View File

@@ -109,6 +109,7 @@ const RECORD_LONG_PRESS_DELAY_MS = 350
type LoadChatOptions = {
showLoading?: boolean
scrollToBottom?: 'jump' | 'animate' | false
}
function compareVersion(left: string, right: string): number {
@@ -232,6 +233,7 @@ export default function ChatDetail() {
const [playingMessageId, setPlayingMessageId] = useState<number | null>(null)
const [recordHint, setRecordHint] = useState('长按开始留言')
const [scrollTarget, setScrollTarget] = useState('')
const [scrollWithAnimation, setScrollWithAnimation] = useState(false)
const audioContextRef = useRef<Taro.InnerAudioContext | null>(null)
const recorderManagerRef = useRef<Taro.RecorderManager | null>(null)
const activeConversationIdRef = useRef(initialConversationId)
@@ -251,12 +253,14 @@ export default function ChatDetail() {
const recordStartPendingRef = useRef(false)
const recordingActiveRef = useRef(false)
const scrollToBottom = () => {
const scrollToBottom = (animated: boolean) => {
if (scrollTimerRef.current) {
clearTimeout(scrollTimerRef.current)
}
setScrollWithAnimation(animated)
setScrollTarget('')
scrollTimerRef.current = setTimeout(() => {
setScrollWithAnimation(animated)
setScrollTarget(CHAT_BOTTOM_ANCHOR_ID)
scrollTimerRef.current = null
}, 80)
@@ -373,8 +377,9 @@ export default function ChatDetail() {
}
const msgs = await getMessages(conversationId, conversationSource, decodedConversationTypeName)
setMessages(msgs)
if (msgs.length > 0) {
scrollToBottom()
const scrollMode = options.scrollToBottom ?? (!hasLoadedChatRef.current ? 'jump' : false)
if (msgs.length > 0 && scrollMode) {
scrollToBottom(scrollMode === 'animate')
}
hasLoadedChatRef.current = true
} catch (error: any) {
@@ -480,9 +485,15 @@ export default function ChatDetail() {
if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) {
skipNextAutoLoadRef.current = true
setActiveConversationId(response.conversation_id)
await latestLoadChatDataRef.current(response.conversation_id, { showLoading: false })
await latestLoadChatDataRef.current(response.conversation_id, {
showLoading: false,
scrollToBottom: 'animate',
})
} else {
await latestLoadChatDataRef.current(activeConversationIdRef.current, { showLoading: false })
await latestLoadChatDataRef.current(activeConversationIdRef.current, {
showLoading: false,
scrollToBottom: 'animate',
})
}
Taro.showToast({ title: '留言已发送', icon: 'success' })
} catch (error: any) {
@@ -752,7 +763,12 @@ export default function ChatDetail() {
</View>
</View>
<ScrollView className='chat-list' scrollY scrollWithAnimation scrollIntoView={scrollTarget}>
<ScrollView
className='chat-list'
scrollY
scrollWithAnimation={scrollWithAnimation}
scrollIntoView={scrollTarget}
>
{loading ? (
<View className='empty-chat'>
<Text className='empty-chat-text'>...</Text>

View File

@@ -0,0 +1,37 @@
.notification-page {
min-height: 100vh;
background: #f5f7fa;
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
box-sizing: border-box;
}
.notification-panel {
width: 100%;
max-width: 520px;
background: #ffffff;
border-radius: 8px;
padding: 40px 32px;
box-sizing: border-box;
box-shadow: 0 10px 24px rgba(31, 41, 55, 0.08);
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.notification-title {
color: #1f2937;
font-size: 34px;
font-weight: 700;
line-height: 1.3;
}
.notification-message {
color: #6b7280;
font-size: 28px;
line-height: 1.5;
text-align: center;
}

View File

@@ -0,0 +1,59 @@
import { View, Text } from '@tarojs/components'
import { useEffect, useState } from 'react'
import Taro, { useRouter } from '@tarojs/taro'
import { getToken } from '@/services/auth'
import { resolveWechatMpNotificationLink } from '@/services/notification'
import './index.scss'
function buildQuery(params: Record<string, string | number | null | undefined>): string {
return Object.entries(params)
.filter(([, value]) => value !== undefined && value !== null && String(value) !== '')
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
.join('&')
}
function buildCurrentPageUrl(token: string): string {
return `/pages/notification/index?token=${encodeURIComponent(token)}`
}
export default function NotificationEntry() {
const router = useRouter()
const token = String(router.params.token || '').trim()
const [message, setMessage] = useState('正在打开留言...')
useEffect(() => {
const openNotification = async () => {
if (!token) {
setMessage('通知链接无效')
return
}
if (!getToken()) {
Taro.setStorageSync('postLoginRedirect', buildCurrentPageUrl(token))
Taro.reLaunch({ url: '/pages/login/index' })
return
}
try {
const result = await resolveWechatMpNotificationLink(token)
const query = buildQuery(result.params || {})
const route = result.route.startsWith('/') ? result.route : `/${result.route}`
Taro.redirectTo({ url: query ? `${route}?${query}` : route })
} catch (error: any) {
console.error('[notification] resolve failed:', error)
setMessage(error?.message || '通知链接已失效')
}
}
void openNotification()
}, [token])
return (
<View className='notification-page'>
<View className='notification-panel'>
<Text className='notification-title'></Text>
<Text className='notification-message'>{message}</Text>
</View>
</View>
)
}

View File

@@ -122,6 +122,8 @@ interface ChildConversationItem {
last_message_preview?: string | null
last_message_at?: string | null
message_count: number
created_at: string
updated_at: string
}
interface ChildConversationListResponse {
@@ -307,9 +309,10 @@ function canSendParentConversation(parentUserId: number | null, context: Binding
return Boolean(parentUserId && parentUserId === context.currentUserId)
}
function getConversationRank(conversation: ChatConversation): number {
if (isParentChildConversation(conversation.conversationTypeName)) return 0
return 1
function compareConversationsByNewest(left: ChatConversation, right: ChatConversation): number {
const timeDiff = parseTime(right.sortAt) - parseTime(left.sortAt)
if (timeDiff !== 0) return timeDiff
return right.key.localeCompare(left.key)
}
async function getBindingContext(): Promise<BindingContext> {
@@ -385,6 +388,7 @@ function toImConversation(item: ChildConversationItem, context: BindingContext):
const parentUserId = isParentConversation ? parseNumericId(item.peer_id) : null
const canSend = isParentConversation ? canSendParentConversation(parentUserId, context) : false
const parentConversationName = getParentConversationName(parentUserId, item.peer_id, item.peer_name, context)
const sortAt = item.last_message_at || item.updated_at || item.created_at
return {
id: item.conversation_id,
@@ -400,8 +404,8 @@ function toImConversation(item: ChildConversationItem, context: BindingContext):
: '其他家长和孩子的留言记录'
: meta.description,
lastMessage: item.last_message_preview || (isParentConversation ? (canSend ? '还没有消息,点进去发送第一条' : '暂无留言记录') : '暂无消息'),
time: formatListTime(item.last_message_at),
sortAt: item.last_message_at || '',
time: formatListTime(sortAt),
sortAt,
conversationTypeName: item.conversation_type_name,
peerId: item.peer_id,
childId: context.childId,
@@ -496,11 +500,7 @@ export async function getConversations(): Promise<ChatConversation[]> {
}
}
return conversations.sort((left, right) => {
const rankDiff = getConversationRank(left) - getConversationRank(right)
if (rankDiff !== 0) return rankDiff
return parseTime(right.sortAt) - parseTime(left.sortAt)
})
return conversations.sort(compareConversationsByNewest)
}
export async function getMessages(

View File

@@ -0,0 +1,10 @@
import { request } from './api'
export interface WechatMpNotificationLink {
route: string
params: Record<string, string | number | null | undefined>
}
export function resolveWechatMpNotificationLink(token: string): Promise<WechatMpNotificationLink> {
return request<WechatMpNotificationLink>(`/banban/wechat-mp/notification-links/${encodeURIComponent(token)}`)
}

View File

@@ -103,10 +103,11 @@ async def list_device_conversations(
last_message_preview,
last_message_at,
message_count,
created_at
created_at,
updated_at
FROM im_conversations
WHERE {where}
ORDER BY COALESCE(last_message_at, created_at) DESC, id DESC
ORDER BY COALESCE(last_message_at, updated_at, created_at) DESC, id DESC
LIMIT :fetch_limit
"""
),

View File

@@ -247,6 +247,8 @@ def _row_to_conversation_item(
last_message_preview=row["last_message_preview"],
last_message_at=row["last_message_at"],
message_count=int(row["message_count"]),
created_at=row["created_at"],
updated_at=row["updated_at"],
)
@@ -331,10 +333,11 @@ async def list_child_conversations(
last_message_preview,
last_message_at,
message_count,
created_at
created_at,
updated_at
FROM im_conversations
WHERE {where}
ORDER BY COALESCE(last_message_at, created_at) DESC, id DESC
ORDER BY COALESCE(last_message_at, updated_at, created_at) DESC, id DESC
LIMIT :fetch_limit
"""
),

View File

@@ -24,6 +24,11 @@ class WechatMpBindStatusResponse(BaseModel):
updated_at: str | None = None
class WechatMpNotificationLinkResponse(BaseModel):
route: str
params: dict[str, str | int | None]
@router.post("/bind/start", response_model=WechatMpBindStartResponse)
async def start_wechat_mp_bind(
current_user_id: int = Depends(get_current_user_id),
@@ -48,6 +53,21 @@ async def get_wechat_mp_bind_status(
)
@router.get("/notification-links/{token}", response_model=WechatMpNotificationLinkResponse)
async def resolve_wechat_mp_notification_link(
token: str,
current_user_id: int = Depends(get_current_user_id),
) -> WechatMpNotificationLinkResponse:
result = await wechat_mp_notification_service.resolve_notification_link(
token=token,
user_id=current_user_id,
)
return WechatMpNotificationLinkResponse(
route=str(result["route"]),
params=dict(result["params"]),
)
@router.get("/oauth/callback", response_class=HTMLResponse)
async def wechat_mp_oauth_callback(
code: str = Query(default=""),

View File

@@ -14,6 +14,8 @@ class ChildConversationItem(BaseModel):
last_message_preview: str | None = None
last_message_at: datetime | None = None
message_count: int
created_at: datetime
updated_at: datetime
class ChildConversationListResponse(BaseModel):

View File

@@ -47,6 +47,16 @@ PARTICIPANT_TYPE_NAMES = {
CHILD_PARTICIPANT_TYPE: "child",
}
@dataclass(frozen=True)
class DeviceParentLeaveMessageTarget:
parent_user_id: int
conversation_id: int
message_id: int
child_id: int
child_name: str | None
device_id: str
_AUDIO_CONTENT_TYPE_TO_EXT = {
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
@@ -644,7 +654,7 @@ class ImService(DatabaseServiceBase):
client_msg_id: str | None = None,
ext_json: dict[str, Any] | None = None,
audio_content: bytes | None = None,
) -> tuple[DeviceIdentity, ConversationMessageCreateResult]:
) -> tuple[DeviceIdentity, ConversationMessageCreateResult, list[DeviceParentLeaveMessageTarget]]:
normalized_media_file_key = str(media_file_key or "").strip()
if not normalized_media_file_key:
raise HTTPException(status_code=400, detail="media_file_key is required")
@@ -656,6 +666,7 @@ class ImService(DatabaseServiceBase):
if not family_identities:
raise HTTPException(status_code=404, detail="device family has no members")
first_result: ConversationMessageCreateResult | None = None
notification_targets: list[DeviceParentLeaveMessageTarget] = []
for owner_identity in family_identities:
member_client_msg_id = (client_msg_id or "").strip()
if len(family_identities) > 1 or not member_client_msg_id:
@@ -676,6 +687,16 @@ class ImService(DatabaseServiceBase):
client_msg_id=member_client_msg_id,
ext_json=ext_json,
)
notification_targets.append(
DeviceParentLeaveMessageTarget(
parent_user_id=owner_identity.owner_user_id,
conversation_id=member_result.conversation_id,
message_id=member_result.message.id,
child_id=owner_identity.child_id,
child_name=owner_identity.child_name,
device_id=owner_identity.device_id,
)
)
if audio_content:
await self.schedule_message_media_duration_parse(
message_id=member_result.message.id,
@@ -695,6 +716,7 @@ class ImService(DatabaseServiceBase):
child_name=family_identities[0].child_name,
),
first_result,
notification_targets,
)
except Exception:
await db_session.rollback()

View File

@@ -1,20 +1,34 @@
import asyncio
import base64
import hashlib
import hmac
import json
import secrets
import time
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
from urllib.parse import urlencode
import httpx
from fastapi import HTTPException
from sqlalchemy import text
from banban.service.im import DeviceParentLeaveMessageTarget
from config import settings
from services.database_service_base import DatabaseServiceBase
from utils.logger import session_logger
WECHAT_MP_ACCOUNT_TYPE = "service_account"
WECHAT_MP_NOTIFICATION_TOKEN_VERSION = 1
@dataclass(frozen=True)
class WechatMpRecipient:
user_id: int
openid: str
class WechatMpNotificationService(DatabaseServiceBase):
@@ -99,6 +113,113 @@ class WechatMpNotificationService(DatabaseServiceBase):
finally:
await db_session.close()
async def resolve_notification_link(self, *, token: str, user_id: int) -> Mapping[str, Any]:
payload = self._decode_notification_token(token)
if payload.get("type") != "leave_message":
raise HTTPException(status_code=400, detail="unsupported notification type")
if int(payload.get("user_id") or 0) != user_id:
raise HTTPException(status_code=403, detail="notification does not belong to current user")
conversation_id = int(payload.get("conversation_id") or 0)
message_id = int(payload.get("message_id") or 0)
child_id = int(payload.get("child_id") or 0)
device_id = str(payload.get("device_id") or "").strip()
if conversation_id <= 0 or message_id <= 0 or child_id <= 0:
raise HTTPException(status_code=400, detail="invalid notification token")
db_session = await self.get_session()
try:
user_id_str = str(user_id)
child_id_str = str(child_id)
result = await db_session.execute(
text(
"""
SELECT
c.id AS conversation_id,
ch.child_id,
ch.child_name,
db.device_id
FROM im_conversations AS c
JOIN im_messages AS m
ON m.id = :message_id
AND m.conversation_id = c.id
AND m.deleted_at IS NULL
JOIN children AS ch
ON ch.child_id = :child_id
AND ch.status = 1
LEFT JOIN parent_child_relations AS pcr
ON pcr.child_id = ch.child_id
AND pcr.user_id = :user_id
AND pcr.status = 1
LEFT JOIN device_bindings AS db
ON db.child_id = ch.child_id
AND db.status = 1
LEFT JOIN device_family_members AS dfm
ON dfm.device_id = db.device_id
AND dfm.user_id = :user_id
AND dfm.status = 1
WHERE c.id = :conversation_id
AND c.status = 1
AND c.conversation_type = 2
AND (
(
c.participant_a_type = 2
AND c.participant_a_id = :child_id_str
AND c.participant_b_type = 1
AND c.participant_b_id = :user_id_str
)
OR (
c.participant_b_type = 2
AND c.participant_b_id = :child_id_str
AND c.participant_a_type = 1
AND c.participant_a_id = :user_id_str
)
)
AND (
pcr.id IS NOT NULL
OR db.owner_user_id = :user_id
OR dfm.id IS NOT NULL
)
LIMIT 1
"""
),
{
"message_id": message_id,
"conversation_id": conversation_id,
"child_id": child_id,
"child_id_str": child_id_str,
"user_id": user_id,
"user_id_str": user_id_str,
},
)
row = result.mappings().first()
if not row:
raise HTTPException(status_code=404, detail="notification target not found")
resolved_child_name = str(row.get("child_name") or payload.get("child_name") or "").strip()
resolved_device_id = str(row.get("device_id") or device_id).strip()
return {
"route": "pages/chat/detail/index",
"params": {
"id": conversation_id,
"source": "im",
"name": "家长沟通",
"peerKind": "parent",
"roleKey": "",
"conversationTypeName": "parent_child",
"peerId": user_id_str,
"childId": child_id,
"childName": resolved_child_name,
"parentUserId": user_id,
"deviceId": resolved_device_id,
"channelLabel": "微信小程序",
"canSend": "1",
"messageId": message_id,
},
}
finally:
await db_session.close()
def schedule_leave_message_notification(self, **kwargs: Any) -> None:
self._schedule_best_effort(
self.notify_leave_message_best_effort(**kwargs),
@@ -120,22 +241,35 @@ class WechatMpNotificationService(DatabaseServiceBase):
*,
device_id: str,
child_name: str | None = None,
targets: list[DeviceParentLeaveMessageTarget | Mapping[str, Any]] | None = None,
) -> None:
if not self.is_configured():
return
recipients = await self._list_device_family_mp_openids(device_id=device_id)
recipients = await self._list_device_family_mp_recipients(device_id=device_id)
if not recipients:
session_logger.info(device_id, "wechat_mp", "留言公众号推送跳过没有绑定服务号openid的家长")
return
await self._send_to_recipients(
device_id=device_id,
notification_type="leave_message",
recipients=recipients,
title=child_name or device_id,
condition="收到新的设备留言",
page=settings.wechat_mp_chat_page,
dedup_seconds=max(0, int(settings.wechat_mp_leave_message_dedup_seconds or 0)),
)
target_by_user_id = {
self._target_int(target, "parent_user_id"): target
for target in targets or []
if self._target_int(target, "parent_user_id") > 0
}
dedup_seconds = max(0, int(settings.wechat_mp_leave_message_dedup_seconds or 0))
for recipient in recipients:
page = self._build_leave_message_page(
recipient_user_id=recipient.user_id,
target=target_by_user_id.get(recipient.user_id),
)
await self._send_to_recipient(
device_id=device_id,
notification_type="leave_message",
openid=recipient.openid,
title=child_name or device_id,
condition="收到新的设备留言",
page=page,
dedup_seconds=dedup_seconds,
)
def schedule_low_battery_notification(self, *, device_id: str, power: Any, child_name: str | None = None) -> None:
self._schedule_best_effort(
@@ -240,35 +374,62 @@ class WechatMpNotificationService(DatabaseServiceBase):
return
for openid in recipients:
if self._is_deduped(
await self._send_to_recipient(
device_id=device_id,
notification_type=notification_type,
openid=openid,
dedup_seconds=dedup_seconds,
):
session_logger.info(device_id, "wechat_mp", f"公众号推送防抖跳过: type={notification_type}")
continue
result = await self._send_template_message(
openid=openid,
template_id=template_id,
title=title,
condition=condition,
page=page,
dedup_seconds=dedup_seconds,
template_id=template_id,
)
async def _send_to_recipient(
self,
*,
device_id: str,
notification_type: str,
openid: str,
title: str,
condition: str,
page: str,
dedup_seconds: int,
template_id: str | None = None,
) -> None:
resolved_template_id = template_id or self._template_id(notification_type)
if not resolved_template_id:
return
if self._is_deduped(
device_id=device_id,
notification_type=notification_type,
openid=openid,
dedup_seconds=dedup_seconds,
):
session_logger.info(device_id, "wechat_mp", f"公众号推送防抖跳过: type={notification_type}")
return
result = await self._send_template_message(
openid=openid,
template_id=resolved_template_id,
title=title,
condition=condition,
page=page,
)
if result.get("ok"):
self._mark_sent(device_id=device_id, notification_type=notification_type, openid=openid)
session_logger.info(
device_id,
"wechat_mp",
f"公众号推送成功: type={notification_type}, msgid={result.get('msgid')}",
)
else:
session_logger.warning(
device_id,
"wechat_mp",
f"公众号推送失败: type={notification_type}, errcode={result.get('errcode')}, errmsg={result.get('errmsg')}",
)
if result.get("ok"):
self._mark_sent(device_id=device_id, notification_type=notification_type, openid=openid)
session_logger.info(
device_id,
"wechat_mp",
f"公众号推送成功: type={notification_type}, msgid={result.get('msgid')}",
)
else:
session_logger.warning(
device_id,
"wechat_mp",
f"公众号推送失败: type={notification_type}, errcode={result.get('errcode')}, errmsg={result.get('errmsg')}",
)
async def _send_template_message(
self,
@@ -306,12 +467,16 @@ class WechatMpNotificationService(DatabaseServiceBase):
}
async def _list_device_family_mp_openids(self, *, device_id: str) -> list[str]:
recipients = await self._list_device_family_mp_recipients(device_id=device_id)
return [recipient.openid for recipient in recipients]
async def _list_device_family_mp_recipients(self, *, device_id: str) -> list[WechatMpRecipient]:
db_session = await self.get_session()
try:
result = await db_session.execute(
text(
"""
SELECT pwa.openid
SELECT recipients.user_id, pwa.openid
FROM (
SELECT
db.owner_user_id AS user_id,
@@ -351,15 +516,20 @@ class WechatMpNotificationService(DatabaseServiceBase):
"account_type": WECHAT_MP_ACCOUNT_TYPE,
},
)
openids: list[str] = []
recipients: list[WechatMpRecipient] = []
seen: set[str] = set()
for row in result.mappings().all():
openid = str(row["openid"]).strip()
if not openid or openid in seen:
continue
seen.add(openid)
openids.append(openid)
return openids
recipients.append(
WechatMpRecipient(
user_id=int(row["user_id"]),
openid=openid,
)
)
return recipients
finally:
await db_session.close()
@@ -560,6 +730,83 @@ class WechatMpNotificationService(DatabaseServiceBase):
raise RuntimeError(f"wechat mp user info failed: errcode={data.get('errcode')} errmsg={data.get('errmsg')}")
return data
def _build_leave_message_page(
self,
*,
recipient_user_id: int,
target: DeviceParentLeaveMessageTarget | Mapping[str, Any] | None,
) -> str:
if target is None:
return settings.wechat_mp_chat_page
child_id = self._target_int(target, "child_id")
conversation_id = self._target_int(target, "conversation_id")
message_id = self._target_int(target, "message_id")
if child_id <= 0 or conversation_id <= 0 or message_id <= 0:
return settings.wechat_mp_chat_page
token = self._create_notification_token(
{
"type": "leave_message",
"user_id": recipient_user_id,
"conversation_id": conversation_id,
"message_id": message_id,
"child_id": child_id,
"child_name": self._target_str(target, "child_name"),
"device_id": self._target_str(target, "device_id"),
}
)
page = (settings.wechat_mp_notification_page or "").strip() or "pages/notification/index"
return f"{page}?token={token}"
def _create_notification_token(self, payload: Mapping[str, Any]) -> str:
now = int(time.time())
ttl_seconds = max(60, int(settings.wechat_mp_notification_token_expire_minutes or 0) * 60)
normalized_payload = {
"v": WECHAT_MP_NOTIFICATION_TOKEN_VERSION,
"iat": now,
"exp": now + ttl_seconds,
**dict(payload),
}
payload_bytes = json.dumps(normalized_payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
payload_part = self._b64url_encode(payload_bytes)
signature = self._sign_notification_payload(payload_part)
return f"{payload_part}.{signature}"
def _decode_notification_token(self, token: str) -> Mapping[str, Any]:
normalized_token = str(token or "").strip()
if not normalized_token or "." not in normalized_token:
raise HTTPException(status_code=400, detail="invalid notification token")
payload_part, signature = normalized_token.rsplit(".", 1)
expected_signature = self._sign_notification_payload(payload_part)
if not hmac.compare_digest(signature, expected_signature):
raise HTTPException(status_code=400, detail="invalid notification token")
try:
payload = json.loads(self._b64url_decode(payload_part).decode("utf-8"))
except Exception as exc:
raise HTTPException(status_code=400, detail="invalid notification token") from exc
if not isinstance(payload, dict):
raise HTTPException(status_code=400, detail="invalid notification token")
if int(payload.get("v") or 0) != WECHAT_MP_NOTIFICATION_TOKEN_VERSION:
raise HTTPException(status_code=400, detail="unsupported notification token")
if int(payload.get("exp") or 0) < int(time.time()):
raise HTTPException(status_code=400, detail="notification token expired")
return payload
def _sign_notification_payload(self, payload_part: str) -> str:
secret = f"wechat-mp-notification:{settings.jwt_secret}".encode("utf-8")
digest = hmac.new(secret, payload_part.encode("ascii"), hashlib.sha256).digest()
return self._b64url_encode(digest)
@staticmethod
def _b64url_encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
@staticmethod
def _b64url_decode(value: str) -> bytes:
padding = "=" * (-len(value) % 4)
return base64.urlsafe_b64decode(f"{value}{padding}")
def _template_id(self, notification_type: str) -> str:
default_template_id = (settings.wechat_mp_template_id or "").strip()
if notification_type == "leave_message":
@@ -579,6 +826,23 @@ class WechatMpNotificationService(DatabaseServiceBase):
def _mark_sent(self, *, device_id: str, notification_type: str, openid: str) -> None:
self._last_sent_at[(device_id, notification_type, openid)] = time.time()
@staticmethod
def _target_value(target: DeviceParentLeaveMessageTarget | Mapping[str, Any], key: str) -> Any:
if isinstance(target, Mapping):
return target.get(key)
return getattr(target, key)
@classmethod
def _target_int(cls, target: DeviceParentLeaveMessageTarget | Mapping[str, Any], key: str) -> int:
try:
return int(cls._target_value(target, key) or 0)
except (TypeError, ValueError):
return 0
@classmethod
def _target_str(cls, target: DeviceParentLeaveMessageTarget | Mapping[str, Any], key: str) -> str:
return str(cls._target_value(target, key) or "").strip()
@staticmethod
def _clip_template_value(value: str, max_len: int) -> str:
text = str(value or "").strip()

View File

@@ -120,6 +120,14 @@ class Settings(BaseSettings):
wechat_mp_miniprogram_link_enabled: bool = Field(default=True, validation_alias="WECHAT_MP_MINIPROGRAM_LINK_ENABLED")
wechat_mp_miniprogram_appid: str = Field(default="", validation_alias="WECHAT_MP_MINIPROGRAM_APPID")
wechat_mp_chat_page: str = Field(default="pages/chat/detail/index", validation_alias="WECHAT_MP_CHAT_PAGE")
wechat_mp_notification_page: str = Field(
default="pages/notification/index",
validation_alias="WECHAT_MP_NOTIFICATION_PAGE",
)
wechat_mp_notification_token_expire_minutes: int = Field(
default=10080,
validation_alias="WECHAT_MP_NOTIFICATION_TOKEN_EXPIRE_MINUTES",
)
wechat_mp_device_page: str = Field(default="pages/device/index", validation_alias="WECHAT_MP_DEVICE_PAGE")
wechat_mp_oauth_redirect_uri: str = Field(default="", validation_alias="WECHAT_MP_OAUTH_REDIRECT_URI")
wechat_mp_bind_success_page: str = Field(

View File

@@ -492,7 +492,7 @@ class TalkingQMQTTService:
media_file_key = str(params.get("media_file_key") or params.get("audio_url") or "").strip()
if media_file_key:
try:
device_identity, _ = await im_service.create_device_parent_leave_message(
device_identity, _, notification_targets = await im_service.create_device_parent_leave_message(
device_id=device_id,
media_file_key=media_file_key,
media_duration_ms=params.get("media_duration_ms"),
@@ -505,6 +505,7 @@ class TalkingQMQTTService:
wechat_mp_notification_service.schedule_leave_message_notification(
device_id=device_id,
child_name=device_identity.child_name,
targets=notification_targets,
)
logger.info(device_id, "", f"[短按留言] 设备 {device_id} 留言已写入家长会话")
await self._publish(

View File

@@ -289,7 +289,7 @@ async def process_parent_leave_message(device_id: str, audio_cache_key: str):
content_type=media_mime_type,
extension=extension,
)
device_identity, _ = await im_conversation_service.create_device_parent_leave_message(
device_identity, _, notification_targets = await im_conversation_service.create_device_parent_leave_message(
device_id=device_id,
media_file_key=stored_audio.file_key,
media_mime_type=media_mime_type,
@@ -305,6 +305,7 @@ async def process_parent_leave_message(device_id: str, audio_cache_key: str):
wechat_mp_notification_service.schedule_leave_message_notification(
device_id=device_id,
child_name=device_identity.child_name,
targets=notification_targets,
)
session_logger.info(
device_id,

View File

@@ -1,8 +1,9 @@
from urllib.parse import parse_qs, urlparse
import pytest
from fastapi import HTTPException
from banban.service.wechat_mp_notification import WechatMpNotificationService
from banban.service.wechat_mp_notification import WechatMpNotificationService, WechatMpRecipient
def configure_enabled_wechat_mp(monkeypatch):
@@ -17,6 +18,8 @@ def configure_enabled_wechat_mp(monkeypatch):
monkeypatch.setattr("banban.service.wechat_mp_notification.settings.wechat_mp_miniprogram_link_enabled", True)
monkeypatch.setattr("banban.service.wechat_mp_notification.settings.wechat_mp_miniprogram_appid", "wx-mini")
monkeypatch.setattr("banban.service.wechat_mp_notification.settings.wechat_mp_chat_page", "pages/chat/detail/index")
monkeypatch.setattr("banban.service.wechat_mp_notification.settings.wechat_mp_notification_page", "pages/notification/index")
monkeypatch.setattr("banban.service.wechat_mp_notification.settings.wechat_mp_notification_token_expire_minutes", 10080)
monkeypatch.setattr("banban.service.wechat_mp_notification.settings.wechat_mp_device_page", "pages/device/index")
monkeypatch.setattr("banban.service.wechat_mp_notification.settings.wechat_mp_oauth_redirect_uri", "https://example.com/banban/wechat-mp/oauth/callback")
monkeypatch.setattr("banban.service.wechat_mp_notification.settings.wechat_mp_leave_message_dedup_seconds", 60)
@@ -34,13 +37,83 @@ async def test_notify_leave_message_sends_template_to_unique_subscribed_recipien
async def fake_list_recipients(*, device_id):
assert device_id == "TalkingQ_XQSN00001007"
return ["openid-1", "openid-2"]
return [
WechatMpRecipient(user_id=4, openid="openid-1"),
WechatMpRecipient(user_id=5, openid="openid-2"),
]
async def fake_send_template_message(**kwargs):
sent.append(kwargs)
return {"ok": True, "msgid": "msg-id"}
monkeypatch.setattr(service, "_list_device_family_mp_openids", fake_list_recipients)
monkeypatch.setattr(service, "_list_device_family_mp_recipients", fake_list_recipients)
monkeypatch.setattr(service, "_send_template_message", fake_send_template_message)
await service.notify_leave_message(
device_id="TalkingQ_XQSN00001007",
child_name="小明",
targets=[
{
"parent_user_id": 4,
"conversation_id": 6,
"message_id": 209,
"child_id": 8,
"child_name": "小明",
"device_id": "TalkingQ_XQSN00001007",
},
{
"parent_user_id": 5,
"conversation_id": 7,
"message_id": 210,
"child_id": 8,
"child_name": "小明",
"device_id": "TalkingQ_XQSN00001007",
},
],
)
assert len(sent) == 2
assert sent[0] == {
"openid": "openid-1",
"template_id": "template-id",
"title": "小明",
"condition": "收到新的设备留言",
"page": sent[0]["page"],
}
assert sent[1] == {
"openid": "openid-2",
"template_id": "template-id",
"title": "小明",
"condition": "收到新的设备留言",
"page": sent[1]["page"],
}
assert sent[0]["page"].startswith("pages/notification/index?token=")
assert sent[1]["page"].startswith("pages/notification/index?token=")
assert sent[0]["page"] != sent[1]["page"]
first_token = sent[0]["page"].split("token=", 1)[1]
second_token = sent[1]["page"].split("token=", 1)[1]
assert service._decode_notification_token(first_token)["conversation_id"] == 6
assert service._decode_notification_token(first_token)["user_id"] == 4
assert service._decode_notification_token(second_token)["conversation_id"] == 7
assert service._decode_notification_token(second_token)["user_id"] == 5
@pytest.mark.asyncio
async def test_notify_leave_message_falls_back_to_chat_page_without_targets(monkeypatch):
configure_enabled_wechat_mp(monkeypatch)
service = WechatMpNotificationService()
sent = []
async def fake_list_recipients(*, device_id):
del device_id
return [WechatMpRecipient(user_id=4, openid="openid-1")]
async def fake_send_template_message(**kwargs):
sent.append(kwargs)
return {"ok": True, "msgid": "msg-id"}
monkeypatch.setattr(service, "_list_device_family_mp_recipients", fake_list_recipients)
monkeypatch.setattr(service, "_send_template_message", fake_send_template_message)
await service.notify_leave_message(device_id="TalkingQ_XQSN00001007", child_name="小明")
@@ -52,14 +125,7 @@ async def test_notify_leave_message_sends_template_to_unique_subscribed_recipien
"title": "小明",
"condition": "收到新的设备留言",
"page": "pages/chat/detail/index",
},
{
"openid": "openid-2",
"template_id": "template-id",
"title": "小明",
"condition": "收到新的设备留言",
"page": "pages/chat/detail/index",
},
}
]
@@ -71,13 +137,13 @@ async def test_notify_leave_message_dedupes_same_device_and_openid(monkeypatch):
async def fake_list_recipients(*, device_id):
del device_id
return ["openid-1"]
return [WechatMpRecipient(user_id=4, openid="openid-1")]
async def fake_send_template_message(**kwargs):
sent.append(kwargs["openid"])
return {"ok": True, "msgid": "msg-id"}
monkeypatch.setattr(service, "_list_device_family_mp_openids", fake_list_recipients)
monkeypatch.setattr(service, "_list_device_family_mp_recipients", fake_list_recipients)
monkeypatch.setattr(service, "_send_template_message", fake_send_template_message)
await service.notify_leave_message(device_id="TalkingQ_XQSN00001007", child_name="小明")
@@ -86,6 +152,91 @@ async def test_notify_leave_message_dedupes_same_device_and_openid(monkeypatch):
assert sent == ["openid-1"]
@pytest.mark.asyncio
async def test_resolve_notification_link_returns_chat_detail_params(monkeypatch):
configure_enabled_wechat_mp(monkeypatch)
service = WechatMpNotificationService()
token = service._create_notification_token(
{
"type": "leave_message",
"user_id": 4,
"conversation_id": 6,
"message_id": 209,
"child_id": 8,
"child_name": "小明",
"device_id": "TalkingQ_XQSN00001007",
}
)
class FakeResult:
def mappings(self):
return self
def first(self):
return {
"conversation_id": 6,
"child_id": 8,
"child_name": "小明",
"device_id": "TalkingQ_XQSN00001007",
}
class FakeSession:
async def execute(self, sql, params):
assert params["message_id"] == 209
assert params["conversation_id"] == 6
assert params["child_id"] == 8
assert params["user_id"] == 4
return FakeResult()
async def close(self):
pass
async def fake_get_session():
return FakeSession()
monkeypatch.setattr(service, "get_session", fake_get_session)
result = await service.resolve_notification_link(token=token, user_id=4)
assert result["route"] == "pages/chat/detail/index"
assert result["params"] == {
"id": 6,
"source": "im",
"name": "家长沟通",
"peerKind": "parent",
"roleKey": "",
"conversationTypeName": "parent_child",
"peerId": "4",
"childId": 8,
"childName": "小明",
"parentUserId": 4,
"deviceId": "TalkingQ_XQSN00001007",
"channelLabel": "微信小程序",
"canSend": "1",
"messageId": 209,
}
@pytest.mark.asyncio
async def test_resolve_notification_link_rejects_other_user(monkeypatch):
configure_enabled_wechat_mp(monkeypatch)
service = WechatMpNotificationService()
token = service._create_notification_token(
{
"type": "leave_message",
"user_id": 4,
"conversation_id": 6,
"message_id": 209,
"child_id": 8,
}
)
with pytest.raises(HTTPException) as exc_info:
await service.resolve_notification_link(token=token, user_id=5)
assert exc_info.value.status_code == 403
@pytest.mark.asyncio
async def test_bind_url_persists_state_and_builds_oauth_url(monkeypatch):
configure_enabled_wechat_mp(monkeypatch)