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():