diff --git a/MeAgent/navigation/Screens.js b/MeAgent/navigation/Screens.js index 2b9ffccd..343b9979 100644 --- a/MeAgent/navigation/Screens.js +++ b/MeAgent/navigation/Screens.js @@ -55,6 +55,7 @@ import PostDetail from "../src/screens/Social/PostDetail"; import CreatePost from "../src/screens/Social/CreatePost"; import CreateChannel from "../src/screens/Social/CreateChannel"; import MemberList from "../src/screens/Social/MemberList"; +import SocialNotifications from "../src/screens/Social/Notifications"; // 新个人中心页面 import { ProfileScreen as NewProfileScreen } from "../src/screens/Profile"; @@ -452,6 +453,13 @@ function NewProfileStack(props) { cardStyle: { backgroundColor: "#0A0A0F" }, }} /> + ); } diff --git a/MeAgent/src/screens/Profile/ProfileScreen.js b/MeAgent/src/screens/Profile/ProfileScreen.js index 68d8d306..9b199ea4 100644 --- a/MeAgent/src/screens/Profile/ProfileScreen.js +++ b/MeAgent/src/screens/Profile/ProfileScreen.js @@ -356,6 +356,8 @@ const ProfileScreen = () => { navigation.navigate('WatchlistDrawer'); } else if (item.route === 'Subscription') { navigation.navigate('Subscription'); + } else if (item.route === 'Messages') { + navigation.navigate('Messages'); } else if (item.route === 'Settings') { navigation.navigate('SettingsDrawer'); } else if (item.route === 'About') { diff --git a/MeAgent/src/screens/Social/Notifications.js b/MeAgent/src/screens/Social/Notifications.js index 3a806586..c5fb24f4 100644 --- a/MeAgent/src/screens/Social/Notifications.js +++ b/MeAgent/src/screens/Social/Notifications.js @@ -1,9 +1,9 @@ /** * 通知页面 - * 显示社区通知列表 + * 显示社区通知和官方公告 */ -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { FlatList, RefreshControl, @@ -28,10 +28,18 @@ import { fetchNotifications, markNotificationRead, markAllNotificationsRead, + fetchAnnouncements, + markAnnouncementRead, + markAllAnnouncementsRead, } from '../../store/slices/socialSlice'; // 通知类型配置 const NOTIFICATION_CONFIG = { + announcement: { + icon: 'megaphone', + color: '#D4AF37', + label: '官方公告', + }, reply: { icon: 'chatbubble', color: '#7C3AED', @@ -52,15 +60,21 @@ const NOTIFICATION_CONFIG = { color: '#3B82F6', label: '关注了你', }, + message: { + icon: 'chatbubble-ellipses', + color: '#06B6D4', + label: '发来消息', + }, system: { - icon: 'megaphone', - color: '#D4AF37', + icon: 'information-circle', + color: '#6B7280', label: '系统通知', }, }; // 格式化相对时间 const formatRelativeTime = (dateStr) => { + if (!dateStr) return ''; const date = new Date(dateStr); const now = new Date(); const diff = now - date; @@ -83,73 +97,147 @@ const Notifications = ({ navigation }) => { const dispatch = useDispatch(); const insets = useSafeAreaInsets(); - const communityState = useSelector((state) => state.social); - const notifications = communityState?.notifications || []; - const notificationsHasMore = communityState?.notificationsHasMore ?? true; - const loading = communityState?.loading || {}; - const unreadCount = communityState?.unreadCount || 0; + const socialState = useSelector((state) => state.social); + const notifications = socialState?.notifications || []; + const announcements = socialState?.announcements || []; + const notificationsHasMore = socialState?.notificationsHasMore ?? true; + const announcementsHasMore = socialState?.announcementsHasMore ?? true; + const loading = socialState?.loading || {}; + const notificationUnreadCount = socialState?.unreadCount || 0; + const announcementUnreadCount = socialState?.announcementUnreadCount || 0; const [refreshing, setRefreshing] = useState(false); - const [page, setPage] = useState(1); + const [notificationPage, setNotificationPage] = useState(1); + const [announcementPage, setAnnouncementPage] = useState(1); - // 加载通知 + // 合并通知和公告,按时间排序 + const mergedItems = useMemo(() => { + // 将公告转换为统一格式 + const formattedAnnouncements = announcements.map(a => ({ + id: `announcement-${a.id}`, + originalId: a.id, + type: 'announcement', + isAnnouncement: true, + title: a.title, + content: a.content, + targetUrl: a.targetUrl, + isRead: a.isRead, + createdAt: a.createdAt, + })); + + // 合并并按时间排序 + const merged = [...notifications, ...formattedAnnouncements]; + merged.sort((a, b) => { + const dateA = new Date(a.createdAt || 0); + const dateB = new Date(b.createdAt || 0); + return dateB - dateA; + }); + + return merged; + }, [notifications, announcements]); + + // 总未读数 + const totalUnreadCount = notificationUnreadCount + announcementUnreadCount; + + // 加载数据 useEffect(() => { dispatch(fetchNotifications({ page: 1 })); - setPage(1); + dispatch(fetchAnnouncements({ page: 1 })); + setNotificationPage(1); + setAnnouncementPage(1); }, [dispatch]); // 下拉刷新 const handleRefresh = useCallback(async () => { setRefreshing(true); - await dispatch(fetchNotifications({ page: 1 })); - setPage(1); + await Promise.all([ + dispatch(fetchNotifications({ page: 1 })), + dispatch(fetchAnnouncements({ page: 1 })), + ]); + setNotificationPage(1); + setAnnouncementPage(1); setRefreshing(false); }, [dispatch]); // 加载更多 const handleLoadMore = useCallback(() => { - if (loading.notifications || !notificationsHasMore) return; + const isLoadingAny = loading.notifications || loading.announcements; + if (isLoadingAny) return; - const nextPage = page + 1; - dispatch(fetchNotifications({ page: nextPage })); - setPage(nextPage); - }, [dispatch, page, loading.notifications, notificationsHasMore]); + // 优先加载有更多数据的类型 + if (notificationsHasMore) { + const nextPage = notificationPage + 1; + dispatch(fetchNotifications({ page: nextPage })); + setNotificationPage(nextPage); + } + if (announcementsHasMore) { + const nextPage = announcementPage + 1; + dispatch(fetchAnnouncements({ page: nextPage })); + setAnnouncementPage(nextPage); + } + }, [dispatch, notificationPage, announcementPage, loading, notificationsHasMore, announcementsHasMore]); // 标记已读 - const handleMarkRead = useCallback((notification) => { - if (!notification.isRead) { - dispatch(markNotificationRead(notification.id)); - } - // 根据通知类型跳转 - if (notification.targetType === 'post' && notification.targetId) { - navigation.navigate('PostDetail', { - post: { id: notification.targetId }, - channel: {}, - }); + const handleMarkRead = useCallback((item) => { + if (item.isAnnouncement) { + // 公告 + if (!item.isRead) { + dispatch(markAnnouncementRead(item.originalId)); + } + // 如果有跳转链接,可以跳转 + if (item.targetUrl) { + // TODO: 处理 URL 跳转 + console.log('跳转到:', item.targetUrl); + } + } else { + // 普通通知 + if (!item.isRead) { + dispatch(markNotificationRead(item.id)); + } + // 根据通知类型跳转 + if (item.targetType === 'post' && item.targetId) { + navigation.navigate('PostDetail', { + post: { id: item.targetId }, + channel: {}, + }); + } } }, [dispatch, navigation]); // 标记全部已读 const handleMarkAllRead = useCallback(() => { - dispatch(markAllNotificationsRead()); - }, [dispatch]); + if (notificationUnreadCount > 0) { + dispatch(markAllNotificationsRead()); + } + if (announcementUnreadCount > 0) { + dispatch(markAllAnnouncementsRead()); + } + }, [dispatch, notificationUnreadCount, announcementUnreadCount]); + + // 检查是否可以返回(从个人中心进入时需要返回按钮) + const canGoBack = navigation.canGoBack(); // 渲染头部 const renderHeader = () => ( + {canGoBack && ( + navigation.goBack()} mr={2}> + + + )} - 通知 + 消息中心 - {unreadCount > 0 ? ( + {totalUnreadCount > 0 ? ( - {unreadCount > 99 ? '99+' : String(unreadCount)} + {totalUnreadCount > 99 ? '99+' : String(totalUnreadCount)} ) : null} - {unreadCount > 0 ? ( + {totalUnreadCount > 0 ? ( 全部已读 @@ -159,14 +247,15 @@ const Notifications = ({ navigation }) => { ); - // 渲染通知项 - const renderNotificationItem = ({ item: notification }) => { - if (!notification) return null; + // 渲染通知/公告项 + const renderItem = ({ item }) => { + if (!item) return null; - const config = NOTIFICATION_CONFIG[notification.type] || NOTIFICATION_CONFIG.system; + const isAnnouncement = item.isAnnouncement; + const config = NOTIFICATION_CONFIG[item.type] || NOTIFICATION_CONFIG.system; return ( - handleMarkRead(notification)}> + handleMarkRead(item)}> {({ isPressed }) => ( { bg={ isPressed ? 'rgba(255, 255, 255, 0.08)' - : notification.isRead + : item.isRead ? 'transparent' + : isAnnouncement + ? 'rgba(212, 175, 55, 0.05)' : 'rgba(124, 58, 237, 0.05)' } - borderWidth={notification.isRead ? 0 : 1} - borderColor="rgba(124, 58, 237, 0.1)" + borderWidth={item.isRead ? 0 : 1} + borderColor={isAnnouncement ? 'rgba(212, 175, 55, 0.15)' : 'rgba(124, 58, 237, 0.1)'} > {/* 图标/头像 */} - {notification.senderAvatar ? ( + {!isAnnouncement && item.senderAvatar ? ( - {notification.senderName?.[0]?.toUpperCase() || '?'} + {item.senderName?.[0]?.toUpperCase() || '?'} ) : ( { )} {/* 未读标记 */} - {!notification.isRead && ( + {!item.isRead && ( { w={3} h={3} rounded="full" - bg="primary.500" + bg={isAnnouncement ? '#D4AF37' : 'primary.500'} /> )} @@ -224,29 +315,44 @@ const Notifications = ({ navigation }) => { {/* 内容 */} - {notification.senderName && ( - - {notification.senderName} - + {isAnnouncement ? ( + + + {config.label} + + {item.title && ( + + · {item.title} + + )} + + ) : ( + <> + {item.senderName && ( + + {item.senderName} + + )} + + {config.label} + + )} - - {config.label} - - {notification.content && ( + {item.content && ( - {notification.content} + {item.content} )} - {formatRelativeTime(notification.createdAt)} + {formatRelativeTime(item.createdAt)} @@ -260,12 +366,12 @@ const Notifications = ({ navigation }) => { // 渲染空状态 const renderEmpty = () => { - if (loading.notifications) { + if (loading.notifications || loading.announcements) { return (
- 加载通知... + 加载消息...
); @@ -275,7 +381,7 @@ const Notifications = ({ navigation }) => {
- 暂无通知 + 暂无消息 当有人回复或提及你时会收到通知 @@ -286,9 +392,10 @@ const Notifications = ({ navigation }) => { // 渲染加载更多 const renderFooter = () => { - if (!notificationsHasMore || notifications.length === 0) return null; + const hasMore = notificationsHasMore || announcementsHasMore; + if (!hasMore || mergedItems.length === 0) return null; - if (loading.notifications) { + if (loading.notifications || loading.announcements) { return (
@@ -300,11 +407,11 @@ const Notifications = ({ navigation }) => { }; return ( - + item?.id || `notification-${index}`} + data={mergedItems} + renderItem={renderItem} + keyExtractor={(item, index) => item?.id || `item-${index}`} ListHeaderComponent={renderHeader} ListEmptyComponent={renderEmpty} ListFooterComponent={renderFooter} diff --git a/MeAgent/src/services/socialService.js b/MeAgent/src/services/socialService.js index 45f4d6f0..be2bd492 100644 --- a/MeAgent/src/services/socialService.js +++ b/MeAgent/src/services/socialService.js @@ -551,6 +551,98 @@ export const uploadService = { }, }; +/** + * 公告服务 - 获取和管理官方公告 + */ +export const announcementService = { + /** + * 获取公告列表 + * @param {object} options - 分页和筛选选项 + * @param {number} options.page - 页码,默认 1 + * @param {number} options.limit - 每页数量,默认 20 + * @param {boolean} options.unreadOnly - 是否只显示未读,默认 false + * @returns {Promise<{data: Array, hasMore: boolean, unreadCount: number}>} + */ + getAnnouncements: async (options = {}) => { + try { + const { page = 1, limit = 20, unreadOnly = false } = options; + + const params = new URLSearchParams({ + page: String(page), + limit: String(limit), + unread_only: String(unreadOnly), + }); + + const response = await fetch(`${API_BASE_URL}/api/social/announcements?${params}`, { + method: 'GET', + credentials: 'include', + }); + + if (!response.ok) { + console.warn('[SocialService] getAnnouncements 失败,返回空列表'); + return { data: [], hasMore: false, unreadCount: 0 }; + } + + const result = await response.json(); + const data = result.data || {}; + + return { + data: data.list || [], + hasMore: data.hasMore || false, + unreadCount: data.unreadCount || 0, + }; + } catch (error) { + console.warn('[SocialService] getAnnouncements 错误:', error); + return { data: [], hasMore: false, unreadCount: 0 }; + } + }, + + /** + * 标记公告为已读 + * @param {number} announcementId - 公告 ID + */ + markAsRead: async (announcementId) => { + try { + const response = await fetch( + `${API_BASE_URL}/api/social/announcements/${announcementId}/read`, + { + method: 'POST', + credentials: 'include', + } + ); + + if (!response.ok) { + throw new Error('标记已读失败'); + } + } catch (error) { + console.warn('[SocialService] markAnnouncementAsRead 错误:', error); + throw error; + } + }, + + /** + * 标记所有公告为已读 + */ + markAllAsRead: async () => { + try { + const response = await fetch( + `${API_BASE_URL}/api/social/announcements/read-all`, + { + method: 'POST', + credentials: 'include', + } + ); + + if (!response.ok) { + throw new Error('标记全部已读失败'); + } + } catch (error) { + console.warn('[SocialService] markAllAnnouncementsAsRead 错误:', error); + throw error; + } + }, +}; + /** * 通知服务 - 使用后端 API */ @@ -757,6 +849,7 @@ export default { postService, memberService, notificationService, + announcementService, uploadService, CHANNEL_TYPES, CHANNEL_CATEGORIES, diff --git a/MeAgent/src/store/slices/socialSlice.js b/MeAgent/src/store/slices/socialSlice.js index e9f436f3..9d217882 100644 --- a/MeAgent/src/store/slices/socialSlice.js +++ b/MeAgent/src/store/slices/socialSlice.js @@ -10,6 +10,7 @@ import { postService, memberService, notificationService, + announcementService, } from '../../services/socialService'; // ============ Async Thunks ============ @@ -174,6 +175,50 @@ export const markAllNotificationsRead = createAsyncThunk( } ); +// 获取公告列表 +export const fetchAnnouncements = createAsyncThunk( + 'social/fetchAnnouncements', + async (options = {}, { rejectWithValue }) => { + try { + const response = await announcementService.getAnnouncements(options); + return { + announcements: response.data || [], + hasMore: response.hasMore, + unreadCount: response.unreadCount || 0, + page: options.page || 1, + }; + } catch (error) { + return rejectWithValue(error.message); + } + } +); + +// 标记公告已读 +export const markAnnouncementRead = createAsyncThunk( + 'social/markAnnouncementRead', + async (announcementId, { rejectWithValue }) => { + try { + await announcementService.markAsRead(announcementId); + return announcementId; + } catch (error) { + return rejectWithValue(error.message); + } + } +); + +// 标记所有公告已读 +export const markAllAnnouncementsRead = createAsyncThunk( + 'social/markAllAnnouncementsRead', + async (_, { rejectWithValue }) => { + try { + await announcementService.markAllAsRead(); + return true; + } catch (error) { + return rejectWithValue(error.message); + } + } +); + // ============ Initial State ============ const initialState = { @@ -201,6 +246,11 @@ const initialState = { notificationsHasMore: true, unreadCount: 0, + // 公告相关 + announcements: [], + announcementsHasMore: true, + announcementUnreadCount: 0, + // 加载状态 loading: { channels: false, @@ -210,6 +260,7 @@ const initialState = { replies: false, members: false, notifications: false, + announcements: false, sending: false, }, @@ -318,6 +369,16 @@ const socialSlice = createSlice({ state.unreadCount += 1; }, + // 设置公告未读数量 + setAnnouncementUnreadCount: (state, action) => { + state.announcementUnreadCount = action.payload; + }, + + // 增加公告未读数量 + incrementAnnouncementUnreadCount: (state) => { + state.announcementUnreadCount += 1; + }, + // 清除错误 clearError: (state) => { state.error = null; @@ -495,6 +556,47 @@ const socialSlice = createSlice({ n.isRead = true; }); state.unreadCount = 0; + }) + + // 获取公告 + .addCase(fetchAnnouncements.pending, (state) => { + state.loading.announcements = true; + }) + .addCase(fetchAnnouncements.fulfilled, (state, action) => { + state.loading.announcements = false; + const { announcements, hasMore, unreadCount, page } = action.payload; + // 第一页替换,后续页追加 + if (page === 1) { + state.announcements = announcements; + } else { + state.announcements = [...state.announcements, ...announcements]; + } + state.announcementsHasMore = hasMore; + if (typeof unreadCount === 'number') { + state.announcementUnreadCount = unreadCount; + } + }) + .addCase(fetchAnnouncements.rejected, (state, action) => { + state.loading.announcements = false; + state.error = action.payload; + }) + + // 标记公告已读 + .addCase(markAnnouncementRead.fulfilled, (state, action) => { + const announcementId = action.payload; + const announcement = state.announcements.find(a => a.id === announcementId); + if (announcement && !announcement.isRead) { + announcement.isRead = true; + state.announcementUnreadCount = Math.max(0, state.announcementUnreadCount - 1); + } + }) + + // 标记所有公告已读 + .addCase(markAllAnnouncementsRead.fulfilled, (state) => { + state.announcements.forEach(a => { + a.isRead = true; + }); + state.announcementUnreadCount = 0; }); }, }); @@ -513,6 +615,8 @@ export const { removeTypingUser, setUnreadCount, incrementUnreadCount, + setAnnouncementUnreadCount, + incrementAnnouncementUnreadCount, clearError, resetSocialState, } = socialSlice.actions; diff --git a/apns_push.py b/apns_push.py index 2e4f8085..913331a9 100644 --- a/apns_push.py +++ b/apns_push.py @@ -206,11 +206,12 @@ def send_social_notification(notification, device_tokens: List[str]) -> dict: # 根据通知类型设置标题 type_labels = { + 'announcement': '📣 官方公告', 'reply': '💬 新回复', 'mention': '📢 有人@你', 'like': '❤️ 收到点赞', 'follow': '👋 新关注', - 'system': '📣 系统通知', + 'system': '🔔 系统通知', 'message': '💬 新消息', } title = type_labels.get(notif_type, '📣 新通知') diff --git a/app.py b/app.py index 94259622..f71ad397 100755 --- a/app.py +++ b/app.py @@ -1619,6 +1619,64 @@ class SocialNotification(db.Model): } +class Announcement(db.Model): + """官方公告表""" + __tablename__ = 'announcements' + + id = db.Column(db.Integer, primary_key=True) + title = db.Column(db.String(200), nullable=True) + content = db.Column(db.Text, nullable=False) + target_url = db.Column(db.String(500), nullable=True) # 点击跳转链接 + + # 发布者信息 + created_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True) + + # 状态 + is_active = db.Column(db.Boolean, default=True, index=True) # 是否有效(可用于软删除) + is_pushed = db.Column(db.Boolean, default=False) # 是否已推送 + + # 时间 + created_at = db.Column(db.DateTime, default=beijing_now, index=True) + updated_at = db.Column(db.DateTime, default=beijing_now, onupdate=beijing_now) + + # 统计 + read_count = db.Column(db.Integer, default=0) # 已读人数 + + # 关系 + creator = db.relationship('User', backref='created_announcements') + + def to_dict(self, user_read_status=None): + return { + 'id': self.id, + 'title': self.title, + 'content': self.content, + 'targetUrl': self.target_url, + 'createdBy': self.created_by, + 'createdAt': self.created_at.isoformat() if self.created_at else None, + 'readCount': self.read_count, + 'isRead': user_read_status, # 当前用户是否已读 + } + + +class AnnouncementReadStatus(db.Model): + """公告已读状态表""" + __tablename__ = 'announcement_read_status' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, index=True) + announcement_id = db.Column(db.Integer, db.ForeignKey('announcements.id'), nullable=False, index=True) + read_at = db.Column(db.DateTime, default=beijing_now) + + # 联合唯一索引 + __table_args__ = ( + db.UniqueConstraint('user_id', 'announcement_id', name='uix_user_announcement'), + ) + + # 关系 + user = db.relationship('User', backref='announcement_reads') + announcement = db.relationship('Announcement', backref='read_statuses') + + class SubscriptionPlan(db.Model): """订阅套餐表""" __tablename__ = 'subscription_plans' @@ -21547,6 +21605,355 @@ def send_social_notification_api(): return jsonify({'code': 500, 'message': str(e)}), 500 +# ==================== 官方公告 API ==================== + +@app.route('/api/social/announcements', methods=['GET']) +@login_required +def get_announcements(): + """ + 获取公告列表(用户端) + 返回所有有效公告及当前用户的已读状态 + + 查询参数: + - page: 页码,默认 1 + - limit: 每页数量,默认 20 + - unread_only: 是否只看未读,默认 false + """ + try: + page = request.args.get('page', 1, type=int) + limit = min(request.args.get('limit', 20, type=int), 50) + unread_only = request.args.get('unread_only', 'false').lower() == 'true' + + # 查询有效公告 + query = db.session.query(Announcement).filter( + Announcement.is_active == True + ) + + # 如果只看未读,排除已读的公告 + if unread_only: + read_announcement_ids = db.session.query( + AnnouncementReadStatus.announcement_id + ).filter( + AnnouncementReadStatus.user_id == current_user.id + ).subquery() + query = query.filter(~Announcement.id.in_(read_announcement_ids)) + + # 按创建时间倒序 + query = query.order_by(Announcement.created_at.desc()) + + total = query.count() + announcements = query.offset((page - 1) * limit).limit(limit).all() + + # 获取当前用户对这些公告的已读状态 + announcement_ids = [a.id for a in announcements] + read_status_query = db.session.query( + AnnouncementReadStatus.announcement_id + ).filter( + AnnouncementReadStatus.user_id == current_user.id, + AnnouncementReadStatus.announcement_id.in_(announcement_ids) + ).all() + read_ids = {rs[0] for rs in read_status_query} + + # 统计未读数量 + unread_count = db.session.query(Announcement).filter( + Announcement.is_active == True, + ~Announcement.id.in_( + db.session.query(AnnouncementReadStatus.announcement_id).filter( + AnnouncementReadStatus.user_id == current_user.id + ) + ) + ).count() + + return jsonify({ + 'code': 200, + 'message': 'success', + 'data': { + 'list': [ + a.to_dict(user_read_status=(a.id in read_ids)) + for a in announcements + ], + 'total': total, + 'hasMore': page * limit < total, + 'unreadCount': unread_count + } + }) + + except Exception as e: + app.logger.error(f"获取公告列表失败: {e}") + return jsonify({'code': 500, 'message': str(e)}), 500 + + +@app.route('/api/social/announcements', methods=['POST']) +@login_required +def publish_announcement(): + """ + 发布官方公告(管理员专用) + 创建一条公告记录并推送给所有用户 + + 请求体: + { + "title": "公告标题", + "content": "公告内容", + "target_url": "可选,点击跳转的链接", + "push_to_all": true // 是否推送给所有用户,默认 true + } + """ + try: + # 检查管理员权限(user_id=1 或有 admin 标记) + if current_user.id != 1 and not getattr(current_user, 'is_admin', False): + return jsonify({'code': 403, 'message': '无权限发布公告'}), 403 + + data = request.get_json() + if not data: + return jsonify({'code': 400, 'message': '缺少请求体'}), 400 + + title = data.get('title', '').strip() + content = data.get('content', '').strip() + target_url = data.get('target_url') + push_to_all = data.get('push_to_all', True) + + if not title and not content: + return jsonify({'code': 400, 'message': '标题和内容不能都为空'}), 400 + + # 创建公告记录 + announcement = Announcement( + title=title or None, + content=content, + target_url=target_url, + created_by=current_user.id, + is_active=True, + is_pushed=push_to_all, + ) + db.session.add(announcement) + db.session.commit() + + print(f'[公告] 已创建公告 ID={announcement.id}') + + # 组合推送内容 + notification_content = f"【{title}】{content}" if title else content + + # 推送通知 + push_result = {'success': 0, 'failed': 0} + if push_to_all: + try: + from apns_push import send_push_notification + + # 获取所有活跃设备 token + device_tokens_query = db.session.query(UserDeviceToken.device_token).filter( + UserDeviceToken.is_active == True + ).all() + tokens = [t[0] for t in device_tokens_query] + + if tokens: + print(f'[公告 APNs] 准备推送到 {len(tokens)} 个设备') + push_result = send_push_notification( + device_tokens=tokens, + title='📣 官方公告', + body=notification_content[:100] + ('...' if len(notification_content) > 100 else ''), + data={ + 'type': 'announcement', + 'announcement_id': announcement.id, + 'title': title, + 'target_url': target_url + }, + badge=1, + priority=10 + ) + print(f'[公告 APNs] 推送结果: 成功 {push_result["success"]}, 失败 {push_result["failed"]}') + else: + print('[公告 APNs] 没有活跃的设备 token') + + except ImportError as e: + print(f'[公告 APNs WARN] apns_push 模块未安装: {e}') + except Exception as apns_error: + print(f'[公告 APNs ERROR] 推送失败: {apns_error}') + + return jsonify({ + 'code': 200, + 'message': '公告已发布', + 'data': { + 'id': announcement.id, + 'title': title, + 'content': content, + 'push_success': push_result.get('success', 0), + 'push_failed': push_result.get('failed', 0) + } + }) + + except Exception as e: + db.session.rollback() + app.logger.error(f"发布公告失败: {e}") + import traceback + traceback.print_exc() + return jsonify({'code': 500, 'message': str(e)}), 500 + + +@app.route('/api/social/announcements//read', methods=['POST']) +@login_required +def mark_announcement_read(announcement_id): + """ + 标记公告为已读 + """ + try: + # 检查公告是否存在 + announcement = db.session.query(Announcement).filter( + Announcement.id == announcement_id, + Announcement.is_active == True + ).first() + + if not announcement: + return jsonify({'code': 404, 'message': '公告不存在'}), 404 + + # 检查是否已经标记过 + existing = db.session.query(AnnouncementReadStatus).filter( + AnnouncementReadStatus.user_id == current_user.id, + AnnouncementReadStatus.announcement_id == announcement_id + ).first() + + if not existing: + # 创建已读记录 + read_status = AnnouncementReadStatus( + user_id=current_user.id, + announcement_id=announcement_id + ) + db.session.add(read_status) + + # 更新公告已读计数 + announcement.read_count = (announcement.read_count or 0) + 1 + db.session.commit() + + return jsonify({ + 'code': 200, + 'message': '已标记为已读' + }) + + except Exception as e: + db.session.rollback() + app.logger.error(f"标记公告已读失败: {e}") + return jsonify({'code': 500, 'message': str(e)}), 500 + + +@app.route('/api/social/announcements/read-all', methods=['POST']) +@login_required +def mark_all_announcements_read(): + """ + 标记所有公告为已读 + """ + try: + # 获取用户尚未读取的公告 + read_ids = db.session.query(AnnouncementReadStatus.announcement_id).filter( + AnnouncementReadStatus.user_id == current_user.id + ).subquery() + + unread_announcements = db.session.query(Announcement).filter( + Announcement.is_active == True, + ~Announcement.id.in_(read_ids) + ).all() + + count = 0 + for announcement in unread_announcements: + read_status = AnnouncementReadStatus( + user_id=current_user.id, + announcement_id=announcement.id + ) + db.session.add(read_status) + announcement.read_count = (announcement.read_count or 0) + 1 + count += 1 + + db.session.commit() + + return jsonify({ + 'code': 200, + 'message': f'已标记 {count} 条公告为已读', + 'data': {'marked_count': count} + }) + + except Exception as e: + db.session.rollback() + app.logger.error(f"标记所有公告已读失败: {e}") + return jsonify({'code': 500, 'message': str(e)}), 500 + + +@app.route('/api/social/announcements/history', methods=['GET']) +@login_required +def get_announcement_history(): + """ + 获取公告历史(管理员查看) + """ + try: + if current_user.id != 1 and not getattr(current_user, 'is_admin', False): + return jsonify({'code': 403, 'message': '无权限'}), 403 + + page = request.args.get('page', 1, type=int) + limit = request.args.get('limit', 20, type=int) + + # 从 Announcement 表查询 + query = db.session.query(Announcement).order_by( + Announcement.created_at.desc() + ) + + total = query.count() + announcements = query.offset((page - 1) * limit).limit(limit).all() + + return jsonify({ + 'code': 200, + 'message': 'success', + 'data': { + 'announcements': [ + { + 'id': a.id, + 'title': a.title, + 'content': a.content, + 'target_url': a.target_url, + 'created_at': a.created_at.isoformat() if a.created_at else None, + 'read_count': a.read_count or 0, + 'is_active': a.is_active, + 'is_pushed': a.is_pushed + } + for a in announcements + ], + 'total': total, + 'hasMore': page * limit < total + } + }) + + except Exception as e: + app.logger.error(f"获取公告历史失败: {e}") + return jsonify({'code': 500, 'message': str(e)}), 500 + + +@app.route('/api/social/announcements/', methods=['DELETE']) +@login_required +def delete_announcement(announcement_id): + """ + 删除公告(软删除,管理员专用) + """ + try: + if current_user.id != 1 and not getattr(current_user, 'is_admin', False): + return jsonify({'code': 403, 'message': '无权限'}), 403 + + announcement = db.session.query(Announcement).filter( + Announcement.id == announcement_id + ).first() + + if not announcement: + return jsonify({'code': 404, 'message': '公告不存在'}), 404 + + # 软删除 + announcement.is_active = False + db.session.commit() + + return jsonify({ + 'code': 200, + 'message': '公告已删除' + }) + + except Exception as e: + db.session.rollback() + app.logger.error(f"删除公告失败: {e}") + return jsonify({'code': 500, 'message': str(e)}), 500 + + if __name__ == '__main__': # 创建数据库表 with app.app_context():