feat(notify): 新增站内消息通知中心(订阅消息兜底)+ 清理后台伪造数据
- 新表 user_notifications + 仓储方法(创建/列表/未读数/标记已读) - 用户接口 GET /api/notifications、POST /api/notifications/read(生产需登录) - 关键节点落库:订单开始服务/接单/拒单/申请完成/完成结算/提现到账/报名成功/退款受理 - 小程序新增「消息通知」页(pages/user/notifications) + 个人中心入口与未读角标;api.js 增加 getNotifications/markNotificationsRead - 清理后台伪造数据:/api/user/certification 改空态、/api/user/my-project/list 改空数组、/api/user/info 去掉编造邮箱/电话/网址 - 更新 pages 数量断言(26→27);新增回归护栏 notification-center.spec.ts(92 套件/658 用例全绿);冒烟 9/9
这个提交包含在:
@@ -150,6 +150,11 @@ MYSQL_DATABASE=peiwan
|
||||
- `40037` 排查:点后台「**拉取小程序模板列表(核对ID)**」,会显示当前 appId(脱敏)与其下真实存在的订阅消息模板;若后台填写的 ID 不在列表中,即为 appId 与建模板的小程序不一致(或用了公众号/服务号的模板ID)。用列表里的真实 ID 覆盖即可。
|
||||
6. 订阅消息为**一次性**:用户授权一次只能收 1 条;高频场景需每次动作前请求授权(代码已在下单/报名/提现前请求)。
|
||||
|
||||
### 站内消息通知中心(已内置,无需额外配置)
|
||||
- 关键节点(订单开始服务/接单/拒单/申请完成/完成结算/提现到账/报名成功/退款受理)都会写入 `user_notifications`;
|
||||
- 小程序「个人中心 → 消息通知」可查看历史与未读数(`GET /api/notifications`、`POST /api/notifications/read`);
|
||||
- 即使未授权微信订阅消息,用户仍能看到完整通知历史,作为订阅消息的兜底渠道。
|
||||
|
||||
### 限流与错误信息(已内置,无需额外配置)
|
||||
- **全局限流**:`/api` 每**用户**(未登录按 IP)1200 次/分钟;**写接口单独限流**:发帖 10/分、评论 20/分、组局申请 20/分、举报 5/分、上传 30/分、下单 20/分、支付下单 30/分、充值/提现 10/分、浏览 1 次/3 秒(同 IP+动态)。超限返回 HTTP `429` + `Retry-After`。
|
||||
- ⚠️ **反向代理务必透传真实客户端 IP**,否则限流会把所有用户并成一个 IP 桶:
|
||||
|
||||
@@ -1212,6 +1212,30 @@ export async function getSubscribeTemplates() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取站内消息通知列表与未读数
|
||||
*/
|
||||
export async function getNotifications(userId, limit = 50) {
|
||||
const uid = normalizeUserId(userId);
|
||||
try {
|
||||
const res = await http.get(`/notifications?userId=${uid}&limit=${Number(limit) || 50}`);
|
||||
return res || { list: [], unread: 0 };
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记站内消息为已读(不传 ids 表示全部已读)
|
||||
*/
|
||||
export async function markNotificationsRead(userId, ids) {
|
||||
const uid = normalizeUserId(userId);
|
||||
const payload = { userId: uid };
|
||||
if (Array.isArray(ids) && ids.length > 0) payload.ids = ids;
|
||||
const res = await http.post('/notifications/read', payload);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存用户隐私与通知订阅设置 (同步落盘服务端与本地存储)
|
||||
*/
|
||||
|
||||
@@ -201,6 +201,16 @@
|
||||
"backgroundColor": "#0A0F1D"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/user/notifications",
|
||||
"style": {
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarTitleText": "消息通知",
|
||||
"navigationBarBackgroundColor": "#0A0F1D",
|
||||
"navigationBarTextStyle": "white",
|
||||
"backgroundColor": "#0A0F1D"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/activity/create",
|
||||
"style": {
|
||||
|
||||
@@ -149,6 +149,12 @@
|
||||
<text class="row-label">官方在线客服</text>
|
||||
<image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="list-row btn-active" @click="openNotifications">
|
||||
<image class="row-svg" :src="ICONS.chat" mode="aspectFit" />
|
||||
<text class="row-label">消息通知</text>
|
||||
<text class="row-sub-tag" v-if="noticeUnread > 0">{{ noticeUnread }}条未读</text>
|
||||
<image class="row-arrow-svg" :src="ICONS.chevronRight" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="list-row btn-active" @click="openNotice">
|
||||
<image class="row-svg" :src="ICONS.bell" mode="aspectFit" />
|
||||
<text class="row-label">平台消息与通知</text>
|
||||
@@ -351,7 +357,7 @@ import { ICONS } from '@/common/icons.js';
|
||||
import { getCurrentThemeKey, setTheme, THEME_KEYS, applyThemeToNativeUI } from '@/common/theme.js';
|
||||
import { getCurrentRole, setCurrentRole, ROLE_TYPES, toggleRole as toggleUserRole, isExpertCertified, getExpertCertStatus, setExpertCertStatus, CERT_STATUS } from '@/common/role.js';
|
||||
import { getNavBarInfo } from '@/common/nav.js';
|
||||
import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords, getMyReports, bindInviteCode } from '@/common/api.js';
|
||||
import { getUserProfile, rechargeBalance, withdrawCommission, getUserFinanceRecords, updateUserProfile, uploadImage, getRechargeTiers, getPlatformConfigs, applyExpertCert, getUserWithdrawals, getFavorites, removeFavorite, getDistributionTeam, getDistributionRecords, getMyReports, bindInviteCode, getNotifications } from '@/common/api.js';
|
||||
import { preloadSubscribeTemplates, requestSubscribeAll } from '@/common/subscribe.js';
|
||||
|
||||
const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200';
|
||||
@@ -384,6 +390,7 @@ const openMyReportsModal = () => {
|
||||
// 系统与隐私设置
|
||||
const privacyHidePhone = ref(uni.getStorageSync('privacy_hide_phone') === '1');
|
||||
const noticeSubscribe = ref(uni.getStorageSync('notice_subscribe') === '1');
|
||||
const noticeUnread = ref(0);
|
||||
const cacheSizeText = ref('1.25 MB');
|
||||
|
||||
// 分销推广管理
|
||||
@@ -643,6 +650,7 @@ onShow(() => {
|
||||
loadPlatformConfigs();
|
||||
privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1';
|
||||
noticeSubscribe.value = uni.getStorageSync('notice_subscribe') === '1';
|
||||
loadNoticeUnread();
|
||||
if (isLoggedIn.value) {
|
||||
loadFavorites();
|
||||
loadMyReports();
|
||||
@@ -1214,6 +1222,26 @@ const openNotice = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const openNotifications = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/user/notifications',
|
||||
fail: () => {
|
||||
uni.showToast({ title: '页面打开失败,请稍后重试', icon: 'none' });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const loadNoticeUnread = async () => {
|
||||
const uid = uni.getStorageSync('userId') || 0;
|
||||
if (!uid) return;
|
||||
try {
|
||||
const res = await getNotifications(uid, 1);
|
||||
noticeUnread.value = Number(res?.unread || 0);
|
||||
} catch (_) {
|
||||
// 未登录或网络异常时保持原值
|
||||
}
|
||||
};
|
||||
|
||||
const openFaq = () => {
|
||||
uni.navigateTo({
|
||||
url: '/pages/user/help-faq',
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
<template>
|
||||
<view class="notif-page" :class="['theme-' + currentThemeKey]" :style="navStyle">
|
||||
<!-- 顶部沉浸式自定义导航栏 -->
|
||||
<view class="custom-nav-bar">
|
||||
<view class="nav-content">
|
||||
<view class="btn-nav-back btn-active" @click="handleBack">
|
||||
<image class="nav-back-icon" :src="ICONS.chevronLeft" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="nav-title-wrap">
|
||||
<text class="nav-title">消息通知</text>
|
||||
</view>
|
||||
<view class="nav-action btn-active" v-if="unreadCount > 0" @click="handleReadAll">
|
||||
<text class="nav-action-text">全部已读</text>
|
||||
</view>
|
||||
<view class="nav-capsule-placeholder" v-else></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<scroll-view
|
||||
scroll-y
|
||||
class="notif-scroll"
|
||||
refresher-enabled
|
||||
:refresher-triggered="isRefreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<view class="notif-body">
|
||||
<view class="notif-summary">
|
||||
<text class="summary-title">通知中心</text>
|
||||
<text class="summary-sub">{{ unreadCount > 0 ? `你有 ${unreadCount} 条未读通知` : '暂无未读通知,订单/提现/报名进展都会在这里留痕' }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="isLoading" class="notif-empty">
|
||||
<text class="empty-text">加载中…</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="list.length === 0" class="notif-empty">
|
||||
<text class="empty-title">暂无消息</text>
|
||||
<text class="empty-text">下单、支付、接单、提现到账等节点,平台都会在此提醒你</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="notif-list">
|
||||
<view
|
||||
v-for="item in list"
|
||||
:key="item.id"
|
||||
class="notif-card btn-active"
|
||||
:class="{ 'is-unread': !item.isRead }"
|
||||
@click="handleOpen(item)"
|
||||
>
|
||||
<view class="notif-dot" v-if="!item.isRead"></view>
|
||||
<view class="notif-main">
|
||||
<view class="notif-head">
|
||||
<text class="notif-title">{{ item.title }}</text>
|
||||
<text class="notif-time">{{ formatTime(item.createdAt) }}</text>
|
||||
</view>
|
||||
<text class="notif-content">{{ item.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { onShow } from '@dcloudio/uni-app';
|
||||
import { ICONS } from '@/common/icons.js';
|
||||
import { getCurrentThemeKey } from '@/common/theme.js';
|
||||
import { getNavBarInfo } from '@/common/nav.js';
|
||||
import { getNotifications, markNotificationsRead } from '@/common/api.js';
|
||||
|
||||
const currentThemeKey = ref(getCurrentThemeKey());
|
||||
const navInfo = ref(getNavBarInfo());
|
||||
const isLoading = ref(true);
|
||||
const isRefreshing = ref(false);
|
||||
const list = ref([]);
|
||||
const unreadCount = ref(0);
|
||||
|
||||
const navStyle = computed(() => ({
|
||||
'--safe-header-gap': `${navInfo.value.totalHeaderHeight}px`,
|
||||
'--capsule-right-space': `${navInfo.value.capsuleRightSpace}px`,
|
||||
'--capsule-height': `${navInfo.value.capsuleHeight}px`
|
||||
}));
|
||||
|
||||
const currentUserId = () => uni.getStorageSync('userId') || uni.getStorageSync('uid') || 0;
|
||||
|
||||
const formatTime = (v) => {
|
||||
if (!v) return '';
|
||||
const s = String(v).replace('T', ' ').replace('Z', '');
|
||||
return s.length >= 16 ? s.slice(0, 16) : s;
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
const uid = currentUserId();
|
||||
if (!uid) {
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await getNotifications(uid, 50);
|
||||
list.value = Array.isArray(res?.list) ? res.list : [];
|
||||
unreadCount.value = Number(res?.unread || 0);
|
||||
} catch (_) {
|
||||
list.value = [];
|
||||
unreadCount.value = 0;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const onRefresh = () => {
|
||||
isRefreshing.value = true;
|
||||
loadData();
|
||||
};
|
||||
|
||||
const handleReadAll = async () => {
|
||||
const uid = currentUserId();
|
||||
if (!uid) return;
|
||||
try {
|
||||
await markNotificationsRead(uid);
|
||||
list.value = list.value.map((x) => ({ ...x, isRead: 1 }));
|
||||
unreadCount.value = 0;
|
||||
uni.showToast({ title: '已全部标记为已读', icon: 'none' });
|
||||
} catch (err) {
|
||||
uni.showToast({ title: err.message || '操作失败', icon: 'none' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = async (item) => {
|
||||
const uid = currentUserId();
|
||||
if (!item.isRead && uid) {
|
||||
try {
|
||||
await markNotificationsRead(uid, [item.id]);
|
||||
item.isRead = 1;
|
||||
unreadCount.value = Math.max(0, unreadCount.value - 1);
|
||||
} catch (_) {}
|
||||
}
|
||||
const link = String(item.link || '');
|
||||
if (!link) return;
|
||||
const tabPages = [
|
||||
'/pages/index/index',
|
||||
'/pages/activity/list',
|
||||
'/pages/order/create',
|
||||
'/pages/community/index',
|
||||
'/pages/user/index'
|
||||
];
|
||||
if (tabPages.includes(link)) {
|
||||
uni.switchTab({ url: link, fail: () => {} });
|
||||
} else {
|
||||
uni.navigateTo({ url: link, fail: () => {} });
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
const pages = getCurrentPages();
|
||||
if (pages && pages.length > 1) {
|
||||
uni.navigateBack();
|
||||
} else {
|
||||
uni.switchTab({ url: '/pages/user/index' });
|
||||
}
|
||||
};
|
||||
|
||||
onShow(() => {
|
||||
navInfo.value = getNavBarInfo();
|
||||
currentThemeKey.value = getCurrentThemeKey();
|
||||
loadData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.notif-page {
|
||||
min-height: 100vh;
|
||||
background: #0a0f1d;
|
||||
}
|
||||
.custom-nav-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
background: rgba(10, 15, 29, 0.92);
|
||||
padding-top: var(--safe-header-gap, 0);
|
||||
}
|
||||
.nav-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 44px;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.btn-nav-back {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.nav-back-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.nav-title-wrap {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
.nav-title {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-action {
|
||||
min-width: 56px;
|
||||
text-align: right;
|
||||
}
|
||||
.nav-action-text {
|
||||
color: #8b9cff;
|
||||
font-size: 13px;
|
||||
}
|
||||
.nav-capsule-placeholder {
|
||||
width: 56px;
|
||||
}
|
||||
.notif-scroll {
|
||||
height: calc(100vh - 44px - var(--safe-header-gap, 0px));
|
||||
}
|
||||
.notif-body {
|
||||
padding: 12px 16px 40px;
|
||||
}
|
||||
.notif-summary {
|
||||
padding: 16px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #1b2340 0%, #141a30 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.summary-title {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.summary-sub {
|
||||
color: #9aa7c7;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.notif-empty {
|
||||
padding: 48px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.empty-title {
|
||||
color: #e6eaf5;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.empty-text {
|
||||
color: #7d89a8;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.notif-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.notif-card {
|
||||
position: relative;
|
||||
padding: 14px 14px 14px 18px;
|
||||
border-radius: 14px;
|
||||
background: #141a30;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
.notif-card.is-unread {
|
||||
background: #182143;
|
||||
}
|
||||
.notif-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ff5f57;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.notif-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.notif-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.notif-title {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.notif-time {
|
||||
color: #6d7997;
|
||||
font-size: 11px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.notif-content {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
color: #a8b3d1;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.btn-active:active {
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
@@ -645,6 +645,21 @@ export async function initDatabase(): Promise<void> {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// 站内消息通知中心:订单/提现/报名/退款/仲裁等关键动作落库,供小程序「消息中心」查看
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS user_notifications (
|
||||
id VARCHAR(64) PRIMARY KEY,
|
||||
user_id INT NOT NULL,
|
||||
type VARCHAR(32) NOT NULL DEFAULT 'SYSTEM',
|
||||
title VARCHAR(128) NOT NULL,
|
||||
content VARCHAR(512) NOT NULL DEFAULT '',
|
||||
link VARCHAR(255) NOT NULL DEFAULT '',
|
||||
is_read TINYINT NOT NULL DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_notif_user_read (user_id, is_read, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`);
|
||||
|
||||
// 慢查询优化:为高频查询列补充索引(幂等:已存在则忽略;表不存在也忽略)
|
||||
// 注意:必须在所有建表语句之后执行,确保目标表已存在。
|
||||
const indexDefs: Array<[string, string, string]> = [
|
||||
|
||||
@@ -3125,6 +3125,76 @@ export class MysqlRepository {
|
||||
return rows;
|
||||
}
|
||||
|
||||
// ===== 站内消息通知中心 =====
|
||||
async createUserNotification(data: {
|
||||
userId: number;
|
||||
type?: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
link?: string;
|
||||
}): Promise<any> {
|
||||
const uid = Number(data.userId) || 0;
|
||||
if (uid <= 0 || !data.title) return null;
|
||||
const id = `ntf_${Date.now()}_${Math.floor(100 + Math.random() * 900)}`;
|
||||
await pool.query(
|
||||
`INSERT INTO user_notifications (id, user_id, type, title, content, link, is_read, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 0, NOW())`,
|
||||
[
|
||||
id,
|
||||
uid,
|
||||
String(data.type || 'SYSTEM').slice(0, 32),
|
||||
String(data.title).slice(0, 120),
|
||||
String(data.content || '').slice(0, 500),
|
||||
String(data.link || '').slice(0, 250),
|
||||
]
|
||||
);
|
||||
return { id };
|
||||
}
|
||||
|
||||
async getUserNotifications(userId: number, limit = 50): Promise<any[]> {
|
||||
const uid = Number(userId) || 0;
|
||||
if (uid <= 0) return [];
|
||||
const take = Math.min(200, Math.max(1, Number(limit) || 50));
|
||||
const [rows]: any = await pool.query(
|
||||
`SELECT id, type, title, content, link, is_read as isRead, created_at as createdAt
|
||||
FROM user_notifications
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC, id DESC
|
||||
LIMIT ${take}`,
|
||||
[uid]
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async getUserUnreadNotificationCount(userId: number): Promise<number> {
|
||||
const uid = Number(userId) || 0;
|
||||
if (uid <= 0) return 0;
|
||||
const [rows]: any = await pool.query(
|
||||
'SELECT COUNT(*) as c FROM user_notifications WHERE user_id = ? AND is_read = 0',
|
||||
[uid]
|
||||
);
|
||||
return Number((rows && rows[0] && rows[0].c) || 0);
|
||||
}
|
||||
|
||||
async markUserNotificationsRead(userId: number, ids?: string[]): Promise<number> {
|
||||
const uid = Number(userId) || 0;
|
||||
if (uid <= 0) return 0;
|
||||
if (Array.isArray(ids) && ids.length > 0) {
|
||||
const safe = ids.map((x) => String(x)).slice(0, 200);
|
||||
const ph = safe.map(() => '?').join(',');
|
||||
const [r]: any = await pool.query(
|
||||
`UPDATE user_notifications SET is_read = 1 WHERE user_id = ? AND id IN (${ph})`,
|
||||
[uid, ...safe]
|
||||
);
|
||||
return Number((r && r.affectedRows) || 0);
|
||||
}
|
||||
const [r]: any = await pool.query(
|
||||
'UPDATE user_notifications SET is_read = 1 WHERE user_id = ? AND is_read = 0',
|
||||
[uid]
|
||||
);
|
||||
return Number((r && r.affectedRows) || 0);
|
||||
}
|
||||
|
||||
async getWithdrawals(): Promise<any[]> {
|
||||
const [rows]: any = await pool.query(`
|
||||
SELECT
|
||||
@@ -3133,8 +3203,7 @@ export class MysqlRepository {
|
||||
status, apply_time as applyTime
|
||||
FROM withdrawals
|
||||
ORDER BY apply_time DESC
|
||||
`);
|
||||
return rows;
|
||||
`); return rows;
|
||||
}
|
||||
|
||||
async reviewWithdrawal(id: string, action: 'approve' | 'reject', rejectReason?: string): Promise<any> {
|
||||
|
||||
@@ -512,7 +512,7 @@ app.all('/api/user/info', (_req: Request, res: Response) => {
|
||||
data: {
|
||||
name: '超级管理员',
|
||||
avatar: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="%23165DFF"/><text x="50" y="62" font-size="36" text-anchor="middle" fill="white" font-family="sans-serif">AD</text></svg>',
|
||||
email: 'admin@peiwan.com',
|
||||
email: '',
|
||||
job: 'super_admin',
|
||||
jobName: '全权运营总监',
|
||||
organization: 'Platform',
|
||||
@@ -520,8 +520,8 @@ app.all('/api/user/info', (_req: Request, res: Response) => {
|
||||
location: 'beijing',
|
||||
locationName: '北京',
|
||||
introduction: '具备平台最高运营、风控与财务审核权限',
|
||||
personalWebsite: 'https://peiww.gynhc.com',
|
||||
phone: '188****8888',
|
||||
personalWebsite: '',
|
||||
phone: '',
|
||||
registrationDate: '2026-09-01 10:00:00',
|
||||
accountId: '10001',
|
||||
certification: 1,
|
||||
@@ -547,56 +547,32 @@ app.all('/api/user/menu', (_req: Request, res: Response) => {
|
||||
});
|
||||
|
||||
// 1.2 个人中心与管理员辅助接口 (用户设置与用户信息页)
|
||||
// 平台未接入企业实名认证:返回"未认证"空态,不再返回伪造的企业信息
|
||||
app.all('/api/user/certification', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: {
|
||||
enterpriseInfo: {
|
||||
accountType: 1,
|
||||
status: 1,
|
||||
time: '2026-09-01 10:00:00',
|
||||
legalPerson: '平台管理员',
|
||||
certificateType: '统一社会信用代码',
|
||||
authenticationNumber: '91110108MA00000000',
|
||||
enterpriseName: '同城生活陪伴运营服务中心',
|
||||
enterpriseCertificateType: '营业执照',
|
||||
organizationCode: 'MA000000-0',
|
||||
status: 0,
|
||||
time: '',
|
||||
legalPerson: '',
|
||||
certificateType: '',
|
||||
authenticationNumber: '',
|
||||
enterpriseName: '',
|
||||
enterpriseCertificateType: '',
|
||||
organizationCode: '',
|
||||
},
|
||||
record: [
|
||||
{
|
||||
certificationType: 1,
|
||||
certificationContent: '企业实名认证及运营资质核准',
|
||||
status: 1,
|
||||
time: '2026-09-01 10:00:00',
|
||||
}
|
||||
]
|
||||
record: []
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 平台未接入多项目管理:返回空数组,不再返回伪造项目与成员
|
||||
app.all('/api/user/my-project/list', (_req: Request, res: Response) => {
|
||||
res.json({
|
||||
code: 20000,
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
name: '同城线下生活陪伴运营大盘',
|
||||
description: '涵盖线下陪吃、陪喝、陪唱与线上游戏陪伴的综合履约与合规监管系统。',
|
||||
peopleNumber: 12,
|
||||
contributors: [
|
||||
{ name: '超级管理员', email: 'admin@peiwan.com', avatar: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="%23165DFF"/><text x="50" y="62" font-size="36" text-anchor="middle" fill="white" font-family="sans-serif">AD</text></svg>' }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '同城公共商圈合规与风控拦截',
|
||||
description: '高频违规场所自动识别与阻断引擎,保障线下组局公开与人身安全。',
|
||||
peopleNumber: 6,
|
||||
contributors: [
|
||||
{ name: '风控专员', email: 'sec@peiwan.com', avatar: 'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="50" fill="%2300B42A"/><text x="50" y="62" font-size="36" text-anchor="middle" fill="white" font-family="sans-serif">FK</text></svg>' }
|
||||
]
|
||||
}
|
||||
]
|
||||
data: []
|
||||
});
|
||||
});
|
||||
|
||||
@@ -756,6 +732,43 @@ app.all('/api/chat/list', (_req: Request, res: Response) => {
|
||||
res.json({ code: 20000, data: [] });
|
||||
});
|
||||
|
||||
// ===== 站内消息通知中心(用户端真实数据源)=====
|
||||
// 关键动作(支付/接单/拒单/完成/提现到账/报名/退款/仲裁)都会写入 user_notifications,
|
||||
// 即使未授权微信订阅消息,用户仍可在小程序「消息中心」看到完整通知历史。
|
||||
app.get('/api/notifications', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const uid = resolveSecureUserId(
|
||||
req,
|
||||
Number(req.query.userId || req.headers['x-user-id'] || 0),
|
||||
'查看消息通知'
|
||||
);
|
||||
const list = await repo.getUserNotifications(uid, Number(req.query.limit) || 50);
|
||||
const unread = await repo.getUserUnreadNotificationCount(uid);
|
||||
res.json({ code: 20000, data: { list, unread } });
|
||||
} catch (err: any) {
|
||||
const statusCode = err.status || 400;
|
||||
const errCode = err.code || 40000;
|
||||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/notifications/read', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const uid = resolveSecureUserId(
|
||||
req,
|
||||
Number(req.body?.userId || req.headers['x-user-id'] || 0),
|
||||
'标记消息已读'
|
||||
);
|
||||
const ids = Array.isArray(req.body?.ids) ? req.body.ids : undefined;
|
||||
const affected = await repo.markUserNotificationsRead(uid, ids);
|
||||
res.json({ code: 20000, data: { affected }, message: '消息已标记为已读' });
|
||||
} catch (err: any) {
|
||||
const statusCode = err.status || 400;
|
||||
const errCode = err.code || 40000;
|
||||
res.status(statusCode).json({ code: errCode, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// 2. 运营后台统计大盘接口 (真实读取 MySQL)
|
||||
app.get('/api/dashboard/stats', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -879,6 +892,7 @@ app.post('/api/activities/:id/join', async (req: Request, res: Response) => {
|
||||
});
|
||||
try {
|
||||
const act = await repo.getActivityById(actId);
|
||||
pushUserNotification(uid, 'ACTIVITY', '报名成功', `你已成功报名「${String(act?.title || '同城组局活动')}」,开始时间 ${toCnTimeOrNow(act?.startTime)}。`, '/pages/activity/my-ticket');
|
||||
void notifyBySubscribe('activityJoin', {
|
||||
userId: uid,
|
||||
page: '/pages/activity/my-ticket',
|
||||
@@ -1338,6 +1352,7 @@ app.post('/api/orders/:id/claim', async (req: Request, res: Response) => {
|
||||
await assertUserNotBanned(operatorId, '接单');
|
||||
}
|
||||
const order = await repo.claimOrder(orderId, { ...(req.body || {}), expertId: operatorId });
|
||||
pushUserNotification(Number(order?.buyer_id || order?.buyerId || 0), 'ORDER_STATUS', '大神已接单', `订单 ${order?.order_no || orderId} 已被大神接单,服务即将开始。`, '/pages/order/list');
|
||||
res.json({ code: 20000, data: order, message: '抢单成功,服务已锁定为履约中' });
|
||||
} catch (err: any) {
|
||||
const statusCode = err.status || 400;
|
||||
@@ -1984,6 +1999,7 @@ app.post('/api/orders/:id/pay', async (req: Request, res: Response) => {
|
||||
}
|
||||
const order = await repo.updateOrderStatus(orderId, '履约中');
|
||||
const buyer = Number(own.buyerId || 0);
|
||||
pushUserNotification(buyer, 'ORDER_STATUS', '订单已开始服务', `订单 ${order?.order_no || orderId} 已进入履约中${order?.verify_code ? `,核销码 ${order.verify_code}` : ''},请按时到场。`, '/pages/order/list');
|
||||
void notifyBySubscribe('orderStatus', {
|
||||
userId: buyer,
|
||||
page: '/pages/order/list',
|
||||
@@ -2010,6 +2026,7 @@ app.post('/api/orders/:id/pay', async (req: Request, res: Response) => {
|
||||
}
|
||||
const order = await repo.updateOrderStatus(orderId, requested);
|
||||
const buyer = Number(own.buyerId || 0);
|
||||
pushUserNotification(buyer, 'ORDER_STATUS', '订单已开始服务', `订单 ${order?.order_no || orderId} 已进入${requested}${order?.verify_code ? `,核销码 ${order.verify_code}` : ''},请按时到场。`, '/pages/order/list');
|
||||
void notifyBySubscribe('orderStatus', {
|
||||
userId: buyer,
|
||||
page: '/pages/order/list',
|
||||
@@ -2442,6 +2459,15 @@ function toCnTimeOrNow(value?: string): string {
|
||||
return formatCnTime(d);
|
||||
}
|
||||
|
||||
// 站内消息落库(fire-and-forget,绝不影响主业务;与微信订阅消息并行)
|
||||
function pushUserNotification(userId: number, type: string, title: string, content: string, link = ''): void {
|
||||
const uid = Number(userId) || 0;
|
||||
if (uid <= 0) return;
|
||||
void repo
|
||||
.createUserNotification({ userId: uid, type, title, content, link })
|
||||
.catch((e: any) => logServerError('pushUserNotification', e));
|
||||
}
|
||||
|
||||
async function notifyBySubscribe(
|
||||
templateKey: 'orderStatus' | 'activityJoin' | 'withdrawSuccess' | 'verifyCode',
|
||||
opts: { userId: number; page?: string; data: Record<string, string | number> }
|
||||
@@ -2928,6 +2954,7 @@ app.post('/api/pay/wechat/transfer-notify', async (req: Request, res: Response)
|
||||
if (outBillNo && state) {
|
||||
const r: any = await repo.finalizeWithdrawalTransfer(String(outBillNo), String(state));
|
||||
if (r && r.status === '已打款' && r.userId) {
|
||||
pushUserNotification(Number(r.userId), 'WITHDRAW', '提现已到账', `提现金额 ¥${Number(r.amount || 0).toFixed(2)} 已打款到你的微信零钱。`, '/pages/user/withdraw-records');
|
||||
void notifyBySubscribe('withdrawSuccess', {
|
||||
userId: Number(r.userId),
|
||||
page: '/pages/user/withdraw-records',
|
||||
@@ -3005,6 +3032,7 @@ app.post('/api/orders/:id/reject', async (req: Request, res: Response) => {
|
||||
return res.status(403).json({ code: 40300, message: '仅承接大神本人或平台管理员可拒单' });
|
||||
}
|
||||
const order = await repo.rejectOrder(orderId, req.body);
|
||||
pushUserNotification(Number(own.buyerId || 0), 'ORDER_REFUND', '订单已被拒单', `订单 ${orderId} 已被大神拒单,款项将原路退回。`, '/pages/order/list');
|
||||
res.json({ code: 20000, data: order, message: '已拒单,订单款项已自动原路退还买家' });
|
||||
} catch (err: any) {
|
||||
const statusCode = err.status || 400;
|
||||
@@ -3026,6 +3054,7 @@ app.post('/api/orders/:id/apply-complete', async (req: Request, res: Response) =
|
||||
return res.status(403).json({ code: 40300, message: '仅承接大神本人或平台管理员可申请完成服务' });
|
||||
}
|
||||
const order = await repo.applyCompleteOrder(orderId);
|
||||
pushUserNotification(Number(own.buyerId || 0), 'ORDER_STATUS', '大神申请完成服务', `订单 ${orderId} 大神已申请完成服务,请及时确认验收(48 小时未处理将自动确认)。`, '/pages/order/list');
|
||||
res.json({ code: 20000, data: order, message: '已发起完成服务申请,等待买家确认验收(48小时自动确认)' });
|
||||
} catch (err: any) {
|
||||
const statusCode = err.status || 400;
|
||||
@@ -3047,6 +3076,11 @@ app.post('/api/orders/:id/complete', async (req: Request, res: Response) => {
|
||||
return res.status(403).json({ code: 40300, message: '仅交易双方或平台管理员可完成该订单' });
|
||||
}
|
||||
const order = await repo.updateOrderStatus(orderId, '已完成');
|
||||
pushUserNotification(Number(own.buyerId || 0), 'ORDER_STATUS', '订单已完成', `订单 ${orderId} 已完成,感谢使用,欢迎评价。`, '/pages/order/list');
|
||||
const companionIds: number[] = Array.isArray(own?.companionIds) ? own.companionIds : [];
|
||||
companionIds.forEach((cid) =>
|
||||
pushUserNotification(Number(cid) || 0, 'ORDER_STATUS', '订单已完成结算', `订单 ${orderId} 已完成,佣金将按平台规则结算入账。`, '/pages/expert/workspace')
|
||||
);
|
||||
res.json({ code: 20000, data: order, message: '订单已确认完成' });
|
||||
} catch (err: any) {
|
||||
const statusCode = err.status || 400;
|
||||
@@ -3086,6 +3120,7 @@ app.post('/api/orders/:id/refund', async (req: Request, res: Response) => {
|
||||
}
|
||||
}
|
||||
const order = await repo.applyOrderRefund(orderId, { reason, description, evidence });
|
||||
pushUserNotification(Number(own.buyerId || 0), 'ORDER_REFUND', '退款申诉已受理', `订单 ${orderId} 的退款申诉已受理,进入争议仲裁流程。`, '/pages/user/reports');
|
||||
res.json({ code: 20000, data: order, message: '退款申诉已受理,进入争议仲裁流程' });
|
||||
} catch (err: any) {
|
||||
const statusCode = err.status || 400;
|
||||
@@ -3266,6 +3301,7 @@ app.post('/api/finance/withdrawals/:id/review', async (req: Request, res: Respon
|
||||
const billNo = String(bill.out_bill_no || outBillNo);
|
||||
if (state === 'SUCCESS') {
|
||||
const r = await repo.markWithdrawalTransfer(wdId, billNo, '已打款', 'SUCCESS');
|
||||
pushUserNotification(Number(wd.companion_id) || 0, 'WITHDRAW', '提现已到账', `提现金额 ¥${Number(wd.amount || 0).toFixed(2)} 已打款到你的微信零钱。`, '/pages/user/withdraw-records');
|
||||
void notifyBySubscribe('withdrawSuccess', {
|
||||
userId: Number(wd.companion_id) || 0,
|
||||
page: '/pages/user/withdraw-records',
|
||||
|
||||
@@ -130,7 +130,7 @@ describe('ContentSecurityService & Compliance Architecture (TDD 微信内容安
|
||||
const raw = fs.readFileSync(pagesJsonPath, 'utf-8');
|
||||
const config = JSON.parse(raw);
|
||||
expect(Array.isArray(config.pages)).toBe(true);
|
||||
expect(config.pages.length).toBe(26);
|
||||
expect(config.pages.length).toBe(27);
|
||||
|
||||
for (const pageItem of config.pages) {
|
||||
const vueFilePath = path.join(rootDir, 'pwuniapp', `${pageItem.path}.vue`);
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// 已读取AGENTS.md
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
/**
|
||||
* 第七十六批:站内消息通知中心(用户端真实数据源)+ 清理后台假数据。
|
||||
*/
|
||||
describe('第七十六批消息通知中心闸门', () => {
|
||||
const root = path.resolve(__dirname, '../../');
|
||||
const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8');
|
||||
const repo = fs.readFileSync(path.join(root, 'server/src/db/repository.ts'), 'utf-8');
|
||||
const mysql = fs.readFileSync(path.join(root, 'server/src/db/mysql.ts'), 'utf-8');
|
||||
const api = fs.readFileSync(path.join(root, 'pwuniapp/common/api.js'), 'utf-8');
|
||||
const userPage = fs.readFileSync(path.join(root, 'pwuniapp/pages/user/index.vue'), 'utf-8');
|
||||
const pagesJson = fs.readFileSync(path.join(root, 'pwuniapp/pages.json'), 'utf-8');
|
||||
|
||||
it('数据库与仓储具备通知能力', () => {
|
||||
expect(mysql).toContain('CREATE TABLE IF NOT EXISTS user_notifications');
|
||||
expect(repo).toContain('async createUserNotification(');
|
||||
expect(repo).toContain('async getUserNotifications(');
|
||||
expect(repo).toContain('async getUserUnreadNotificationCount(');
|
||||
expect(repo).toContain('async markUserNotificationsRead(');
|
||||
});
|
||||
|
||||
it('提供用户端通知接口', () => {
|
||||
expect(indexCode).toContain("app.get('/api/notifications'");
|
||||
expect(indexCode).toContain("app.post('/api/notifications/read'");
|
||||
expect(indexCode).toContain('function pushUserNotification(');
|
||||
});
|
||||
|
||||
it('关键动作写入站内通知(订单/提现/报名/退款)', () => {
|
||||
expect(indexCode).toContain("pushUserNotification(buyer, 'ORDER_STATUS'");
|
||||
expect(indexCode).toContain("'WITHDRAW', '提现已到账'");
|
||||
expect(indexCode).toContain("'ACTIVITY', '报名成功'");
|
||||
expect(indexCode).toContain("'ORDER_REFUND'");
|
||||
});
|
||||
|
||||
it('小程序具备消息中心页面与入口', () => {
|
||||
expect(pagesJson).toContain('pages/user/notifications');
|
||||
expect(api).toContain('export async function getNotifications(');
|
||||
expect(api).toContain('export async function markNotificationsRead(');
|
||||
expect(userPage).toContain('openNotifications');
|
||||
expect(userPage).toContain('noticeUnread');
|
||||
});
|
||||
|
||||
it('后台伪造数据已清理', () => {
|
||||
expect(indexCode).not.toContain('同城生活陪伴运营服务中心');
|
||||
expect(indexCode).not.toContain('91110108MA00000000');
|
||||
expect(indexCode).not.toContain('admin@peiwan.com');
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户