Files
danyang-match/miniapp/components/activity-card/index.js
T
WorkBuddy 755a570849 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.
2026-09-17 14:02:00 +08:00

109 lines
3.9 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.
/**
* ActivityCard 活动卡片(规范 §43-46)
*
* 不变量(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: {
activity: { type: Object, value: null },
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 });
},
},
});
/** 封面地址:优先后端给定 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())}`;
}