统一聊天排序并支持公众号跳转小程序
This commit is contained in:
@@ -3,6 +3,7 @@ export default defineAppConfig({
|
|||||||
"pages/login/index",
|
"pages/login/index",
|
||||||
"pages/bind/index",
|
"pages/bind/index",
|
||||||
"pages/device/index",
|
"pages/device/index",
|
||||||
|
"pages/notification/index",
|
||||||
"pages/chat/index",
|
"pages/chat/index",
|
||||||
"pages/chat/detail/index",
|
"pages/chat/detail/index",
|
||||||
"pages/location/index",
|
"pages/location/index",
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ const RECORD_LONG_PRESS_DELAY_MS = 350
|
|||||||
|
|
||||||
type LoadChatOptions = {
|
type LoadChatOptions = {
|
||||||
showLoading?: boolean
|
showLoading?: boolean
|
||||||
|
scrollToBottom?: 'jump' | 'animate' | false
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareVersion(left: string, right: string): number {
|
function compareVersion(left: string, right: string): number {
|
||||||
@@ -232,6 +233,7 @@ export default function ChatDetail() {
|
|||||||
const [playingMessageId, setPlayingMessageId] = useState<number | null>(null)
|
const [playingMessageId, setPlayingMessageId] = useState<number | null>(null)
|
||||||
const [recordHint, setRecordHint] = useState('长按开始留言')
|
const [recordHint, setRecordHint] = useState('长按开始留言')
|
||||||
const [scrollTarget, setScrollTarget] = useState('')
|
const [scrollTarget, setScrollTarget] = useState('')
|
||||||
|
const [scrollWithAnimation, setScrollWithAnimation] = useState(false)
|
||||||
const audioContextRef = useRef<Taro.InnerAudioContext | null>(null)
|
const audioContextRef = useRef<Taro.InnerAudioContext | null>(null)
|
||||||
const recorderManagerRef = useRef<Taro.RecorderManager | null>(null)
|
const recorderManagerRef = useRef<Taro.RecorderManager | null>(null)
|
||||||
const activeConversationIdRef = useRef(initialConversationId)
|
const activeConversationIdRef = useRef(initialConversationId)
|
||||||
@@ -251,12 +253,14 @@ export default function ChatDetail() {
|
|||||||
const recordStartPendingRef = useRef(false)
|
const recordStartPendingRef = useRef(false)
|
||||||
const recordingActiveRef = useRef(false)
|
const recordingActiveRef = useRef(false)
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const scrollToBottom = (animated: boolean) => {
|
||||||
if (scrollTimerRef.current) {
|
if (scrollTimerRef.current) {
|
||||||
clearTimeout(scrollTimerRef.current)
|
clearTimeout(scrollTimerRef.current)
|
||||||
}
|
}
|
||||||
|
setScrollWithAnimation(animated)
|
||||||
setScrollTarget('')
|
setScrollTarget('')
|
||||||
scrollTimerRef.current = setTimeout(() => {
|
scrollTimerRef.current = setTimeout(() => {
|
||||||
|
setScrollWithAnimation(animated)
|
||||||
setScrollTarget(CHAT_BOTTOM_ANCHOR_ID)
|
setScrollTarget(CHAT_BOTTOM_ANCHOR_ID)
|
||||||
scrollTimerRef.current = null
|
scrollTimerRef.current = null
|
||||||
}, 80)
|
}, 80)
|
||||||
@@ -373,8 +377,9 @@ export default function ChatDetail() {
|
|||||||
}
|
}
|
||||||
const msgs = await getMessages(conversationId, conversationSource, decodedConversationTypeName)
|
const msgs = await getMessages(conversationId, conversationSource, decodedConversationTypeName)
|
||||||
setMessages(msgs)
|
setMessages(msgs)
|
||||||
if (msgs.length > 0) {
|
const scrollMode = options.scrollToBottom ?? (!hasLoadedChatRef.current ? 'jump' : false)
|
||||||
scrollToBottom()
|
if (msgs.length > 0 && scrollMode) {
|
||||||
|
scrollToBottom(scrollMode === 'animate')
|
||||||
}
|
}
|
||||||
hasLoadedChatRef.current = true
|
hasLoadedChatRef.current = true
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -480,9 +485,15 @@ export default function ChatDetail() {
|
|||||||
if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) {
|
if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) {
|
||||||
skipNextAutoLoadRef.current = true
|
skipNextAutoLoadRef.current = true
|
||||||
setActiveConversationId(response.conversation_id)
|
setActiveConversationId(response.conversation_id)
|
||||||
await latestLoadChatDataRef.current(response.conversation_id, { showLoading: false })
|
await latestLoadChatDataRef.current(response.conversation_id, {
|
||||||
|
showLoading: false,
|
||||||
|
scrollToBottom: 'animate',
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
await latestLoadChatDataRef.current(activeConversationIdRef.current, { showLoading: false })
|
await latestLoadChatDataRef.current(activeConversationIdRef.current, {
|
||||||
|
showLoading: false,
|
||||||
|
scrollToBottom: 'animate',
|
||||||
|
})
|
||||||
}
|
}
|
||||||
Taro.showToast({ title: '留言已发送', icon: 'success' })
|
Taro.showToast({ title: '留言已发送', icon: 'success' })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -752,7 +763,12 @@ export default function ChatDetail() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<ScrollView className='chat-list' scrollY scrollWithAnimation scrollIntoView={scrollTarget}>
|
<ScrollView
|
||||||
|
className='chat-list'
|
||||||
|
scrollY
|
||||||
|
scrollWithAnimation={scrollWithAnimation}
|
||||||
|
scrollIntoView={scrollTarget}
|
||||||
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<View className='empty-chat'>
|
<View className='empty-chat'>
|
||||||
<Text className='empty-chat-text'>加载中...</Text>
|
<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_preview?: string | null
|
||||||
last_message_at?: string | null
|
last_message_at?: string | null
|
||||||
message_count: number
|
message_count: number
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChildConversationListResponse {
|
interface ChildConversationListResponse {
|
||||||
@@ -307,9 +309,10 @@ function canSendParentConversation(parentUserId: number | null, context: Binding
|
|||||||
return Boolean(parentUserId && parentUserId === context.currentUserId)
|
return Boolean(parentUserId && parentUserId === context.currentUserId)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getConversationRank(conversation: ChatConversation): number {
|
function compareConversationsByNewest(left: ChatConversation, right: ChatConversation): number {
|
||||||
if (isParentChildConversation(conversation.conversationTypeName)) return 0
|
const timeDiff = parseTime(right.sortAt) - parseTime(left.sortAt)
|
||||||
return 1
|
if (timeDiff !== 0) return timeDiff
|
||||||
|
return right.key.localeCompare(left.key)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBindingContext(): Promise<BindingContext> {
|
async function getBindingContext(): Promise<BindingContext> {
|
||||||
@@ -385,6 +388,7 @@ function toImConversation(item: ChildConversationItem, context: BindingContext):
|
|||||||
const parentUserId = isParentConversation ? parseNumericId(item.peer_id) : null
|
const parentUserId = isParentConversation ? parseNumericId(item.peer_id) : null
|
||||||
const canSend = isParentConversation ? canSendParentConversation(parentUserId, context) : false
|
const canSend = isParentConversation ? canSendParentConversation(parentUserId, context) : false
|
||||||
const parentConversationName = getParentConversationName(parentUserId, item.peer_id, item.peer_name, context)
|
const parentConversationName = getParentConversationName(parentUserId, item.peer_id, item.peer_name, context)
|
||||||
|
const sortAt = item.last_message_at || item.updated_at || item.created_at
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: item.conversation_id,
|
id: item.conversation_id,
|
||||||
@@ -400,8 +404,8 @@ function toImConversation(item: ChildConversationItem, context: BindingContext):
|
|||||||
: '其他家长和孩子的留言记录'
|
: '其他家长和孩子的留言记录'
|
||||||
: meta.description,
|
: meta.description,
|
||||||
lastMessage: item.last_message_preview || (isParentConversation ? (canSend ? '还没有消息,点进去发送第一条' : '暂无留言记录') : '暂无消息'),
|
lastMessage: item.last_message_preview || (isParentConversation ? (canSend ? '还没有消息,点进去发送第一条' : '暂无留言记录') : '暂无消息'),
|
||||||
time: formatListTime(item.last_message_at),
|
time: formatListTime(sortAt),
|
||||||
sortAt: item.last_message_at || '',
|
sortAt,
|
||||||
conversationTypeName: item.conversation_type_name,
|
conversationTypeName: item.conversation_type_name,
|
||||||
peerId: item.peer_id,
|
peerId: item.peer_id,
|
||||||
childId: context.childId,
|
childId: context.childId,
|
||||||
@@ -496,11 +500,7 @@ export async function getConversations(): Promise<ChatConversation[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return conversations.sort((left, right) => {
|
return conversations.sort(compareConversationsByNewest)
|
||||||
const rankDiff = getConversationRank(left) - getConversationRank(right)
|
|
||||||
if (rankDiff !== 0) return rankDiff
|
|
||||||
return parseTime(right.sortAt) - parseTime(left.sortAt)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMessages(
|
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_preview,
|
||||||
last_message_at,
|
last_message_at,
|
||||||
message_count,
|
message_count,
|
||||||
created_at
|
created_at,
|
||||||
|
updated_at
|
||||||
FROM im_conversations
|
FROM im_conversations
|
||||||
WHERE {where}
|
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
|
LIMIT :fetch_limit
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -247,6 +247,8 @@ def _row_to_conversation_item(
|
|||||||
last_message_preview=row["last_message_preview"],
|
last_message_preview=row["last_message_preview"],
|
||||||
last_message_at=row["last_message_at"],
|
last_message_at=row["last_message_at"],
|
||||||
message_count=int(row["message_count"]),
|
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_preview,
|
||||||
last_message_at,
|
last_message_at,
|
||||||
message_count,
|
message_count,
|
||||||
created_at
|
created_at,
|
||||||
|
updated_at
|
||||||
FROM im_conversations
|
FROM im_conversations
|
||||||
WHERE {where}
|
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
|
LIMIT :fetch_limit
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ class WechatMpBindStatusResponse(BaseModel):
|
|||||||
updated_at: str | None = None
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WechatMpNotificationLinkResponse(BaseModel):
|
||||||
|
route: str
|
||||||
|
params: dict[str, str | int | None]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/bind/start", response_model=WechatMpBindStartResponse)
|
@router.post("/bind/start", response_model=WechatMpBindStartResponse)
|
||||||
async def start_wechat_mp_bind(
|
async def start_wechat_mp_bind(
|
||||||
current_user_id: int = Depends(get_current_user_id),
|
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)
|
@router.get("/oauth/callback", response_class=HTMLResponse)
|
||||||
async def wechat_mp_oauth_callback(
|
async def wechat_mp_oauth_callback(
|
||||||
code: str = Query(default=""),
|
code: str = Query(default=""),
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ class ChildConversationItem(BaseModel):
|
|||||||
last_message_preview: str | None = None
|
last_message_preview: str | None = None
|
||||||
last_message_at: datetime | None = None
|
last_message_at: datetime | None = None
|
||||||
message_count: int
|
message_count: int
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
class ChildConversationListResponse(BaseModel):
|
class ChildConversationListResponse(BaseModel):
|
||||||
|
|||||||
@@ -47,6 +47,16 @@ PARTICIPANT_TYPE_NAMES = {
|
|||||||
CHILD_PARTICIPANT_TYPE: "child",
|
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_CONTENT_TYPE_TO_EXT = {
|
||||||
"audio/mpeg": "mp3",
|
"audio/mpeg": "mp3",
|
||||||
"audio/mp3": "mp3",
|
"audio/mp3": "mp3",
|
||||||
@@ -644,7 +654,7 @@ class ImService(DatabaseServiceBase):
|
|||||||
client_msg_id: str | None = None,
|
client_msg_id: str | None = None,
|
||||||
ext_json: dict[str, Any] | None = None,
|
ext_json: dict[str, Any] | None = None,
|
||||||
audio_content: bytes | 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()
|
normalized_media_file_key = str(media_file_key or "").strip()
|
||||||
if not normalized_media_file_key:
|
if not normalized_media_file_key:
|
||||||
raise HTTPException(status_code=400, detail="media_file_key is required")
|
raise HTTPException(status_code=400, detail="media_file_key is required")
|
||||||
@@ -656,6 +666,7 @@ class ImService(DatabaseServiceBase):
|
|||||||
if not family_identities:
|
if not family_identities:
|
||||||
raise HTTPException(status_code=404, detail="device family has no members")
|
raise HTTPException(status_code=404, detail="device family has no members")
|
||||||
first_result: ConversationMessageCreateResult | None = None
|
first_result: ConversationMessageCreateResult | None = None
|
||||||
|
notification_targets: list[DeviceParentLeaveMessageTarget] = []
|
||||||
for owner_identity in family_identities:
|
for owner_identity in family_identities:
|
||||||
member_client_msg_id = (client_msg_id or "").strip()
|
member_client_msg_id = (client_msg_id or "").strip()
|
||||||
if len(family_identities) > 1 or not member_client_msg_id:
|
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,
|
client_msg_id=member_client_msg_id,
|
||||||
ext_json=ext_json,
|
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:
|
if audio_content:
|
||||||
await self.schedule_message_media_duration_parse(
|
await self.schedule_message_media_duration_parse(
|
||||||
message_id=member_result.message.id,
|
message_id=member_result.message.id,
|
||||||
@@ -695,6 +716,7 @@ class ImService(DatabaseServiceBase):
|
|||||||
child_name=family_identities[0].child_name,
|
child_name=family_identities[0].child_name,
|
||||||
),
|
),
|
||||||
first_result,
|
first_result,
|
||||||
|
notification_targets,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
await db_session.rollback()
|
await db_session.rollback()
|
||||||
|
|||||||
@@ -1,20 +1,34 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urlencode
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from banban.service.im import DeviceParentLeaveMessageTarget
|
||||||
from config import settings
|
from config import settings
|
||||||
from services.database_service_base import DatabaseServiceBase
|
from services.database_service_base import DatabaseServiceBase
|
||||||
from utils.logger import session_logger
|
from utils.logger import session_logger
|
||||||
|
|
||||||
|
|
||||||
WECHAT_MP_ACCOUNT_TYPE = "service_account"
|
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):
|
class WechatMpNotificationService(DatabaseServiceBase):
|
||||||
@@ -99,6 +113,113 @@ class WechatMpNotificationService(DatabaseServiceBase):
|
|||||||
finally:
|
finally:
|
||||||
await db_session.close()
|
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:
|
def schedule_leave_message_notification(self, **kwargs: Any) -> None:
|
||||||
self._schedule_best_effort(
|
self._schedule_best_effort(
|
||||||
self.notify_leave_message_best_effort(**kwargs),
|
self.notify_leave_message_best_effort(**kwargs),
|
||||||
@@ -120,21 +241,34 @@ class WechatMpNotificationService(DatabaseServiceBase):
|
|||||||
*,
|
*,
|
||||||
device_id: str,
|
device_id: str,
|
||||||
child_name: str | None = None,
|
child_name: str | None = None,
|
||||||
|
targets: list[DeviceParentLeaveMessageTarget | Mapping[str, Any]] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if not self.is_configured():
|
if not self.is_configured():
|
||||||
return
|
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:
|
if not recipients:
|
||||||
session_logger.info(device_id, "wechat_mp", "留言公众号推送跳过:没有绑定服务号openid的家长")
|
session_logger.info(device_id, "wechat_mp", "留言公众号推送跳过:没有绑定服务号openid的家长")
|
||||||
return
|
return
|
||||||
await self._send_to_recipients(
|
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,
|
device_id=device_id,
|
||||||
notification_type="leave_message",
|
notification_type="leave_message",
|
||||||
recipients=recipients,
|
openid=recipient.openid,
|
||||||
title=child_name or device_id,
|
title=child_name or device_id,
|
||||||
condition="收到新的设备留言",
|
condition="收到新的设备留言",
|
||||||
page=settings.wechat_mp_chat_page,
|
page=page,
|
||||||
dedup_seconds=max(0, int(settings.wechat_mp_leave_message_dedup_seconds or 0)),
|
dedup_seconds=dedup_seconds,
|
||||||
)
|
)
|
||||||
|
|
||||||
def schedule_low_battery_notification(self, *, device_id: str, power: Any, child_name: str | None = None) -> None:
|
def schedule_low_battery_notification(self, *, device_id: str, power: Any, child_name: str | None = None) -> None:
|
||||||
@@ -240,6 +374,33 @@ class WechatMpNotificationService(DatabaseServiceBase):
|
|||||||
return
|
return
|
||||||
|
|
||||||
for openid in recipients:
|
for openid in recipients:
|
||||||
|
await self._send_to_recipient(
|
||||||
|
device_id=device_id,
|
||||||
|
notification_type=notification_type,
|
||||||
|
openid=openid,
|
||||||
|
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(
|
if self._is_deduped(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
notification_type=notification_type,
|
notification_type=notification_type,
|
||||||
@@ -247,11 +408,11 @@ class WechatMpNotificationService(DatabaseServiceBase):
|
|||||||
dedup_seconds=dedup_seconds,
|
dedup_seconds=dedup_seconds,
|
||||||
):
|
):
|
||||||
session_logger.info(device_id, "wechat_mp", f"公众号推送防抖跳过: type={notification_type}")
|
session_logger.info(device_id, "wechat_mp", f"公众号推送防抖跳过: type={notification_type}")
|
||||||
continue
|
return
|
||||||
|
|
||||||
result = await self._send_template_message(
|
result = await self._send_template_message(
|
||||||
openid=openid,
|
openid=openid,
|
||||||
template_id=template_id,
|
template_id=resolved_template_id,
|
||||||
title=title,
|
title=title,
|
||||||
condition=condition,
|
condition=condition,
|
||||||
page=page,
|
page=page,
|
||||||
@@ -306,12 +467,16 @@ class WechatMpNotificationService(DatabaseServiceBase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
async def _list_device_family_mp_openids(self, *, device_id: str) -> list[str]:
|
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()
|
db_session = await self.get_session()
|
||||||
try:
|
try:
|
||||||
result = await db_session.execute(
|
result = await db_session.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
SELECT pwa.openid
|
SELECT recipients.user_id, pwa.openid
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
db.owner_user_id AS user_id,
|
db.owner_user_id AS user_id,
|
||||||
@@ -351,15 +516,20 @@ class WechatMpNotificationService(DatabaseServiceBase):
|
|||||||
"account_type": WECHAT_MP_ACCOUNT_TYPE,
|
"account_type": WECHAT_MP_ACCOUNT_TYPE,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
openids: list[str] = []
|
recipients: list[WechatMpRecipient] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for row in result.mappings().all():
|
for row in result.mappings().all():
|
||||||
openid = str(row["openid"]).strip()
|
openid = str(row["openid"]).strip()
|
||||||
if not openid or openid in seen:
|
if not openid or openid in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(openid)
|
seen.add(openid)
|
||||||
openids.append(openid)
|
recipients.append(
|
||||||
return openids
|
WechatMpRecipient(
|
||||||
|
user_id=int(row["user_id"]),
|
||||||
|
openid=openid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return recipients
|
||||||
finally:
|
finally:
|
||||||
await db_session.close()
|
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')}")
|
raise RuntimeError(f"wechat mp user info failed: errcode={data.get('errcode')} errmsg={data.get('errmsg')}")
|
||||||
return data
|
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:
|
def _template_id(self, notification_type: str) -> str:
|
||||||
default_template_id = (settings.wechat_mp_template_id or "").strip()
|
default_template_id = (settings.wechat_mp_template_id or "").strip()
|
||||||
if notification_type == "leave_message":
|
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:
|
def _mark_sent(self, *, device_id: str, notification_type: str, openid: str) -> None:
|
||||||
self._last_sent_at[(device_id, notification_type, openid)] = time.time()
|
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
|
@staticmethod
|
||||||
def _clip_template_value(value: str, max_len: int) -> str:
|
def _clip_template_value(value: str, max_len: int) -> str:
|
||||||
text = str(value or "").strip()
|
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_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_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_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_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_oauth_redirect_uri: str = Field(default="", validation_alias="WECHAT_MP_OAUTH_REDIRECT_URI")
|
||||||
wechat_mp_bind_success_page: str = Field(
|
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()
|
media_file_key = str(params.get("media_file_key") or params.get("audio_url") or "").strip()
|
||||||
if media_file_key:
|
if media_file_key:
|
||||||
try:
|
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,
|
device_id=device_id,
|
||||||
media_file_key=media_file_key,
|
media_file_key=media_file_key,
|
||||||
media_duration_ms=params.get("media_duration_ms"),
|
media_duration_ms=params.get("media_duration_ms"),
|
||||||
@@ -505,6 +505,7 @@ class TalkingQMQTTService:
|
|||||||
wechat_mp_notification_service.schedule_leave_message_notification(
|
wechat_mp_notification_service.schedule_leave_message_notification(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
child_name=device_identity.child_name,
|
child_name=device_identity.child_name,
|
||||||
|
targets=notification_targets,
|
||||||
)
|
)
|
||||||
logger.info(device_id, "", f"[短按留言] 设备 {device_id} 留言已写入家长会话")
|
logger.info(device_id, "", f"[短按留言] 设备 {device_id} 留言已写入家长会话")
|
||||||
await self._publish(
|
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,
|
content_type=media_mime_type,
|
||||||
extension=extension,
|
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,
|
device_id=device_id,
|
||||||
media_file_key=stored_audio.file_key,
|
media_file_key=stored_audio.file_key,
|
||||||
media_mime_type=media_mime_type,
|
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(
|
wechat_mp_notification_service.schedule_leave_message_notification(
|
||||||
device_id=device_id,
|
device_id=device_id,
|
||||||
child_name=device_identity.child_name,
|
child_name=device_identity.child_name,
|
||||||
|
targets=notification_targets,
|
||||||
)
|
)
|
||||||
session_logger.info(
|
session_logger.info(
|
||||||
device_id,
|
device_id,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from urllib.parse import parse_qs, urlparse
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
import pytest
|
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):
|
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_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_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_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_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_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)
|
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):
|
async def fake_list_recipients(*, device_id):
|
||||||
assert device_id == "TalkingQ_XQSN00001007"
|
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):
|
async def fake_send_template_message(**kwargs):
|
||||||
sent.append(kwargs)
|
sent.append(kwargs)
|
||||||
return {"ok": True, "msgid": "msg-id"}
|
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)
|
monkeypatch.setattr(service, "_send_template_message", fake_send_template_message)
|
||||||
|
|
||||||
await service.notify_leave_message(device_id="TalkingQ_XQSN00001007", child_name="小明")
|
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": "小明",
|
"title": "小明",
|
||||||
"condition": "收到新的设备留言",
|
"condition": "收到新的设备留言",
|
||||||
"page": "pages/chat/detail/index",
|
"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):
|
async def fake_list_recipients(*, device_id):
|
||||||
del device_id
|
del device_id
|
||||||
return ["openid-1"]
|
return [WechatMpRecipient(user_id=4, openid="openid-1")]
|
||||||
|
|
||||||
async def fake_send_template_message(**kwargs):
|
async def fake_send_template_message(**kwargs):
|
||||||
sent.append(kwargs["openid"])
|
sent.append(kwargs["openid"])
|
||||||
return {"ok": True, "msgid": "msg-id"}
|
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)
|
monkeypatch.setattr(service, "_send_template_message", fake_send_template_message)
|
||||||
|
|
||||||
await service.notify_leave_message(device_id="TalkingQ_XQSN00001007", child_name="小明")
|
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"]
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_bind_url_persists_state_and_builds_oauth_url(monkeypatch):
|
async def test_bind_url_persists_state_and_builds_oauth_url(monkeypatch):
|
||||||
configure_enabled_wechat_mp(monkeypatch)
|
configure_enabled_wechat_mp(monkeypatch)
|
||||||
|
|||||||
Reference in New Issue
Block a user