fix(activity): close Phase 14 KEEP-VERIFY/EXTEND — 11 backend + 3 frontend-contract + 2 test-fidelity defects

Phase 14 (multi-type public-welfare activities) verification closure.

Backend (D1-D11):
- D1 CRITICAL: ActivityMapper.countRemainingCapacity had no @Select and no XML
  -> activity detail / register / approve all threw Invalid bound statement.
  Removed the shell; unified on ActivityRegistrationMapper.
- D2 CRITICAL: register & approve were read-then-write -> oversold under
  concurrency. Replaced with single atomic SQL (insertIfCapacityAvailable /
  approveIfCapacityAvailable, affectedRows==0 => full).
- D3 HIGH: activityType / orgRole never validated against production
  chk_activity_type / chk_org_role -> 500 only on PostgreSQL.
- D4 HIGH: ActivityListItemDTO had no activityType -> miniapp category tabs
  always empty (Gate failure).
- D5: male/female counts were fabricated as approvedCount/2; now real
  aggregation over user_profiles.gender.
- D6: minAge/maxAge was a TODO no-op; added assertAgeInRange.
- D7: registration_policy write-only; per-type eligibility now enforced
  (identity / local eligibility / gender quota).
- D8: POST /operator/activities dropped type, multi-org and capacity groups
  and set organizer_org_id to the operator user id; now delegates to
  createActivityFromAdmin (single implementation).
- D9: three operator write endpoints were mounted on the user-side
  ActivityController (dual implementation, zero callers) -> moved.
- D10: NPE on soft-deleted activity in getMyActivities; NPE/500 on missing
  admin form fields (Instant.parse(null)); tolerant time parsing added.
- D11: DTO lacked registeredCount / status / coverFileId / description
  -> card always showed 0 registered, "view detail", no cover, no summary.

Frontend contract (F1-F3):
- F1: miniapp category code "classroom" is not in the production whitelist
  (should be relationship_education); three types had no tab at all.
- F2: ui-activity-card read 7 fields that do not exist on the backend;
  now reads only real DTO fields, plus per-type badge and eligibility hint.
- F3: admin-web had no activity management page at all; added
  ActivityManagementView.vue (type-driven dynamic form, multi-org,
  capacity groups, publish/cancel) + /activities route and nav entry.

Test fidelity (T1-T2):
- T1: deleted two assertTrue(true) placeholder tests (testNoOverbooking,
  testNoDuplicateRegistration) that covered the exact broken invariants.
- T2: test schema lacked V023 columns/constraints and three tables; the
  activity tables were only in the OPTIONAL set (zero coverage) -> schema
  completed and moved to REQUIRED_IN_TEST_SCHEMA.

Tests: ActivityPhase14Test (23 cases), ActivityConstraintGuardTest (9 cases,
parses real db/migration/V*.sql plus miniapp source). Two negative-verification
rounds proved both guard families actually turn red.

Regression: 450 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(baseline 420). admin-web vue-tsc 0 error, vite build OK.
This commit is contained in:
WorkBuddy
2026-09-17 14:02:00 +08:00
parent 7a2693b239
commit 755a570849
22 changed files with 2164 additions and 136 deletions
+89 -1
View File
@@ -4,6 +4,36 @@
* 不变量(AGENTS.md 第 1 条):feeType=paid 仅作展示字段,前端不实现任何支付入口。
* "公益免费"作为主要视觉标识突出展示。
*/
/**
* 活动类型 → 中文名。
*
* <p><b>Phase 14</b>:键必须**逐字等于**后端 {@code activities.activity_type}
* (生产约束 {@code chk_activity_type} 白名单)。守卫会解析真实 DDL 校验。
*/
const ACTIVITY_TYPE_LABELS = {
matchmaking: '公益联谊',
interest_social: '兴趣社交',
relationship_education: '婚恋课堂',
premarital_guidance: '婚前辅导',
marriage_custom_culture: '婚俗文化',
anti_fraud: '反诈宣传',
};
/**
* 按类型的**差异化资格提示**(Gate 要求:matchmaking 与课堂/反诈类不同)。
*
* <p>相亲类接触陌生人 → 实名 + 本地资格 + 性别名额;
* 婚恋课堂 / 婚俗文化 / 反诈宣传是公益科普 → 不设实名与本地资格门槛。
*/
const ACTIVITY_TYPE_ELIGIBILITY = {
matchmaking: '需实名认证 · 丹阳本地资格',
premarital_guidance: '需实名认证',
interest_social: '按男女名额分别报名',
relationship_education: '公益科普 · 免费参与',
marriage_custom_culture: '公益科普 · 免费参与',
anti_fraud: '公益科普 · 免费参与',
};
Component({
options: { addGlobalClass: true },
properties: {
@@ -11,10 +41,68 @@ Component({
showStatusBadge: { type: Boolean, value: true },
compact: { type: Boolean, value: false }, // 紧凑模式(首页推荐位)
},
data: {
typeLabel: '',
eligibilityText: '',
registeredCount: 0,
capacity: 0,
cover: '',
startAtText: '',
},
observers: {
/** 活动对象变化时重算派生展示字段(列表复用节点时 observer 同样触发) */
'activity': function (activity) {
if (!activity) {
this.setData({
typeLabel: '', eligibilityText: '', registeredCount: 0,
capacity: 0, cover: '', startAtText: '',
});
return;
}
const type = activity.activityType || '';
// Phase 14 修复:原 wxml 读 registeredCount / currentMaleCount / currentFemaleCount,
// 后端三个字段都不存在 → "报名人数" 恒显示 0。改为后端真实字段 + 本地兜底。
const registered =
typeof activity.registeredCount === 'number'
? activity.registeredCount
: (activity.maleCount || 0) + (activity.femaleCount || 0);
this.setData({
typeLabel: ACTIVITY_TYPE_LABELS[type] || '',
eligibilityText: ACTIVITY_TYPE_ELIGIBILITY[type] || '',
registeredCount: registered,
// 容量只认 totalLimit —— 原实现还有 activity.capacity 回退,但 DTO 并无该字段
capacity: activity.totalLimit || 0,
cover: buildCover(activity),
startAtText: formatStartAt(activity.startAt),
});
},
},
methods: {
onTap() {
if (!this.data.activity) return;
this.triggerEvent('tap', { activityId: this.data.activity.activityId || this.data.activity.id });
this.triggerEvent('tap', { activityId: this.data.activity.activityId });
},
},
});
/** 封面地址:优先后端给定 URL,否则按 uploadBase + /files/{coverFileId} 拼接 */
function buildCover(activity) {
if (activity.coverUrl) return activity.coverUrl;
if (!activity.coverFileId) return '';
try {
const app = getApp();
const base = (app && app.globalData && app.globalData.uploadBase) || '';
return base ? `${base}/files/${activity.coverFileId}` : '';
} catch (e) {
return '';
}
}
/** 开始时间:后端返回 ISO 串,转成「M月D日 HH:mm」 */
function formatStartAt(raw) {
if (!raw) return '';
const d = new Date(typeof raw === 'string' ? raw.replace(/-/g, '/').replace(/T/, ' ').slice(0, 19) : raw);
if (isNaN(d.getTime())) return String(raw);
const pad = (n) => (n < 10 ? `0${n}` : `${n}`);
return `${d.getMonth() + 1}月${d.getDate()}日 ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}