diff --git a/miniapp/pages/counseling-request/counseling-request.ts b/miniapp/pages/counseling-request/counseling-request.ts index 0836224..2870ea1 100644 --- a/miniapp/pages/counseling-request/counseling-request.ts +++ b/miniapp/pages/counseling-request/counseling-request.ts @@ -1,5 +1,6 @@ export {} import { request } from '../../utils/requestPromise' +import api from '../../utils/api' const app = getApp<{ globalData: { apiBase: string; token: string } }>(); Page({ @@ -45,6 +46,9 @@ Page({ return; } this.setData({ submitting: true, errorMsg: '' }); + // 幂等键在「一次提交」内保持稳定:网络异常后用户重试复用同一把键, + // 后端据此识别为重复投递(重放首次结果)而不是第二次申请。 + const idempotencyKey = api.idempotencyKey('counseling-apply', serviceType); try { const res = await request({ url: `${app.globalData.apiBase}/counseling/services`, @@ -58,10 +62,13 @@ Page({ summary, requestSource: 'user_self', // 幂等键:同一次填写重复提交不会产生两条申请 - idempotencyKey: `counseling-${serviceType}-${Date.now()}` + // 幂等键由 api 层托管(见 onSubmit 开头的说明) + idempotencyKey } }); if (res.statusCode === 200 && res.data && res.data.success) { + // 提交已落地:释放幂等键,下一次申请是新的一次 + api.releaseIdempotencyKey(idempotencyKey); wx.showToast({ title: '申请已提交', icon: 'success' }); setTimeout(() => wx.redirectTo({ url: '/pages/my-counseling/my-counseling' }), 1500); } else { diff --git a/miniapp/pages/premarital-request/premarital-request.ts b/miniapp/pages/premarital-request/premarital-request.ts index 3b4d09b..3fa238d 100644 --- a/miniapp/pages/premarital-request/premarital-request.ts +++ b/miniapp/pages/premarital-request/premarital-request.ts @@ -1,5 +1,6 @@ export {} import { request } from '../../utils/requestPromise' +import api from '../../utils/api' const app = getApp<{ globalData: { apiBase: string; token: string } }>(); Page({ @@ -22,6 +23,9 @@ Page({ return; } this.setData({ submitting: true, errorMsg: '' }); + // 幂等键在「一次提交」内保持稳定:网络异常后用户重试复用同一把键, + // 后端据此识别为重复投递(重放首次结果)而不是第二次申请。 + const idempotencyKey = api.idempotencyKey('premarital-apply', name); try { const res = await request({ url: `${app.globalData.apiBase}/counseling/services`, @@ -35,10 +39,13 @@ Page({ requestSource: 'user_self', // INV-005:伴侣须**本人**确认。此处只登记受邀伴侣 ID,确认动作由伴侣本人另行完成。 companionUserId: hasCompanion ? (companionUserId || null) : null, - idempotencyKey: `premarital-${name}-${Date.now()}` + // 幂等键由 api 层托管(见 onSubmit 开头的说明) + idempotencyKey } }); if (res.statusCode === 200 && res.data && res.data.success) { + // 提交已落地:释放幂等键,下一次申请是新的一次 + api.releaseIdempotencyKey(idempotencyKey); wx.showToast({ title: hasCompanion ? '已提交,伴侣需本人确认' : '申请已提交', icon: 'success' }); setTimeout(() => wx.redirectTo({ url: '/pages/my-counseling/my-counseling' }), 1800); } else { diff --git a/miniapp/services/activityService.js b/miniapp/services/activityService.js index 6d96363..63c125b 100644 --- a/miniapp/services/activityService.js +++ b/miniapp/services/activityService.js @@ -36,7 +36,7 @@ function getActivityDetail(activityId) { * @returns {Promise<{ registrationStatus: 'pending', registrationId: string }>} */ function register(activityId, payload = {}) { - const idempotencyKey = `activity-register:${activityId}:${Date.now()}`; + const idempotencyKey = api.idempotencyKey('activity-register', activityId); return api.post(`/activities/${encodeURIComponent(activityId)}/register`, payload, undefined, idempotencyKey); } @@ -66,21 +66,17 @@ function getMyActivities(params = {}) { * @param {{ rating: number, content: string }} payload * @returns {Promise<{ submitted: true }>} */ -function submitFeedback(activityId, payload) { - const idempotencyKey = `activity-feedback:${activityId}:${Date.now()}`; - return api.post(`/activities/${encodeURIComponent(activityId)}/feedback`, payload, undefined, idempotencyKey); -} - /** - * 提交活动互选意向(API_INVENTORY 行 98,AGENTS 第 6 条:单向意向保密) - * @param {string} activityId - * @param {{ likedUserIds: string[] }} payload - * @returns {Promise<{ submitted: true, matchedUserIds: string[] }>} - * matchedUserIds 仅包含双向匹配成功的人;未匹配的人不会泄露是否被对方选择。 + * 提交活动反馈(含表达意向 via interestTargetUserIds) + * + * 注意:此前的 `submitInterests()` → `POST /activities/{id}/interests` + * **后端不存在该端点**(Phase 16 已把表达意向合并进本流程),调用必然 404。 + * 全仓确认无页面调用方,属设计残留,已删除。若需要独立的互选提交接口, + * 须先由产品定义语义并补后端端点。 */ -function submitInterests(activityId, payload) { - const idempotencyKey = `activity-interests:${activityId}:${Date.now()}`; - return api.post(`/activities/${encodeURIComponent(activityId)}/interests`, payload, undefined, idempotencyKey); +function submitFeedback(activityId, payload) { + const idempotencyKey = api.idempotencyKey('activity-feedback', activityId); + return api.post(`/activities/${encodeURIComponent(activityId)}/feedback`, payload, undefined, idempotencyKey); } module.exports = { @@ -90,5 +86,4 @@ module.exports = { cancelRegistration, getMyActivities, submitFeedback, - submitInterests, }; diff --git a/miniapp/services/appealService.js b/miniapp/services/appealService.js index 6cc7ffb..1b4be4c 100644 --- a/miniapp/services/appealService.js +++ b/miniapp/services/appealService.js @@ -18,7 +18,7 @@ const api = require('../utils/api'); function submitAppeal(payload) { // 幂等键:申诉类型 + 关联处置 ID + 当前秒,防止用户多次点击重复提交 const actionKey = payload.relatedActionId || 'no_action'; - const idempotencyKey = `appeal:${payload.appealType}:${actionKey}:${Math.floor(Date.now() / 1000)}`; + const idempotencyKey = api.idempotencyKey('appeal', `${payload.appealType}:${actionKey}`); return api.post('/appeals', payload, undefined, idempotencyKey); } diff --git a/miniapp/services/authService.js b/miniapp/services/authService.js index 0cf794d..95eba59 100644 --- a/miniapp/services/authService.js +++ b/miniapp/services/authService.js @@ -36,11 +36,11 @@ function refresh() { /** * 登出(API_INVENTORY 行 11) - * 幂等:Idempotency-Key = "logout:" + 当前毫秒时间,便于后端去重。 + * 幂等:键由 api 层托管,网络异常重试时复用同一把,避免后端重复记账。 * @returns {Promise} */ function logout() { - return api.post('/auth/logout', {}, undefined, 'logout:' + Date.now()); + return api.post('/auth/logout', {}, undefined, api.idempotencyKey('logout')); } module.exports = { diff --git a/miniapp/services/matchService.js b/miniapp/services/matchService.js index 380b075..b1d5b03 100644 --- a/miniapp/services/matchService.js +++ b/miniapp/services/matchService.js @@ -26,7 +26,7 @@ const api = require('../utils/api'); * @returns {Promise} */ function like(userId) { - const idempotencyKey = `like:${userId}:${Date.now()}`; + const idempotencyKey = api.idempotencyKey('like', userId); return api.post('/likes', { targetUserId: userId }, undefined, idempotencyKey); } @@ -51,7 +51,7 @@ function unlike(userId) { * @returns {Promise} */ function pass(userId) { - const idempotencyKey = `pass:${userId}:${Date.now()}`; + const idempotencyKey = api.idempotencyKey('pass', userId); return api.post('/passes', { targetUserId: userId }, undefined, idempotencyKey); } @@ -65,7 +65,7 @@ function pass(userId) { * @returns {Promise} */ function block(userId, payload = {}) { - const idempotencyKey = `block:${userId}:${Date.now()}`; + const idempotencyKey = api.idempotencyKey('block', userId); return api.post('/blocks', { targetUserId: userId, reason: payload.reason }, undefined, idempotencyKey); } @@ -145,7 +145,7 @@ function getMatches(params = {}) { * @returns {Promise} */ function unmatch(matchId) { - const idempotencyKey = `unmatch:${matchId}:${Date.now()}`; + const idempotencyKey = api.idempotencyKey('unmatch', matchId); return api.post(`/matches/${encodeURIComponent(matchId)}/unmatch`, {}, undefined, idempotencyKey); } @@ -158,7 +158,7 @@ function unmatch(matchId) { * @returns {Promise<{ requestId: string, initiatedBy: string, status: 'pending' }>} */ function requestSuccess(matchId) { - const idempotencyKey = `success-req:${matchId}:${Date.now()}`; + const idempotencyKey = api.idempotencyKey('success-req', matchId); return api.post(`/matches/${encodeURIComponent(matchId)}/success/request`, {}, undefined, idempotencyKey); } @@ -171,7 +171,7 @@ function requestSuccess(matchId) { * @returns {Promise<{ relationshipStatus: string }>} */ function confirmSuccess(matchId) { - const idempotencyKey = `success-confirm:${matchId}:${Date.now()}`; + const idempotencyKey = api.idempotencyKey('success-confirm', matchId); return api.post(`/matches/${encodeURIComponent(matchId)}/success/confirm`, {}, undefined, idempotencyKey); } @@ -182,7 +182,7 @@ function confirmSuccess(matchId) { * @returns {Promise} */ function declineSuccess(matchId) { - const idempotencyKey = `success-decline:${matchId}:${Date.now()}`; + const idempotencyKey = api.idempotencyKey('success-decline', matchId); return api.post(`/matches/${encodeURIComponent(matchId)}/success/decline`, {}, undefined, idempotencyKey); } diff --git a/miniapp/services/matchmakerService.js b/miniapp/services/matchmakerService.js index ab10fb7..1c433a9 100644 --- a/miniapp/services/matchmakerService.js +++ b/miniapp/services/matchmakerService.js @@ -15,7 +15,7 @@ const api = require('../utils/api'); * @returns {Promise<{ serviceId: string, status: 'submitted' }>} */ function applyService(payload) { - const idempotencyKey = `matchmaker-apply:${Date.now()}`; + const idempotencyKey = api.idempotencyKey('matchmaker-apply'); return api.post('/matchmaker/services', payload, undefined, idempotencyKey); } diff --git a/miniapp/services/profileService.js b/miniapp/services/profileService.js index 7df19b2..dcee610 100644 --- a/miniapp/services/profileService.js +++ b/miniapp/services/profileService.js @@ -22,7 +22,7 @@ function getProfile() { */ function updateProfile(payload) { // PUT 默认幂等:用 userId + 字段指纹作为幂等键,避免重复提交时脏写 - const idempotencyKey = 'profile-put:' + (payload && payload.userId || 'me') + ':' + Date.now(); + const idempotencyKey = api.idempotencyKey('profile-put', (payload && payload.userId) || 'me'); return api.put('/me/profile', payload, undefined, idempotencyKey); } @@ -40,7 +40,7 @@ function getPreferences() { * @returns {Promise} */ function updatePreferences(payload) { - const idempotencyKey = 'preferences-put:' + Date.now(); + const idempotencyKey = api.idempotencyKey('preferences-put'); return api.put('/me/preferences', payload, undefined, idempotencyKey); } @@ -67,7 +67,7 @@ function deletePhoto(photoId) { * @returns {Promise<{ avatarUrl: string }>} */ function setMainPhoto(photoId) { - const idempotencyKey = 'photo-main:' + photoId + ':' + Date.now(); + const idempotencyKey = api.idempotencyKey('photo-main', photoId); return api.put(`/me/photos/${photoId}/main`, {}, undefined, idempotencyKey); } @@ -94,7 +94,7 @@ function getPrivacySettings() { * @returns {Promise} */ function updatePrivacySettings(payload) { - const idempotencyKey = 'privacy-put:' + Date.now(); + const idempotencyKey = api.idempotencyKey('privacy-put'); return api.put('/me/privacy-settings', payload, undefined, idempotencyKey); } @@ -129,7 +129,7 @@ function getReviewStatus() { * @returns {Promise<{ reviewStatus: 'pending', submittedAt: number }>} */ function submitReview() { - const idempotencyKey = 'review-submit:' + Date.now(); + const idempotencyKey = api.idempotencyKey('review-submit'); return api.post('/me/profile/submit-review', {}, undefined, idempotencyKey); } @@ -147,7 +147,7 @@ function getRelationshipStatus() { * @returns {Promise<{ status: 'paused', recommendationEnabled: boolean }>} */ function pauseSearching() { - const idempotencyKey = 'rel-pause:' + Date.now(); + const idempotencyKey = api.idempotencyKey('rel-pause'); return api.patch('/me/relationship-status', { status: 'paused' }, undefined, idempotencyKey); } @@ -156,7 +156,7 @@ function pauseSearching() { * @returns {Promise<{ status: 'searching', recommendationEnabled: boolean }>} */ function resumeSearching() { - const idempotencyKey = 'rel-resume:' + Date.now(); + const idempotencyKey = api.idempotencyKey('rel-resume'); return api.patch('/me/relationship-status', { status: 'searching' }, undefined, idempotencyKey); } @@ -165,7 +165,7 @@ function resumeSearching() { * @returns {Promise<{ status: 'getting_to_know', recommendationEnabled: boolean }>} */ function markGettingToKnow() { - const idempotencyKey = 'rel-gtk:' + Date.now(); + const idempotencyKey = api.idempotencyKey('rel-gtk'); return api.patch('/me/relationship-status', { status: 'getting_to_know' }, undefined, idempotencyKey); } diff --git a/miniapp/services/safetyService.js b/miniapp/services/safetyService.js index d4a7769..f3ae9dc 100644 --- a/miniapp/services/safetyService.js +++ b/miniapp/services/safetyService.js @@ -21,7 +21,7 @@ const api = require('../utils/api'); */ function submitReport(payload) { // 幂等键:目标用户 + 举报类型 + 当前秒,防止用户多次点击重复提交 - const idempotencyKey = `report:${payload.targetUserId}:${payload.reportType}:${Math.floor(Date.now() / 1000)}`; + const idempotencyKey = api.idempotencyKey('report', `${payload.targetUserId}:${payload.reportType}`); return api.post('/reports', payload, undefined, idempotencyKey); } diff --git a/miniapp/services/verificationService.js b/miniapp/services/verificationService.js index 1c86da7..4af2013 100644 --- a/miniapp/services/verificationService.js +++ b/miniapp/services/verificationService.js @@ -15,7 +15,7 @@ const api = require('../utils/api'); function submit(payload) { // 幂等键:身份证号后4位 + 当前秒,避免用户多次点击 const tail = (payload.idCardNumber || '').slice(-4).toUpperCase(); - const idempotencyKey = 'verification-submit:' + tail + ':' + Math.floor(Date.now() / 1000); + const idempotencyKey = api.idempotencyKey('verification-submit', tail); return api.post('/verification/submit', payload, undefined, idempotencyKey); } diff --git a/miniapp/stores/activityStore.js b/miniapp/stores/activityStore.js index 56622da..37c4047 100644 --- a/miniapp/stores/activityStore.js +++ b/miniapp/stores/activityStore.js @@ -8,8 +8,9 @@ * * 不变量(AGENTS.md): * - 第 1 条:用户永久免费——活动 feeType=paid 字段仅作展示,前端不实现任何支付入口。 - * - 第 6 条:活动单向意向保密——submitInterests 返回的 matchedUserIds 仅含双向匹配, - * 未匹配的人不在前端可见;Store 不缓存"谁单独选了我"的字段。 + * - 第 6 条:活动单向意向保密——表达意向走 submitFeedback 的 interestTargetUserIds, + * 其返回的 newMutuals 仅含双向匹配成功的人;未匹配的人不在前端可见; + * Store 不缓存"谁单独选了我"的字段。 */ const BaseStore = require('./baseStore'); const { activityService } = require('../services'); @@ -166,16 +167,15 @@ class ActivityStore extends BaseStore { } /** - * 提交活动互选意向(AGENTS 第 6 条:单向意向保密) - * @param {string} activityId - * @param {string[]} likedUserIds - * @returns {Promise<{ matchedUserIds: string[] }>} 仅返回双向匹配的 userIds + * 提交活动反馈(含表达意向) + * + * 原有一个 `submitInterests(activityId, likedUserIds)` 已删除:它请求 + * `POST /activities/{id}/interests` —— **后端没有这个端点**(Phase 16 把 + * 表达意向合并进反馈流程,即本方法的 `interestTargetUserIds`),调用必然 404。 + * 全仓已确认它没有任何页面调用方,属 Phase 16 前的设计残留。 + * 若将来确实需要独立的"提交互选"接口,须由产品先定义语义再补后端端点, + * 不应让前端留一个指向 404 的方法。 */ - async submitInterests(activityId, likedUserIds) { - return activityService.submitInterests(activityId, { likedUserIds }); - } - - /** 提交活动反馈 */ async submitFeedback(activityId, payload) { return activityService.submitFeedback(activityId, payload); } diff --git a/miniapp/utils/api.js b/miniapp/utils/api.js index ccb3fc0..ae63711 100644 --- a/miniapp/utils/api.js +++ b/miniapp/utils/api.js @@ -7,6 +7,14 @@ const { BUSINESS_LIMITS, REQUEST_ERROR } = require('../constants'); const { API_BASE_URL } = require('../env'); +/** + * 未成功操作的幂等键保留时长。 + * + * 取 10 分钟:足够覆盖用户「看到失败 → 重试」的常规间隔,又不至于让一把 + * 早该作废的键长期占据缓存、把两次真正独立的操作误认成同一次。 + */ +const IDEMPOTENCY_KEY_TTL_MS = 10 * 60 * 1000; + class ApiClient { constructor() { this.baseURL = API_BASE_URL; @@ -16,6 +24,52 @@ class ApiClient { this.onSessionExpired = null; /** @type {((config: object) => void)|null} 维护态拦截回调,由 AppStore 注入 */ this.onMaintenance = null; + /** 未确认成功的幂等键缓存:操作指纹 -> {key, createdAt}。语义见 idempotencyKey()。 */ + this._pendingIdempotency = {}; + } + + /** + * 取一次逻辑操作的幂等键。 + * + * @param {string} scope 操作域,如 'activity-register' + * @param {string} [subject] 操作对象(活动 ID / 目标用户 ID 等) + * @returns {string} 幂等键 + * + * 幂等键的语义是「同一次逻辑操作的重复投递用同一把键」,而不是「每次调用 + * 一把新键」。此前各 service 自行拼 `xxx:${Date.now()}`,每次调用都是新键 + * —— 后端即便实现了幂等也永远命中不了,「弱网重发」照样产生重复数据。 + * + * 这里由 api 层统一托管:同一次**尚未成功**的操作复用同一把键, + * 成功后才释放,下一次点击才是新的一次操作。 + */ + idempotencyKey(scope, subject) { + const fingerprint = `${scope}::${subject == null ? '' : subject}`; + const now = Date.now(); + const cached = this._pendingIdempotency[fingerprint]; + if (cached && now - cached.createdAt < IDEMPOTENCY_KEY_TTL_MS) { + return cached.key; + } + const key = `${scope}:${subject == null ? '' : subject}:${this.generateRequestId()}`; + this._pendingIdempotency[fingerprint] = { key, createdAt: now }; + return key; + } + + /** + * 释放幂等键:一次逻辑操作成功后调用,下一次同域操作属新的一次。 + * + * 公开方法:`request()` 内部在业务成功后自动调用;直接使用 + * `utils/requestPromise` 绕开本层封装的页面(如 counseling-request) + * 需在成功后自行调用,否则一把旧键会被一直复用。 + */ + releaseIdempotencyKey(key) { + if (!key) return; + const fingerprints = Object.keys(this._pendingIdempotency); + for (let i = 0; i < fingerprints.length; i += 1) { + if (this._pendingIdempotency[fingerprints[i]].key === key) { + delete this._pendingIdempotency[fingerprints[i]]; + return; + } + } } /** 读取本地存储的 access token */ @@ -98,6 +152,10 @@ class ApiClient { if (res.statusCode === 200) { const body = res.data || {}; if (body.success) { + // 操作已成功落地:释放幂等键,下一次同域操作是新的一次。 + // 失败时刻意**不释放** —— 用户重试时复用同一把键, + // 后端才能识别出这是重复投递而不是第二次操作。 + this._releaseIdempotencyKey(idempotencyKey); resolve(body.data); } else { reject({