统一聊天排序并支持公众号跳转小程序
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
37
banban-mini/src/pages/notification/index.scss
Normal file
37
banban-mini/src/pages/notification/index.scss
Normal 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;
|
||||
}
|
||||
59
banban-mini/src/pages/notification/index.tsx
Normal file
59
banban-mini/src/pages/notification/index.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
10
banban-mini/src/services/notification.ts
Normal file
10
banban-mini/src/services/notification.ts
Normal 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)}`)
|
||||
}
|
||||
@@ -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
|
||||
"""
|
||||
),
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
),
|
||||
|
||||
@@ -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=""),
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user