diff --git a/docs/部署验收与1分钱支付闭环.md b/docs/部署验收与1分钱支付闭环.md index 50bd2a6..eb3fb87 100644 --- a/docs/部署验收与1分钱支付闭环.md +++ b/docs/部署验收与1分钱支付闭环.md @@ -14,10 +14,16 @@ PORT=3000 ADMIN_TOKEN_SECRET=<强随机长串> USER_TOKEN_SECRET=<另一个强随机长串> -# 后台登录口令(生产必配;未配置或仍为 admin/admin123 时后台登录被禁用) +# 后台登录口令(必配;未配置或仍为 admin/admin123 时后台登录被禁用) ADMIN_USERNAME=<后台账号,默认 admin> ADMIN_PASSWORD=<强口令;多个可用 | 分隔> +# 安全闸门不再依赖 NODE_ENV:上传/内容检测/核销归属/提现打款等默认按生产口径收紧, +# 只有本地联调显式设置 ALLOW_INSECURE_DEV_AUTH=1 才放开;沙箱支付需显式 ALLOW_SANDBOX_PAY=1。 +# +# 订阅消息跳转版本:正式发布后请设置为 formal,否则真实用户收不到订阅消息(默认随 NODE_ENV 推导) +MINIPROGRAM_STATE=formal + # 可选:后台令牌有效期小时数(默认 24;改口令/被盗时可在后台右上角「吊销所有后台登录态」立即失效全部令牌) # ADMIN_TOKEN_TTL_HOURS=24 diff --git a/pwuniapp/common/api.js b/pwuniapp/common/api.js index d8bb32a..40b0540 100644 --- a/pwuniapp/common/api.js +++ b/pwuniapp/common/api.js @@ -451,7 +451,7 @@ export async function universalVerifyCheckin(code, operatorId) { */ export async function getPosts(category, userId) { try { - const uid = userId || uni.getStorageSync('userId') || 1001; + const uid = normalizeUserId(userId); let query = `?userId=${uid}`; if (category && category !== 'all') { query += `&category=${encodeURIComponent(category)}`; @@ -519,7 +519,7 @@ export async function createPost(payload) { */ export async function likePost(id, action, userId) { try { - const uid = userId || uni.getStorageSync('userId') || 1001; + const uid = normalizeUserId(userId); const payload = { userId: uid }; if (action) { payload.action = action; @@ -1437,16 +1437,12 @@ export async function requestWechatPayment({ amount, title = '同城陪伴担保 userId: uid, wxCode }); - } catch (_) { - prepayData = { - mode: 'SANDBOX', - timeStamp: String(Math.floor(Date.now() / 1000)), - nonceStr: `pw_${Date.now()}`, - package: `prepay_id=wx${Date.now()}`, - signType: 'RSA', - paySign: 'SANDBOX_FALLBACK', - outTradeNo: orderId || `WX_${Date.now()}` - }; + } catch (err) { + // 预支付失败必须向上抛出:绝不能伪造"沙箱支付成功",否则用户以为付款成功、订单却未支付。 + const e = new Error((err && err.message) || '微信支付发起失败,请稍后重试'); + e.code = err && err.code; + e.statusCode = err && err.statusCode; + throw e; } return new Promise((resolve, reject) => { diff --git a/pwuniapp/common/request.js b/pwuniapp/common/request.js index 4635975..dd710ba 100644 --- a/pwuniapp/common/request.js +++ b/pwuniapp/common/request.js @@ -18,8 +18,12 @@ const BASE_URL = (() => { */ // 登录过期提示仅一次,避免并发请求重复弹窗 let authExpiredNotified = false; -function handleAuthExpired() { +function handleAuthExpired(hadSession = false) { + let hadLocalAuth = Boolean(hadSession); try { + if (!hadLocalAuth) { + hadLocalAuth = Boolean(uni.getStorageSync('userToken') || uni.getStorageSync('userId')); + } uni.removeStorageSync('userToken'); uni.removeStorageSync('userId'); uni.removeStorageSync('userInfo'); @@ -27,7 +31,8 @@ function handleAuthExpired() { } catch (_) { // 忽略本地存储清理异常 } - if (!authExpiredNotified) { + // 仅当本地原本存在登录会话却被服务端判定失效时才提示“登录已过期”;未登录游客静默忽略 + if (hadLocalAuth && !authExpiredNotified) { authExpiredNotified = true; try { uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' }); @@ -46,8 +51,10 @@ export function request(options = {}) { const method = (options.method || 'GET').toUpperCase(); let sessionToken = ''; + let hadSession = false; try { sessionToken = uni.getStorageSync('userToken') || ''; + hadSession = Boolean(sessionToken || uni.getStorageSync('userId')); } catch (_) { sessionToken = ''; } @@ -69,13 +76,14 @@ export function request(options = {}) { // 适配标准的业务封装 { code: 20000, data: ... } if (res.data && typeof res.data === 'object' && 'code' in res.data) { if (res.data.code === 20000) { + if (sessionToken) authExpiredNotified = false; resolve(res.data.data); } else { const bizErr = new Error(res.data.message || '业务请求失败'); bizErr.code = res.data.code; bizErr.statusCode = res.statusCode; - if (res.data.code === 40100) handleAuthExpired(); - else if (res.data.code === 40300 && /身份校验失败|无权以他人身份/.test(res.data.message || '')) handleAuthExpired(); + if (res.data.code === 40100) handleAuthExpired(hadSession); + else if (res.data.code === 40300 && /身份校验失败|无权以他人身份/.test(res.data.message || '')) handleAuthExpired(hadSession); reject(bizErr); } } else { @@ -86,8 +94,8 @@ export function request(options = {}) { const httpErr = new Error(errMsg); httpErr.statusCode = res.statusCode; httpErr.code = (res.data && res.data.code) || res.statusCode; - if (res.statusCode === 401) handleAuthExpired(); - else if (res.statusCode === 403 && /身份校验失败|无权以他人身份/.test(errMsg)) handleAuthExpired(); + if (res.statusCode === 401) handleAuthExpired(hadSession); + else if (res.statusCode === 403 && /身份校验失败|无权以他人身份/.test(errMsg)) handleAuthExpired(hadSession); reject(httpErr); } }, diff --git a/pwuniapp/pages/community/index.vue b/pwuniapp/pages/community/index.vue index 6ccff85..0473ca2 100644 --- a/pwuniapp/pages/community/index.vue +++ b/pwuniapp/pages/community/index.vue @@ -427,8 +427,10 @@ const syncCurrentUserProfile = async () => { ...currentUserProfile.value, ...cached }; + const storedUid = Number(uni.getStorageSync('userId')); + if (!storedUid) return currentUserProfile.value; try { - const remote = await getUserProfile(cached.id || 1001); + const remote = await getUserProfile(storedUid); if (remote && remote.nickname) { const merged = { ...cached, diff --git a/pwuniapp/pages/user/index.vue b/pwuniapp/pages/user/index.vue index ebbf65f..d84b7f6 100644 --- a/pwuniapp/pages/user/index.vue +++ b/pwuniapp/pages/user/index.vue @@ -368,7 +368,8 @@ import { preloadSubscribeTemplates, requestSubscribeAll } from '@/common/subscri const DEFAULT_GUEST_AVATAR = 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=200'; 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); // 我的收藏与我的举报反馈 @@ -377,7 +378,12 @@ const loadingFavorites = ref(false); const myReportsList = ref([]); 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 { const list = await getMyReports(uid); if (Array.isArray(list)) { @@ -409,7 +415,11 @@ const openCoupons = () => { }; 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; try { const res = await getMyCoupons(uid, 'UNUSED'); @@ -663,7 +673,9 @@ onMounted(() => { uni.$on('login-success', onLoginSuccess); loadUserProfile(); loadPlatformConfigs(); - loadMyReports(); + if (isLoggedIn.value) { + loadMyReports(); + } // 预加载订阅消息模板ID,确保点击授权时无需等待网络(保留点击手势) preloadSubscribeTemplates(); }); @@ -678,11 +690,16 @@ onShow(() => { loadPlatformConfigs(); privacyHidePhone.value = uni.getStorageSync('privacy_hide_phone') === '1'; noticeSubscribe.value = uni.getStorageSync('notice_subscribe') === '1'; - loadNoticeUnread(); - loadCouponUsable(); if (isLoggedIn.value) { + loadNoticeUnread(); + loadCouponUsable(); loadFavorites(); loadMyReports(); + } else { + noticeUnread.value = 0; + couponUsable.value = 0; + favoriteList.value = []; + myReportsList.value = []; } }); @@ -1030,9 +1047,15 @@ const openFavoritesModal = () => { }; const loadFavorites = async () => { + if (!isLoggedIn.value) { + favoriteList.value = []; + return; + } + const uid = user.value.id || uni.getStorageSync('userId'); + if (!uid) return; loadingFavorites.value = true; try { - const list = await getFavorites(user.value.id || 1001); + const list = await getFavorites(uid); favoriteList.value = Array.isArray(list) ? list : []; } catch (_) { favoriteList.value = []; @@ -1261,7 +1284,11 @@ const openNotifications = () => { }; 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; try { const res = await getNotifications(uid, 1); diff --git a/server/src/index.ts b/server/src/index.ts index d853dc6..1c4bc71 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -499,12 +499,13 @@ app.post('/api/user/login', (req: Request, res: Response) => { if (isLoginLocked(loginKey)) { return res.status(429).json({ code: 50000, message: '登录失败次数过多,请 10 分钟后再试' }); } - // 生产环境 fail-closed:未配置 ADMIN_PASSWORD(或仍为内置默认口令)时,禁止后台登录 - if (IS_PRODUCTION && !ADMIN_PASSWORD_CONFIGURED) { - logServerError('adminLogin', new Error('生产环境未配置 ADMIN_PASSWORD(或仍为默认口令),已禁用后台登录')); + // 安全 fail-closed:未配置 ADMIN_PASSWORD(或仍为内置默认口令)时,禁止后台登录。 + // 不依赖 NODE_ENV(线上曾以 development 运行导致默认口令可直接登录)。 + if (SECURE_MODE && !ADMIN_PASSWORD_CONFIGURED) { + logServerError('adminLogin', new Error('未配置 ADMIN_PASSWORD(或仍为默认口令),已禁用后台登录')); return res.status(503).json({ code: 50000, - message: '生产环境未配置后台登录口令:请设置环境变量 ADMIN_PASSWORD(不得为 admin/admin123)后重启服务', + message: '后台登录口令未配置:请设置环境变量 ADMIN_PASSWORD(不得为 admin/admin123)后重启服务', }); } // 管理凭证默认 admin/admin,可通过环境变量 ADMIN_USERNAME / ADMIN_PASSWORD 覆盖 @@ -593,8 +594,9 @@ app.all('/api/user/certification', (_req: Request, res: Response) => { // 真实文件与图片对象存储上传接口 (支持单图上传并持久化至 public/uploads) const handleFileUpload = (req: Request, res: Response) => { - // 生产环境要求登录后上传,避免匿名上传被滥用(刷盘/托管非法内容) - if (IS_PRODUCTION) { + // 上传要求登录,避免匿名上传被滥用(刷盘/托管非法内容)。 + // 不依赖 NODE_ENV:只有显式 ALLOW_INSECURE_DEV_AUTH=1 的本地联调才放开。 + if (SECURE_MODE) { try { resolveSecureUserId(req, 0, '上传文件'); } 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) => { try { let callerId = 0; - if (IS_PRODUCTION) { - // 生产环境要求登录,避免匿名滥用微信内容安全额度 + if (SECURE_MODE) { + // 要求登录,避免匿名滥用微信内容安全额度 callerId = resolveSecureUserId(req, Number(req.body?.userId || req.headers['x-user-id'] || 0), '内容安全检测'); } const { content, scene } = req.body || {}; @@ -977,9 +979,9 @@ app.get('/api/verify/query/:code', async (req: Request, res: Response) => { try { const operator = resolveSecureUserId(req, Number(req.query.userId || req.headers['x-user-id'] || 0), '查询核销码'); const info = await repo.queryCheckinCodeInfo(String(req.params.code)); - // 生产环境:仅门票归属方(活动发起人 / 订单承接大神)或管理员可查看核销码详情, - // 防止对 4 位纯数字核销码做批量枚举探测。 - if (IS_PRODUCTION && !isVerifiedAdminRequest(req)) { + // 仅门票归属方(活动发起人 / 订单承接大神)或管理员可查看核销码详情, + // 防止对 4 位纯数字核销码做批量枚举探测(不依赖 NODE_ENV)。 + if (SECURE_MODE && !isVerifiedAdminRequest(req)) { let allowed = false; if (info.type === 'ACTIVITY') { allowed = Number(info.creatorId) === operator; @@ -1004,8 +1006,8 @@ app.post('/api/verify/universal', async (req: Request, res: Response) => { try { const { verifyCode, operatorId } = req.body; const operator = resolveSecureUserId(req, Number(operatorId || 0), '核销门票'); - // 生产环境校验核销人归属:活动门票仅限发起人核销;订单门票仅限承接大神核销 - if (IS_PRODUCTION && !isVerifiedAdminRequest(req)) { + // 校验核销人归属:活动门票仅限发起人核销;订单门票仅限承接大神核销(不依赖 NODE_ENV) + if (SECURE_MODE && !isVerifiedAdminRequest(req)) { const info = await repo.queryCheckinCodeInfo(String(verifyCode)); let allowed = false; if (info.type === 'ACTIVITY') { @@ -2208,6 +2210,16 @@ const IS_PRODUCTION = process.env.NODE_ENV === 'production'; // - ALLOW_SANDBOX_PAY=1:仅在联调时允许沙箱支付(无真实收款推进订单) const ALLOW_INSECURE_DEV_AUTH = process.env.ALLOW_INSECURE_DEV_AUTH === '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, page: opts.page, data: buildTemplateData(opts.data || {}), - miniprogram_state: IS_PRODUCTION ? 'formal' : 'developer', + miniprogram_state: MINIPROGRAM_STATE, }); if (result && result.errcode && result.errcode !== 0) { 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({ code: 20000, 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: '提现打款已受理(微信处理中),到账以后端最终状态为准' }); } - // 未开启真实打款:生产环境拒绝(避免"只改状态、不发钱"的误导);非生产保留沙箱仅改状态 - if (IS_PRODUCTION && !ALLOW_SANDBOX_PAY) { + // 未开启真实打款:禁止"只改状态、不发钱"(不依赖 NODE_ENV,避免线上以 development 运行时静默放行)。 + if (!ALLOW_SANDBOX_PAY) { return res.status(503).json({ code: 50001, message: '微信商家转账未就绪:请在后台填写「转账场景ID」并确保商户号已开通"商家转账到零钱"产品' @@ -4109,7 +4127,7 @@ app.post('/api/admin/message/test-send', async (req: Request, res: Response) => template_id: templateId, page, data: buildTemplateData(payloadData), - miniprogram_state: IS_PRODUCTION ? 'formal' : 'developer', + miniprogram_state: MINIPROGRAM_STATE, }); const ok = !result || !result.errcode || result.errcode === 0; return res.json({ @@ -4938,9 +4956,9 @@ async function logStartupConfigSelfCheck(): Promise { console.log(`[Peiwan Config] ADMIN_TOKEN_SECRET=${adminSecretSet ? '已设置' : '开发默认值'} USER_TOKEN_SECRET=${userSecretSet ? '已设置' : '按 ADMIN 派生'}`); } - // 后台登录口令闸门:生产未配置(或为默认口令)时后台登录被禁用 - if (isProd && !ADMIN_PASSWORD_CONFIGURED) { - console.warn('[Peiwan Config][WARN] 未设置有效的 ADMIN_PASSWORD(不得为 admin/admin123):生产环境后台登录已被禁用,请尽快设置后重启。'); + // 后台登录口令闸门:未配置有效口令时后台登录被禁用(不再依赖 NODE_ENV) + if (SECURE_MODE && !ADMIN_PASSWORD_CONFIGURED) { + console.warn('[Peiwan Config][WARN] 未设置有效的 ADMIN_PASSWORD(不得为 admin/admin123):后台登录已被禁用,请尽快设置后重启。'); } // 危险兜底自检:仅在显式开启时提示(默认全部关闭 = fail-closed) @@ -4951,7 +4969,7 @@ async function logStartupConfigSelfCheck(): Promise { console.warn('[Peiwan Config][WARN] 已开启 ALLOW_SANDBOX_PAY=1:允许沙箱支付(无真实收款推进订单),仅限联调环境!'); } 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(); diff --git a/server/tests/admin-credential-hardening.spec.ts b/server/tests/admin-credential-hardening.spec.ts index 94e0a6f..0b89749 100644 --- a/server/tests/admin-credential-hardening.spec.ts +++ b/server/tests/admin-credential-hardening.spec.ts @@ -11,10 +11,10 @@ describe('第七十二批后台凭证闸门', () => { 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'); - it('生产未配置 ADMIN_PASSWORD 时禁用后台登录(fail-closed)', () => { + it('未配置 ADMIN_PASSWORD 时禁用后台登录(fail-closed,不依赖 NODE_ENV)', () => { expect(indexCode).toContain('ADMIN_PASSWORD_CONFIGURED'); - expect(indexCode).toContain('if (IS_PRODUCTION && !ADMIN_PASSWORD_CONFIGURED)'); - expect(indexCode).toContain('生产环境未配置后台登录口令'); + expect(indexCode).toContain('if (SECURE_MODE && !ADMIN_PASSWORD_CONFIGURED)'); + expect(indexCode).toContain('后台登录口令未配置'); }); it('启动自检提示默认口令与非生产放宽风险', () => { diff --git a/server/tests/content-check-auth.spec.ts b/server/tests/content-check-auth.spec.ts index 4c147a1..fd7c834 100644 --- a/server/tests/content-check-auth.spec.ts +++ b/server/tests/content-check-auth.spec.ts @@ -9,11 +9,11 @@ describe('第四十六批内容安全接口闸门', () => { const rootDir = path.resolve(__dirname, '../../'); 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'"); expect(start).toBeGreaterThan(-1); const block = indexCode.slice(start, start + 1200); - expect(block).toContain('IS_PRODUCTION'); + expect(block).toContain('SECURE_MODE'); expect(block).toContain("'内容安全检测'"); expect(block).toContain('await resolveUserOpenid(callerId)'); expect(block).not.toContain('const { content, openid, scene } = req.body'); diff --git a/server/tests/login-hardening.spec.ts b/server/tests/login-hardening.spec.ts index f3ef897..53737c3 100644 --- a/server/tests/login-hardening.spec.ts +++ b/server/tests/login-hardening.spec.ts @@ -20,7 +20,7 @@ describe('第二十二批登录安全闸门', () => { it('登录路由:超限锁定 + 常量时间比较', () => { const start = indexCode.indexOf("app.post('/api/user/login'"); 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('登录失败次数过多'); expect(block).toContain('recordLoginFailure(loginKey)'); diff --git a/server/tests/ownership-hardening.spec.ts b/server/tests/ownership-hardening.spec.ts index 3960b5f..1b81e77 100644 --- a/server/tests/ownership-hardening.spec.ts +++ b/server/tests/ownership-hardening.spec.ts @@ -57,7 +57,7 @@ describe('第七批安全加固闸门 (评价回复 / 服务上下架 / 通用 describe('3. 通用核销归属 (生产闸门)', () => { it('生产环境必须校验核销人:活动=发起人,订单=承接大神', () => { 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('Number(info.creatorId) === operator'); expect(block).toContain('own!.companionIds.includes(operator)'); diff --git a/server/tests/secure-mode-gates.spec.ts b/server/tests/secure-mode-gates.spec.ts new file mode 100644 index 0000000..08b4f69 --- /dev/null +++ b/server/tests/secure-mode-gates.spec.ts @@ -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 };'); + }); +}); diff --git a/server/tests/upload-auth.spec.ts b/server/tests/upload-auth.spec.ts index 2cfcbe0..46df540 100644 --- a/server/tests/upload-auth.spec.ts +++ b/server/tests/upload-auth.spec.ts @@ -9,11 +9,11 @@ describe('第四十五批上传鉴权闸门', () => { const rootDir = path.resolve(__dirname, '../../'); const indexCode = fs.readFileSync(path.join(rootDir, 'server/src/index.ts'), 'utf-8'); - it('生产环境上传必须要求登录', () => { + it('上传必须要求登录(安全闸门不依赖 NODE_ENV)', () => { const start = indexCode.indexOf('const handleFileUpload ='); expect(start).toBeGreaterThan(-1); 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('请先登录后再上传'); }); diff --git a/server/tests/verify-pool-hardening.spec.ts b/server/tests/verify-pool-hardening.spec.ts index fb4589c..e180e02 100644 --- a/server/tests/verify-pool-hardening.spec.ts +++ b/server/tests/verify-pool-hardening.spec.ts @@ -21,7 +21,7 @@ describe('第十五批核销码与抢单池加固闸门', () => { it('必须要求登录并按门票归属方校验', () => { const block = routeBlock("app.get('/api/verify/query/:code'"); expect(block).toContain("'查询核销码'"); - expect(block).toContain('IS_PRODUCTION && !isVerifiedAdminRequest(req)'); + expect(block).toContain('SECURE_MODE && !isVerifiedAdminRequest(req)'); expect(block).toContain('无权查看该核销码信息'); }); });