Files
danyang-match/miniapp/services/activityService.js
T
WorkBuddy 29112eed6e fix(activity): close Phase 15 KEEP-VERIFY/EXTEND — 2 critical + 14 other defects, 3 test-fidelity fixes
Phase 15 (activity registration / review / checkin) verification closure.

Two CRITICAL defects, both "production always had it, test schema never did":
- D1: generateCheckinToken wrote the token to Redis with value "used", while
  scanCheckin rejects any token marked "used" -> every freshly issued check-in
  code was dead on arrival, staff scanning failed 100% of the time.
  Issuing now writes "issued"; "used" is set only after a successful scan.
- D9: UNIQUE(activity_id, user_id) exists in production, but re-registering
  after cancel/reject went through INSERT -> users who cancelled once could
  never register for that activity again (duplicate key 500). The H2 test
  schema lacked the UNIQUE, so the path had zero coverage. Now the original
  row is reused via an atomic conditional UPDATE.

Checkin (D2-D8):
- D2: duplicate check-in was read-then-write; now atomic checkinIfNotCheckedIn
  and repeat scans are idempotent (alreadyCheckedIn) without duplicate audit.
- D3: no data scope at all -> added activity staff assignment (V040) and
  assertCheckinScope; INV-020 (org relation grants nothing).
- D4: manualCheckin did not validate activity existence/cancellation.
- D5: getCheckinStats NPE on missing activity; remainingCount was semantically
  "free seats" -> replaced with notCheckedInCount.
- D6: malformed timestamp caused 500 -> 400. D7: constant-time signature compare.

Registration / review / cancel (D10-D13):
- D10: backend registration list only queried status='approved' -> the review
  screen never saw pending rows (review unusable).
- D11: registration_start_at / registration_end_at were never enforced.
- D12: cancel had no audit log (the only write without one).
- D13: reject/cancel were read-then-full-update -> rejectIfPending/cancelIfActive.

Capacity groups (D14, plan item 2): groups were stored and read back but never
participated in capacity -> incrementIfAvailable / decrementIfPositive now make
approved_count real; activities without groups are unaffected.

Frontend contract (F1-F2):
- F1: admin-web had no check-in surface at all -> ActivityCheckinView.vue
  (paste/scan token, manual check-in, stats) + /activities/checkin route & nav.
- F2: miniapp had no cancel-registration wrapper although the endpoint existed.

Test fidelity (T1-T3):
- T1: test schema gained UNIQUE(activity_id,user_id) + chk_reg_status +
  chk_checkin_status; two new guards parse the real V010 DDL and compare.
- T2: 3 assertTrue(true) placeholders in CheckinServiceTest (covering duplicate
  check-in, expiry, no-sensitive-plaintext) replaced with 5 real guards that
  parse the real source and real production DDL.
- T3: activity_registrations + activity_staff_assignments moved into
  REQUIRED_IN_TEST_SCHEMA; MigrationValidationTest baseline 39 -> 40.

Note: ActivityStaffAssignment must NOT extend BaseEntity - the table is a
composite PK (activity_id, operator_id); BaseEntity would add id/updated_at/
deleted_at to every INSERT and break on PostgreSQL (caught by
EntityMigrationParityTest immediately).

Tests: ActivityPhase15Test (21), CheckinServiceTest (5), 2 new constraint
guards. Negative verification: 3 injected defects -> 8 assertions turned red,
all restored.

Regression: 474 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(baseline 450). admin-web vue-tsc 0 error, vite build OK.
2026-09-17 15:07:52 +08:00

95 lines
3.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 活动服务(API_INVENTORY 行 92-96, 98)
*
* 封装:活动列表 / 详情 / 报名 / 我的活动 / 反馈 / 互选意向提交。
* 关键不变量(AGENTS.md):
* - 第 6 条:活动单向意向保密——A 选 B 但 B 没选 → B 不知道。
* 前端调用 interests 提交后,结果接口只返回"匹配成功"的双向交集,
* 不返回对方是否单独选了我。
* - 第 1 条:用户永久免费——feeType=paid 仅作为后端字段,前端展示以"公益免费"为主标识。
* 报名与互选均走幂等键防重(AGENTS 第 12 条:参数配置化)。
*/
const api = require('../utils/api');
/**
* 活动列表(API_INVENTORY 行 92)
* @param {{ status?: string, cursor?: string, pageSize?: number }} params
* @returns {Promise<{ items: Array<import('../types').Activity>, nextCursor: string|null }>}
*/
function getActivities(params = {}) {
return api.get('/activities', params);
}
/**
* 活动详情(API_INVENTORY 行 93)
* @param {string} activityId
* @returns {Promise<import('../types').Activity & { registrationStatus: import('../types').RegistrationStatus }>}
*/
function getActivityDetail(activityId) {
return api.get(`/activities/${encodeURIComponent(activityId)}`);
}
/**
* 报名活动(API_INVENTORY 行 94)
* @param {string} activityId
* @param {{ remark?: string }} [payload]
* @returns {Promise<{ registrationStatus: 'pending', registrationId: string }>}
*/
function register(activityId, payload = {}) {
const idempotencyKey = `activity-register:${activityId}:${Date.now()}`;
return api.post(`/activities/${encodeURIComponent(activityId)}/register`, payload, undefined, idempotencyKey);
}
/**
* 取消报名(Phase 15)
* 后端:DELETE /activities/{activityId}/register
*
* 说明:后端为 UNIQUE(activity_id, user_id) 设计,取消后原记录保留为 cancelled,
* 再次报名会**复用**该记录(不会撞唯一键),因此取消不是不可逆操作。
*/
function cancelRegistration(activityId) {
return api.delete(`/activities/${encodeURIComponent(activityId)}/register`);
}
/**
* 我的活动(API_INVENTORY 行 95)
* @param {{ status?: string, cursor?: string, pageSize?: number }} params
* @returns {Promise<{ items: Array<import('../types').Activity & { registrationStatus: import('../types').RegistrationStatus }>, nextCursor: string|null }>}
*/
function getMyActivities(params = {}) {
return api.get('/my-activities', params);
}
/**
* 提交活动反馈(API_INVENTORY 行 96)
* @param {string} activityId
* @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 仅包含双向匹配成功的人;未匹配的人不会泄露是否被对方选择。
*/
function submitInterests(activityId, payload) {
const idempotencyKey = `activity-interests:${activityId}:${Date.now()}`;
return api.post(`/activities/${encodeURIComponent(activityId)}/interests`, payload, undefined, idempotencyKey);
}
module.exports = {
getActivities,
getActivityDetail,
register,
cancelRegistration,
getMyActivities,
submitFeedback,
submitInterests,
};