fix(security): 安全闸门与 NODE_ENV 解耦,支付/提现 fail-closed
- 新增 SECURE_MODE(仅 ALLOW_INSECURE_DEV_AUTH=1 时放开):后台登录口令、上传、内容检测、核销码归属全部改为默认收紧,修复线上以 NODE_ENV=development 运行时闸门失效 - 提现审核:未开通真实转账且未显式 ALLOW_SANDBOX_PAY=1 时一律 503,杜绝只改状态不打款 - 预支付:未开启沙箱时不再下发沙箱参数;小程序删除预支付失败伪造沙箱成功的兜底,改为抛错 - 订阅消息支持 MINIPROGRAM_STATE=formal|trial|developer 显式配置,避免真实用户收不到通知 - 动态列表/点赞身份统一 normalizeUserId;文档补充安全闸门与订阅消息环境变量 - 109 套件/720 用例全绿,tsc 构建通过
这个提交包含在:
@@ -14,10 +14,16 @@ PORT=3000
|
|||||||
ADMIN_TOKEN_SECRET=<强随机长串>
|
ADMIN_TOKEN_SECRET=<强随机长串>
|
||||||
USER_TOKEN_SECRET=<另一个强随机长串>
|
USER_TOKEN_SECRET=<另一个强随机长串>
|
||||||
|
|
||||||
# 后台登录口令(生产必配;未配置或仍为 admin/admin123 时后台登录被禁用)
|
# 后台登录口令(必配;未配置或仍为 admin/admin123 时后台登录被禁用)
|
||||||
ADMIN_USERNAME=<后台账号,默认 admin>
|
ADMIN_USERNAME=<后台账号,默认 admin>
|
||||||
ADMIN_PASSWORD=<强口令;多个可用 | 分隔>
|
ADMIN_PASSWORD=<强口令;多个可用 | 分隔>
|
||||||
|
|
||||||
|
# 安全闸门不再依赖 NODE_ENV:上传/内容检测/核销归属/提现打款等默认按生产口径收紧,
|
||||||
|
# 只有本地联调显式设置 ALLOW_INSECURE_DEV_AUTH=1 才放开;沙箱支付需显式 ALLOW_SANDBOX_PAY=1。
|
||||||
|
#
|
||||||
|
# 订阅消息跳转版本:正式发布后请设置为 formal,否则真实用户收不到订阅消息(默认随 NODE_ENV 推导)
|
||||||
|
MINIPROGRAM_STATE=formal
|
||||||
|
|
||||||
# 可选:后台令牌有效期小时数(默认 24;改口令/被盗时可在后台右上角「吊销所有后台登录态」立即失效全部令牌)
|
# 可选:后台令牌有效期小时数(默认 24;改口令/被盗时可在后台右上角「吊销所有后台登录态」立即失效全部令牌)
|
||||||
# ADMIN_TOKEN_TTL_HOURS=24
|
# ADMIN_TOKEN_TTL_HOURS=24
|
||||||
|
|
||||||
|
|||||||
@@ -451,7 +451,7 @@ export async function universalVerifyCheckin(code, operatorId) {
|
|||||||
*/
|
*/
|
||||||
export async function getPosts(category, userId) {
|
export async function getPosts(category, userId) {
|
||||||
try {
|
try {
|
||||||
const uid = userId || uni.getStorageSync('userId') || 1001;
|
const uid = normalizeUserId(userId);
|
||||||
let query = `?userId=${uid}`;
|
let query = `?userId=${uid}`;
|
||||||
if (category && category !== 'all') {
|
if (category && category !== 'all') {
|
||||||
query += `&category=${encodeURIComponent(category)}`;
|
query += `&category=${encodeURIComponent(category)}`;
|
||||||
@@ -519,7 +519,7 @@ export async function createPost(payload) {
|
|||||||
*/
|
*/
|
||||||
export async function likePost(id, action, userId) {
|
export async function likePost(id, action, userId) {
|
||||||
try {
|
try {
|
||||||
const uid = userId || uni.getStorageSync('userId') || 1001;
|
const uid = normalizeUserId(userId);
|
||||||
const payload = { userId: uid };
|
const payload = { userId: uid };
|
||||||
if (action) {
|
if (action) {
|
||||||
payload.action = action;
|
payload.action = action;
|
||||||
@@ -1437,16 +1437,12 @@ export async function requestWechatPayment({ amount, title = '同城陪伴担保
|
|||||||
userId: uid,
|
userId: uid,
|
||||||
wxCode
|
wxCode
|
||||||
});
|
});
|
||||||
} catch (_) {
|
} catch (err) {
|
||||||
prepayData = {
|
// 预支付失败必须向上抛出:绝不能伪造"沙箱支付成功",否则用户以为付款成功、订单却未支付。
|
||||||
mode: 'SANDBOX',
|
const e = new Error((err && err.message) || '微信支付发起失败,请稍后重试');
|
||||||
timeStamp: String(Math.floor(Date.now() / 1000)),
|
e.code = err && err.code;
|
||||||
nonceStr: `pw_${Date.now()}`,
|
e.statusCode = err && err.statusCode;
|
||||||
package: `prepay_id=wx${Date.now()}`,
|
throw e;
|
||||||
signType: 'RSA',
|
|
||||||
paySign: 'SANDBOX_FALLBACK',
|
|
||||||
outTradeNo: orderId || `WX_${Date.now()}`
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -18,8 +18,12 @@ const BASE_URL = (() => {
|
|||||||
*/
|
*/
|
||||||
// 登录过期提示仅一次,避免并发请求重复弹窗
|
// 登录过期提示仅一次,避免并发请求重复弹窗
|
||||||
let authExpiredNotified = false;
|
let authExpiredNotified = false;
|
||||||
function handleAuthExpired() {
|
function handleAuthExpired(hadSession = false) {
|
||||||
|
let hadLocalAuth = Boolean(hadSession);
|
||||||
try {
|
try {
|
||||||
|
if (!hadLocalAuth) {
|
||||||
|
hadLocalAuth = Boolean(uni.getStorageSync('userToken') || uni.getStorageSync('userId'));
|
||||||
|
}
|
||||||
uni.removeStorageSync('userToken');
|
uni.removeStorageSync('userToken');
|
||||||
uni.removeStorageSync('userId');
|
uni.removeStorageSync('userId');
|
||||||
uni.removeStorageSync('userInfo');
|
uni.removeStorageSync('userInfo');
|
||||||
@@ -27,7 +31,8 @@ function handleAuthExpired() {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 忽略本地存储清理异常
|
// 忽略本地存储清理异常
|
||||||
}
|
}
|
||||||
if (!authExpiredNotified) {
|
// 仅当本地原本存在登录会话却被服务端判定失效时才提示“登录已过期”;未登录游客静默忽略
|
||||||
|
if (hadLocalAuth && !authExpiredNotified) {
|
||||||
authExpiredNotified = true;
|
authExpiredNotified = true;
|
||||||
try {
|
try {
|
||||||
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' });
|
uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' });
|
||||||
@@ -46,8 +51,10 @@ export function request(options = {}) {
|
|||||||
|
|
||||||
const method = (options.method || 'GET').toUpperCase();
|
const method = (options.method || 'GET').toUpperCase();
|
||||||
let sessionToken = '';
|
let sessionToken = '';
|
||||||
|
let hadSession = false;
|
||||||
try {
|
try {
|
||||||
sessionToken = uni.getStorageSync('userToken') || '';
|
sessionToken = uni.getStorageSync('userToken') || '';
|
||||||
|
hadSession = Boolean(sessionToken || uni.getStorageSync('userId'));
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
sessionToken = '';
|
sessionToken = '';
|
||||||
}
|
}
|
||||||
@@ -69,13 +76,14 @@ export function request(options = {}) {
|
|||||||
// 适配标准的业务封装 { code: 20000, data: ... }
|
// 适配标准的业务封装 { code: 20000, data: ... }
|
||||||
if (res.data && typeof res.data === 'object' && 'code' in res.data) {
|
if (res.data && typeof res.data === 'object' && 'code' in res.data) {
|
||||||
if (res.data.code === 20000) {
|
if (res.data.code === 20000) {
|
||||||
|
if (sessionToken) authExpiredNotified = false;
|
||||||
resolve(res.data.data);
|
resolve(res.data.data);
|
||||||
} else {
|
} else {
|
||||||
const bizErr = new Error(res.data.message || '业务请求失败');
|
const bizErr = new Error(res.data.message || '业务请求失败');
|
||||||
bizErr.code = res.data.code;
|
bizErr.code = res.data.code;
|
||||||
bizErr.statusCode = res.statusCode;
|
bizErr.statusCode = res.statusCode;
|
||||||
if (res.data.code === 40100) handleAuthExpired();
|
if (res.data.code === 40100) handleAuthExpired(hadSession);
|
||||||
else if (res.data.code === 40300 && /身份校验失败|无权以他人身份/.test(res.data.message || '')) handleAuthExpired();
|
else if (res.data.code === 40300 && /身份校验失败|无权以他人身份/.test(res.data.message || '')) handleAuthExpired(hadSession);
|
||||||
reject(bizErr);
|
reject(bizErr);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -86,8 +94,8 @@ export function request(options = {}) {
|
|||||||
const httpErr = new Error(errMsg);
|
const httpErr = new Error(errMsg);
|
||||||
httpErr.statusCode = res.statusCode;
|
httpErr.statusCode = res.statusCode;
|
||||||
httpErr.code = (res.data && res.data.code) || res.statusCode;
|
httpErr.code = (res.data && res.data.code) || res.statusCode;
|
||||||
if (res.statusCode === 401) handleAuthExpired();
|
if (res.statusCode === 401) handleAuthExpired(hadSession);
|
||||||
else if (res.statusCode === 403 && /身份校验失败|无权以他人身份/.test(errMsg)) handleAuthExpired();
|
else if (res.statusCode === 403 && /身份校验失败|无权以他人身份/.test(errMsg)) handleAuthExpired(hadSession);
|
||||||
reject(httpErr);
|
reject(httpErr);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -427,8 +427,10 @@ const syncCurrentUserProfile = async () => {
|
|||||||
...currentUserProfile.value,
|
...currentUserProfile.value,
|
||||||
...cached
|
...cached
|
||||||
};
|
};
|
||||||
|
const storedUid = Number(uni.getStorageSync('userId'));
|
||||||
|
if (!storedUid) return currentUserProfile.value;
|
||||||
try {
|
try {
|
||||||
const remote = await getUserProfile(cached.id || 1001);
|
const remote = await getUserProfile(storedUid);
|
||||||
if (remote && remote.nickname) {
|
if (remote && remote.nickname) {
|
||||||
const merged = {
|
const merged = {
|
||||||
...cached,
|
...cached,
|
||||||
|
|||||||
@@ -368,7 +368,8 @@ import { preloadSubscribeTemplates, requestSubscribeAll } from '@/common/subscri
|
|||||||
|
|
||||||
const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200';
|
const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200';
|
||||||
const initialUserId = typeof uni !== 'undefined' ? (uni.getStorageSync('userId') || null) : null;
|
const initialUserId = typeof uni !== 'undefined' ? (uni.getStorageSync('userId') || null) : null;
|
||||||
const isLoggedIn = ref(Boolean(initialUserId));
|
const initialLoggedOut = typeof uni !== 'undefined' ? (uni.getStorageSync('user_has_logged_out') === '1') : false;
|
||||||
|
const isLoggedIn = ref(Boolean(initialUserId && !initialLoggedOut));
|
||||||
const showLoginModal = ref(false);
|
const showLoginModal = ref(false);
|
||||||
|
|
||||||
// 我的收藏与我的举报反馈
|
// 我的收藏与我的举报反馈
|
||||||
@@ -377,7 +378,12 @@ const loadingFavorites = ref(false);
|
|||||||
const myReportsList = ref([]);
|
const myReportsList = ref([]);
|
||||||
|
|
||||||
const loadMyReports = async () => {
|
const loadMyReports = async () => {
|
||||||
const uid = user.value.id || uni.getStorageSync('userId') || 1001;
|
if (!isLoggedIn.value) {
|
||||||
|
myReportsList.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uid = user.value.id || uni.getStorageSync('userId');
|
||||||
|
if (!uid) return;
|
||||||
try {
|
try {
|
||||||
const list = await getMyReports(uid);
|
const list = await getMyReports(uid);
|
||||||
if (Array.isArray(list)) {
|
if (Array.isArray(list)) {
|
||||||
@@ -409,7 +415,11 @@ const openCoupons = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadCouponUsable = async () => {
|
const loadCouponUsable = async () => {
|
||||||
const uid = uni.getStorageSync('userId') || 0;
|
if (!isLoggedIn.value) {
|
||||||
|
couponUsable.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uid = user.value.id || uni.getStorageSync('userId') || 0;
|
||||||
if (!uid) return;
|
if (!uid) return;
|
||||||
try {
|
try {
|
||||||
const res = await getMyCoupons(uid, 'UNUSED');
|
const res = await getMyCoupons(uid, 'UNUSED');
|
||||||
@@ -663,7 +673,9 @@ onMounted(() => {
|
|||||||
uni.$on('login-success', onLoginSuccess);
|
uni.$on('login-success', onLoginSuccess);
|
||||||
loadUserProfile();
|
loadUserProfile();
|
||||||
loadPlatformConfigs();
|
loadPlatformConfigs();
|
||||||
loadMyReports();
|
if (isLoggedIn.value) {
|
||||||
|
loadMyReports();
|
||||||
|
}
|
||||||
// 预加载订阅消息模板ID,确保点击授权时无需等待网络(保留点击手势)
|
// 预加载订阅消息模板ID,确保点击授权时无需等待网络(保留点击手势)
|
||||||
preloadSubscribeTemplates();
|
preloadSubscribeTemplates();
|
||||||
});
|
});
|
||||||
@@ -678,11 +690,16 @@ onShow(() => {
|
|||||||
loadPlatformConfigs();
|
loadPlatformConfigs();
|
||||||
privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1';
|
privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1';
|
||||||
noticeSubscribe.value = uni.getStorageSync('notice_subscribe') === '1';
|
noticeSubscribe.value = uni.getStorageSync('notice_subscribe') === '1';
|
||||||
loadNoticeUnread();
|
|
||||||
loadCouponUsable();
|
|
||||||
if (isLoggedIn.value) {
|
if (isLoggedIn.value) {
|
||||||
|
loadNoticeUnread();
|
||||||
|
loadCouponUsable();
|
||||||
loadFavorites();
|
loadFavorites();
|
||||||
loadMyReports();
|
loadMyReports();
|
||||||
|
} else {
|
||||||
|
noticeUnread.value = 0;
|
||||||
|
couponUsable.value = 0;
|
||||||
|
favoriteList.value = [];
|
||||||
|
myReportsList.value = [];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1030,9 +1047,15 @@ const openFavoritesModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadFavorites = async () => {
|
const loadFavorites = async () => {
|
||||||
|
if (!isLoggedIn.value) {
|
||||||
|
favoriteList.value = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uid = user.value.id || uni.getStorageSync('userId');
|
||||||
|
if (!uid) return;
|
||||||
loadingFavorites.value = true;
|
loadingFavorites.value = true;
|
||||||
try {
|
try {
|
||||||
const list = await getFavorites(user.value.id || 1001);
|
const list = await getFavorites(uid);
|
||||||
favoriteList.value = Array.isArray(list) ? list : [];
|
favoriteList.value = Array.isArray(list) ? list : [];
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
favoriteList.value = [];
|
favoriteList.value = [];
|
||||||
@@ -1261,7 +1284,11 @@ const openNotifications = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadNoticeUnread = async () => {
|
const loadNoticeUnread = async () => {
|
||||||
const uid = uni.getStorageSync('userId') || 0;
|
if (!isLoggedIn.value) {
|
||||||
|
noticeUnread.value = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const uid = user.value.id || uni.getStorageSync('userId') || 0;
|
||||||
if (!uid) return;
|
if (!uid) return;
|
||||||
try {
|
try {
|
||||||
const res = await getNotifications(uid, 1);
|
const res = await getNotifications(uid, 1);
|
||||||
|
|||||||
@@ -499,12 +499,13 @@ app.post('/api/user/login', (req: Request, res: Response) => {
|
|||||||
if (isLoginLocked(loginKey)) {
|
if (isLoginLocked(loginKey)) {
|
||||||
return res.status(429).json({ code: 50000, message: '登录失败次数过多,请 10 分钟后再试' });
|
return res.status(429).json({ code: 50000, message: '登录失败次数过多,请 10 分钟后再试' });
|
||||||
}
|
}
|
||||||
// 生产环境 fail-closed:未配置 ADMIN_PASSWORD(或仍为内置默认口令)时,禁止后台登录
|
// 安全 fail-closed:未配置 ADMIN_PASSWORD(或仍为内置默认口令)时,禁止后台登录。
|
||||||
if (IS_PRODUCTION && !ADMIN_PASSWORD_CONFIGURED) {
|
// 不依赖 NODE_ENV(线上曾以 development 运行导致默认口令可直接登录)。
|
||||||
logServerError('adminLogin', new Error('生产环境未配置 ADMIN_PASSWORD(或仍为默认口令),已禁用后台登录'));
|
if (SECURE_MODE && !ADMIN_PASSWORD_CONFIGURED) {
|
||||||
|
logServerError('adminLogin', new Error('未配置 ADMIN_PASSWORD(或仍为默认口令),已禁用后台登录'));
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
code: 50000,
|
code: 50000,
|
||||||
message: '生产环境未配置后台登录口令:请设置环境变量 ADMIN_PASSWORD(不得为 admin/admin123)后重启服务',
|
message: '后台登录口令未配置:请设置环境变量 ADMIN_PASSWORD(不得为 admin/admin123)后重启服务',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 管理凭证默认 admin/admin,可通过环境变量 ADMIN_USERNAME / ADMIN_PASSWORD 覆盖
|
// 管理凭证默认 admin/admin,可通过环境变量 ADMIN_USERNAME / ADMIN_PASSWORD 覆盖
|
||||||
@@ -593,8 +594,9 @@ app.all('/api/user/certification', (_req: Request, res: Response) => {
|
|||||||
|
|
||||||
// 真实文件与图片对象存储上传接口 (支持单图上传并持久化至 public/uploads)
|
// 真实文件与图片对象存储上传接口 (支持单图上传并持久化至 public/uploads)
|
||||||
const handleFileUpload = (req: Request, res: Response) => {
|
const handleFileUpload = (req: Request, res: Response) => {
|
||||||
// 生产环境要求登录后上传,避免匿名上传被滥用(刷盘/托管非法内容)
|
// 上传要求登录,避免匿名上传被滥用(刷盘/托管非法内容)。
|
||||||
if (IS_PRODUCTION) {
|
// 不依赖 NODE_ENV:只有显式 ALLOW_INSECURE_DEV_AUTH=1 的本地联调才放开。
|
||||||
|
if (SECURE_MODE) {
|
||||||
try {
|
try {
|
||||||
resolveSecureUserId(req, 0, '上传文件');
|
resolveSecureUserId(req, 0, '上传文件');
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -653,8 +655,8 @@ app.post('/api/user/upload', rateLimit({ name: 'upload_user', windowMs: 60 * 100
|
|||||||
app.post('/api/security/msg-sec-check', async (req: Request, res: Response) => {
|
app.post('/api/security/msg-sec-check', async (req: Request, res: Response) => {
|
||||||
try {
|
try {
|
||||||
let callerId = 0;
|
let callerId = 0;
|
||||||
if (IS_PRODUCTION) {
|
if (SECURE_MODE) {
|
||||||
// 生产环境要求登录,避免匿名滥用微信内容安全额度
|
// 要求登录,避免匿名滥用微信内容安全额度
|
||||||
callerId = resolveSecureUserId(req, Number(req.body?.userId || req.headers['x-user-id'] || 0), '内容安全检测');
|
callerId = resolveSecureUserId(req, Number(req.body?.userId || req.headers['x-user-id'] || 0), '内容安全检测');
|
||||||
}
|
}
|
||||||
const { content, scene } = req.body || {};
|
const { content, scene } = req.body || {};
|
||||||
@@ -977,9 +979,9 @@ app.get('/api/verify/query/:code', async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const operator = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查询核销码');
|
const operator = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查询核销码');
|
||||||
const info = await repo.queryCheckinCodeInfo(String(req.params.code));
|
const info = await repo.queryCheckinCodeInfo(String(req.params.code));
|
||||||
// 生产环境:仅门票归属方(活动发起人 / 订单承接大神)或管理员可查看核销码详情,
|
// 仅门票归属方(活动发起人 / 订单承接大神)或管理员可查看核销码详情,
|
||||||
// 防止对 4 位纯数字核销码做批量枚举探测。
|
// 防止对 4 位纯数字核销码做批量枚举探测(不依赖 NODE_ENV)。
|
||||||
if (IS_PRODUCTION && !isVerifiedAdminRequest(req)) {
|
if (SECURE_MODE && !isVerifiedAdminRequest(req)) {
|
||||||
let allowed = false;
|
let allowed = false;
|
||||||
if (info.type === 'ACTIVITY') {
|
if (info.type === 'ACTIVITY') {
|
||||||
allowed = Number(info.creatorId) === operator;
|
allowed = Number(info.creatorId) === operator;
|
||||||
@@ -1004,8 +1006,8 @@ app.post('/api/verify/universal', async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
const { verifyCode, operatorId } = req.body;
|
const { verifyCode, operatorId } = req.body;
|
||||||
const operator = resolveSecureUserId(req, Number(operatorId || 0), '核销门票');
|
const operator = resolveSecureUserId(req, Number(operatorId || 0), '核销门票');
|
||||||
// 生产环境校验核销人归属:活动门票仅限发起人核销;订单门票仅限承接大神核销
|
// 校验核销人归属:活动门票仅限发起人核销;订单门票仅限承接大神核销(不依赖 NODE_ENV)
|
||||||
if (IS_PRODUCTION && !isVerifiedAdminRequest(req)) {
|
if (SECURE_MODE && !isVerifiedAdminRequest(req)) {
|
||||||
const info = await repo.queryCheckinCodeInfo(String(verifyCode));
|
const info = await repo.queryCheckinCodeInfo(String(verifyCode));
|
||||||
let allowed = false;
|
let allowed = false;
|
||||||
if (info.type === 'ACTIVITY') {
|
if (info.type === 'ACTIVITY') {
|
||||||
@@ -2208,6 +2210,16 @@ const IS_PRODUCTION = process.env.NODE_ENV === 'production';
|
|||||||
// - ALLOW_SANDBOX_PAY=1:仅在联调时允许沙箱支付(无真实收款推进订单)
|
// - ALLOW_SANDBOX_PAY=1:仅在联调时允许沙箱支付(无真实收款推进订单)
|
||||||
const ALLOW_INSECURE_DEV_AUTH = process.env.ALLOW_INSECURE_DEV_AUTH === '1';
|
const ALLOW_INSECURE_DEV_AUTH = process.env.ALLOW_INSECURE_DEV_AUTH === '1';
|
||||||
const ALLOW_SANDBOX_PAY = process.env.ALLOW_SANDBOX_PAY === '1';
|
const ALLOW_SANDBOX_PAY = process.env.ALLOW_SANDBOX_PAY === '1';
|
||||||
|
// 安全闸门不再依赖 NODE_ENV(线上曾以 NODE_ENV=development 运行导致闸门全部失效):
|
||||||
|
// 默认按生产口径收紧,只有显式 ALLOW_INSECURE_DEV_AUTH=1 的本地联调才放开。
|
||||||
|
const SECURE_MODE = !ALLOW_INSECURE_DEV_AUTH;
|
||||||
|
// 订阅消息跳转版本:线上若以 NODE_ENV=development 运行,默认会落到 developer(真实用户收不到),
|
||||||
|
// 因此支持用 MINIPROGRAM_STATE=formal|trial|developer 显式指定。
|
||||||
|
const MINIPROGRAM_STATE = ((): 'formal' | 'trial' | 'developer' => {
|
||||||
|
const v = String(process.env.MINIPROGRAM_STATE || '').toLowerCase();
|
||||||
|
if (v === 'formal' || v === 'trial' || v === 'developer') return v;
|
||||||
|
return IS_PRODUCTION ? 'formal' : 'developer';
|
||||||
|
})();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确认某笔微信支付已成功:优先读本地意图,其次主动向微信支付查单(含短暂重试),
|
* 确认某笔微信支付已成功:优先读本地意图,其次主动向微信支付查单(含短暂重试),
|
||||||
@@ -2579,7 +2591,7 @@ async function notifyBySubscribe(
|
|||||||
template_id: templateId,
|
template_id: templateId,
|
||||||
page: opts.page,
|
page: opts.page,
|
||||||
data: buildTemplateData(opts.data || {}),
|
data: buildTemplateData(opts.data || {}),
|
||||||
miniprogram_state: IS_PRODUCTION ? 'formal' : 'developer',
|
miniprogram_state: MINIPROGRAM_STATE,
|
||||||
});
|
});
|
||||||
if (result && result.errcode && result.errcode !== 0) {
|
if (result && result.errcode && result.errcode !== 0) {
|
||||||
logServerError('notifyBySubscribe', new Error(`订阅消息[${templateKey}]下发失败 ${result.errcode} ${result.errmsg}`));
|
logServerError('notifyBySubscribe', new Error(`订阅消息[${templateKey}]下发失败 ${result.errcode} ${result.errmsg}`));
|
||||||
@@ -2930,7 +2942,13 @@ app.post('/api/pay/wechat/prepay', rateLimit({ name: 'prepay', windowMs: 60 * 10
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// SANDBOX 兜底(仅开发联调)
|
// SANDBOX 兜底(仅显式 ALLOW_SANDBOX_PAY=1 的联调环境)
|
||||||
|
if (!ALLOW_SANDBOX_PAY) {
|
||||||
|
return res.status(503).json({
|
||||||
|
code: 50001,
|
||||||
|
message: '微信支付未就绪:请在后台完成微信支付商户配置后重试'
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json({
|
res.json({
|
||||||
code: 20000,
|
code: 20000,
|
||||||
data: {
|
data: {
|
||||||
@@ -3395,8 +3413,8 @@ app.post('/api/finance/withdrawals/:id/review', async (req: Request, res: Respon
|
|||||||
return res.json({ code: 20000, data: { ...r, transfer: bill }, message: '提现打款已受理(微信处理中),到账以后端最终状态为准' });
|
return res.json({ code: 20000, data: { ...r, transfer: bill }, message: '提现打款已受理(微信处理中),到账以后端最终状态为准' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未开启真实打款:生产环境拒绝(避免"只改状态、不发钱"的误导);非生产保留沙箱仅改状态
|
// 未开启真实打款:禁止"只改状态、不发钱"(不依赖 NODE_ENV,避免线上以 development 运行时静默放行)。
|
||||||
if (IS_PRODUCTION && !ALLOW_SANDBOX_PAY) {
|
if (!ALLOW_SANDBOX_PAY) {
|
||||||
return res.status(503).json({
|
return res.status(503).json({
|
||||||
code: 50001,
|
code: 50001,
|
||||||
message: '微信商家转账未就绪:请在后台填写「转账场景ID」并确保商户号已开通"商家转账到零钱"产品'
|
message: '微信商家转账未就绪:请在后台填写「转账场景ID」并确保商户号已开通"商家转账到零钱"产品'
|
||||||
@@ -4109,7 +4127,7 @@ app.post('/api/admin/message/test-send', async (req: Request, res: Response) =>
|
|||||||
template_id: templateId,
|
template_id: templateId,
|
||||||
page,
|
page,
|
||||||
data: buildTemplateData(payloadData),
|
data: buildTemplateData(payloadData),
|
||||||
miniprogram_state: IS_PRODUCTION ? 'formal' : 'developer',
|
miniprogram_state: MINIPROGRAM_STATE,
|
||||||
});
|
});
|
||||||
const ok = !result || !result.errcode || result.errcode === 0;
|
const ok = !result || !result.errcode || result.errcode === 0;
|
||||||
return res.json({
|
return res.json({
|
||||||
@@ -4938,9 +4956,9 @@ async function logStartupConfigSelfCheck(): Promise<void> {
|
|||||||
console.log(`[Peiwan Config] ADMIN_TOKEN_SECRET=${adminSecretSet ? '已设置' : '开发默认值'} USER_TOKEN_SECRET=${userSecretSet ? '已设置' : '按 ADMIN 派生'}`);
|
console.log(`[Peiwan Config] ADMIN_TOKEN_SECRET=${adminSecretSet ? '已设置' : '开发默认值'} USER_TOKEN_SECRET=${userSecretSet ? '已设置' : '按 ADMIN 派生'}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 后台登录口令闸门:生产未配置(或为默认口令)时后台登录被禁用
|
// 后台登录口令闸门:未配置有效口令时后台登录被禁用(不再依赖 NODE_ENV)
|
||||||
if (isProd && !ADMIN_PASSWORD_CONFIGURED) {
|
if (SECURE_MODE && !ADMIN_PASSWORD_CONFIGURED) {
|
||||||
console.warn('[Peiwan Config][WARN] 未设置有效的 ADMIN_PASSWORD(不得为 admin/admin123):生产环境后台登录已被禁用,请尽快设置后重启。');
|
console.warn('[Peiwan Config][WARN] 未设置有效的 ADMIN_PASSWORD(不得为 admin/admin123):后台登录已被禁用,请尽快设置后重启。');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 危险兜底自检:仅在显式开启时提示(默认全部关闭 = fail-closed)
|
// 危险兜底自检:仅在显式开启时提示(默认全部关闭 = fail-closed)
|
||||||
@@ -4951,7 +4969,7 @@ async function logStartupConfigSelfCheck(): Promise<void> {
|
|||||||
console.warn('[Peiwan Config][WARN] 已开启 ALLOW_SANDBOX_PAY=1:允许沙箱支付(无真实收款推进订单),仅限联调环境!');
|
console.warn('[Peiwan Config][WARN] 已开启 ALLOW_SANDBOX_PAY=1:允许沙箱支付(无真实收款推进订单),仅限联调环境!');
|
||||||
}
|
}
|
||||||
if (!isProd) {
|
if (!isProd) {
|
||||||
console.warn('[Peiwan Config][WARN] 当前 NODE_ENV 非 production:订阅消息将以 developer 状态下发、部分生产闸门不生效;请确认是否为预期环境。');
|
console.warn(`[Peiwan Config] NODE_ENV 非 production(仅影响日志/订阅消息默认状态,安全闸门由 SECURE_MODE 控制);MINIPROGRAM_STATE=${MINIPROGRAM_STATE}。若已正式发布,请设置 MINIPROGRAM_STATE=formal 让真实用户收到订阅消息。`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg: any = await resolveWechatPayRuntimeConfig();
|
const cfg: any = await resolveWechatPayRuntimeConfig();
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ describe('第七十二批后台凭证闸门', () => {
|
|||||||
const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8');
|
const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8');
|
||||||
const doc = fs.readFileSync(path.join(root, 'docs/部署验收与1分钱支付闭环.md'), 'utf-8');
|
const doc = fs.readFileSync(path.join(root, 'docs/部署验收与1分钱支付闭环.md'), 'utf-8');
|
||||||
|
|
||||||
it('生产未配置 ADMIN_PASSWORD 时禁用后台登录(fail-closed)', () => {
|
it('未配置 ADMIN_PASSWORD 时禁用后台登录(fail-closed,不依赖 NODE_ENV)', () => {
|
||||||
expect(indexCode).toContain('ADMIN_PASSWORD_CONFIGURED');
|
expect(indexCode).toContain('ADMIN_PASSWORD_CONFIGURED');
|
||||||
expect(indexCode).toContain('if (IS_PRODUCTION && !ADMIN_PASSWORD_CONFIGURED)');
|
expect(indexCode).toContain('if (SECURE_MODE && !ADMIN_PASSWORD_CONFIGURED)');
|
||||||
expect(indexCode).toContain('生产环境未配置后台登录口令');
|
expect(indexCode).toContain('后台登录口令未配置');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('启动自检提示默认口令与非生产放宽风险', () => {
|
it('启动自检提示默认口令与非生产放宽风险', () => {
|
||||||
|
|||||||
@@ -9,11 +9,11 @@ describe('第四十六批内容安全接口闸门', () => {
|
|||||||
const rootDir = path.resolve(__dirname, '../../');
|
const rootDir = path.resolve(__dirname, '../../');
|
||||||
const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8');
|
const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8');
|
||||||
|
|
||||||
it('生产需登录且 openid 以会话用户为准', () => {
|
it('需登录且 openid 以会话用户为准(安全闸门不依赖 NODE_ENV)', () => {
|
||||||
const start = indexCode.indexOf("app.post('/api/security/msg-sec-check'");
|
const start = indexCode.indexOf("app.post('/api/security/msg-sec-check'");
|
||||||
expect(start).toBeGreaterThan(-1);
|
expect(start).toBeGreaterThan(-1);
|
||||||
const block = indexCode.slice(start, start + 1200);
|
const block = indexCode.slice(start, start + 1200);
|
||||||
expect(block).toContain('IS_PRODUCTION');
|
expect(block).toContain('SECURE_MODE');
|
||||||
expect(block).toContain("'内容安全检测'");
|
expect(block).toContain("'内容安全检测'");
|
||||||
expect(block).toContain('await resolveUserOpenid(callerId)');
|
expect(block).toContain('await resolveUserOpenid(callerId)');
|
||||||
expect(block).not.toContain('const { content, openid, scene } = req.body');
|
expect(block).not.toContain('const { content, openid, scene } = req.body');
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ describe('第二十二批登录安全闸门', () => {
|
|||||||
it('登录路由:超限锁定 + 常量时间比较', () => {
|
it('登录路由:超限锁定 + 常量时间比较', () => {
|
||||||
const start = indexCode.indexOf("app.post('/api/user/login'");
|
const start = indexCode.indexOf("app.post('/api/user/login'");
|
||||||
expect(start).toBeGreaterThan(-1);
|
expect(start).toBeGreaterThan(-1);
|
||||||
const block = indexCode.slice(start, start + 1200);
|
const block = indexCode.slice(start, start + 2000);
|
||||||
expect(block).toContain('isLoginLocked(loginKey)');
|
expect(block).toContain('isLoginLocked(loginKey)');
|
||||||
expect(block).toContain('登录失败次数过多');
|
expect(block).toContain('登录失败次数过多');
|
||||||
expect(block).toContain('recordLoginFailure(loginKey)');
|
expect(block).toContain('recordLoginFailure(loginKey)');
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ describe('第七批安全加固闸门 (评价回复 / 服务上下架 / 通用
|
|||||||
describe('3. 通用核销归属 (生产闸门)', () => {
|
describe('3. 通用核销归属 (生产闸门)', () => {
|
||||||
it('生产环境必须校验核销人:活动=发起人,订单=承接大神', () => {
|
it('生产环境必须校验核销人:活动=发起人,订单=承接大神', () => {
|
||||||
const block = routeBlock("app.post('/api/verify/universal'");
|
const block = routeBlock("app.post('/api/verify/universal'");
|
||||||
expect(block).toContain('IS_PRODUCTION && !isVerifiedAdminRequest(req)');
|
expect(block).toContain('SECURE_MODE && !isVerifiedAdminRequest(req)');
|
||||||
expect(block).toContain("info.type === 'ACTIVITY'");
|
expect(block).toContain("info.type === 'ACTIVITY'");
|
||||||
expect(block).toContain('Number(info.creatorId) === operator');
|
expect(block).toContain('Number(info.creatorId) === operator');
|
||||||
expect(block).toContain('own!.companionIds.includes(operator)');
|
expect(block).toContain('own!.companionIds.includes(operator)');
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// 已读取AGENTS.md
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 第九十三批:安全闸门与 NODE_ENV 解耦 + 支付/提现 fail-closed。
|
||||||
|
* 背景:线上以 NODE_ENV=development 运行,导致所有 `IS_PRODUCTION && ...` 闸门失效:
|
||||||
|
* - 后台可用默认口令 admin/admin123 登录;
|
||||||
|
* - 提现审核通过只改状态、不真实打款;
|
||||||
|
* - 4 位核销码可被任意登录用户枚举/核销;
|
||||||
|
* - 匿名可上传、可无限调用内容安全检测。
|
||||||
|
* 另外小程序在预支付失败时会伪造"沙箱支付成功",必须改为抛错。
|
||||||
|
*/
|
||||||
|
describe('第九十三批安全闸门解耦与支付 fail-closed', () => {
|
||||||
|
const root = path.resolve(__dirname, '../../');
|
||||||
|
const indexCode = fs.readFileSync(path.join(root, 'server/src/index.ts'), 'utf-8');
|
||||||
|
const apiCode = fs.readFileSync(path.join(root, 'pwuniapp/common/api.js'), 'utf-8');
|
||||||
|
|
||||||
|
const routeBlock = (marker: string, len = 3000) => {
|
||||||
|
const start = indexCode.indexOf(marker);
|
||||||
|
expect(start).toBeGreaterThan(-1);
|
||||||
|
return indexCode.slice(start, start + len);
|
||||||
|
};
|
||||||
|
|
||||||
|
it('安全闸门统一由 SECURE_MODE 控制,不依赖 NODE_ENV', () => {
|
||||||
|
expect(indexCode).toContain('const SECURE_MODE = !ALLOW_INSECURE_DEV_AUTH;');
|
||||||
|
expect(routeBlock("app.post('/api/security/msg-sec-check'")).toContain('if (SECURE_MODE)');
|
||||||
|
expect(indexCode).not.toContain('if (IS_PRODUCTION && !ADMIN_PASSWORD_CONFIGURED)');
|
||||||
|
expect(indexCode).not.toContain('if (IS_PRODUCTION) {\n try {\n resolveSecureUserId');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('提现审核未开通真实转账时必须 503,禁止只改状态不打款', () => {
|
||||||
|
const block = routeBlock("app.post('/api/finance/withdrawals/:id/review'", 6000);
|
||||||
|
expect(block).not.toContain('IS_PRODUCTION && !ALLOW_SANDBOX_PAY');
|
||||||
|
expect(block).toContain('if (!ALLOW_SANDBOX_PAY)');
|
||||||
|
expect(block).toContain('微信商家转账未就绪');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('预支付在未开启沙箱时拒绝下发沙箱参数', () => {
|
||||||
|
const block = routeBlock("app.post('/api/pay/wechat/prepay'", 12000);
|
||||||
|
const sandboxIdx = block.indexOf("mode: 'SANDBOX'");
|
||||||
|
expect(sandboxIdx).toBeGreaterThan(-1);
|
||||||
|
expect(block.slice(0, sandboxIdx)).toContain('if (!ALLOW_SANDBOX_PAY)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('订阅消息版本支持 MINIPROGRAM_STATE 显式配置', () => {
|
||||||
|
expect(indexCode).toContain('process.env.MINIPROGRAM_STATE');
|
||||||
|
expect(indexCode).toContain('const MINIPROGRAM_STATE = (');
|
||||||
|
expect(indexCode).not.toContain("miniprogram_state: IS_PRODUCTION ? 'formal' : 'developer'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it('小程序预支付失败必须抛错,不得伪造沙箱支付成功', () => {
|
||||||
|
expect(apiCode).not.toContain('SANDBOX_FALLBACK');
|
||||||
|
expect(apiCode).toContain('微信支付发起失败,请稍后重试');
|
||||||
|
expect(apiCode).toMatch(/catch \(err\) \{\n \/\/ 预支付失败必须向上抛出/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('动态列表与点赞的身份统一走 normalizeUserId', () => {
|
||||||
|
expect(apiCode).toContain('const uid = normalizeUserId(userId);\n let query = `?userId=${uid}`;');
|
||||||
|
expect(apiCode).toContain('const uid = normalizeUserId(userId);\n const payload = { userId: uid };');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -9,11 +9,11 @@ describe('第四十五批上传鉴权闸门', () => {
|
|||||||
const rootDir = path.resolve(__dirname, '../../');
|
const rootDir = path.resolve(__dirname, '../../');
|
||||||
const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8');
|
const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8');
|
||||||
|
|
||||||
it('生产环境上传必须要求登录', () => {
|
it('上传必须要求登录(安全闸门不依赖 NODE_ENV)', () => {
|
||||||
const start = indexCode.indexOf('const handleFileUpload =');
|
const start = indexCode.indexOf('const handleFileUpload =');
|
||||||
expect(start).toBeGreaterThan(-1);
|
expect(start).toBeGreaterThan(-1);
|
||||||
const block = indexCode.slice(start, start + 500);
|
const block = indexCode.slice(start, start + 500);
|
||||||
expect(block).toContain('IS_PRODUCTION');
|
expect(block).toContain('SECURE_MODE');
|
||||||
expect(block).toContain("resolveSecureUserId(req, 0, '上传文件')");
|
expect(block).toContain("resolveSecureUserId(req, 0, '上传文件')");
|
||||||
expect(block).toContain('请先登录后再上传');
|
expect(block).toContain('请先登录后再上传');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ describe('第十五批核销码与抢单池加固闸门', () => {
|
|||||||
it('必须要求登录并按门票归属方校验', () => {
|
it('必须要求登录并按门票归属方校验', () => {
|
||||||
const block = routeBlock("app.get('/api/verify/query/:code'");
|
const block = routeBlock("app.get('/api/verify/query/:code'");
|
||||||
expect(block).toContain("'查询核销码'");
|
expect(block).toContain("'查询核销码'");
|
||||||
expect(block).toContain('IS_PRODUCTION && !isVerifiedAdminRequest(req)');
|
expect(block).toContain('SECURE_MODE && !isVerifiedAdminRequest(req)');
|
||||||
expect(block).toContain('无权查看该核销码信息');
|
expect(block).toContain('无权查看该核销码信息');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户