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.
95 lines
3.6 KiB
JavaScript
95 lines
3.6 KiB
JavaScript
/**
|
||
* 活动服务(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,
|
||
};
|