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:
@@ -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())}`;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
<!--
|
||||
公益活动卡片(规范 §43-46)
|
||||
|
||||
不变量(AGENTS.md 第 1 条):"公益免费"作为主要视觉标识突出展示,
|
||||
前端不实现任何支付入口。
|
||||
|
||||
Phase 14:所有 activity.* 字段都必须在 ActivityListItemDTO 中真实存在。
|
||||
守卫 ActivityConstraintGuardTest#cardFieldsExistInListItemDto 会解析本文件
|
||||
校验字段名 —— 此前这里的 registeredCount / currentMaleCount / currentFemaleCount /
|
||||
coverImageUrl / activityStartAt / suitableFor / intro 后端一个都没有,
|
||||
导致封面、摘要、报名人数恒不显示。
|
||||
-->
|
||||
<view class="ui-act {{compact ? 'ui-act--compact' : ''}}" bindtap="onTap">
|
||||
<view class="ui-act__cover">
|
||||
<image class="ui-act__cover-img" src="{{activity.coverUrl || activity.coverImageUrl || '/assets/covers/activity-default.jpg'}}" mode="aspectFill" />
|
||||
<image class="ui-act__cover-img" src="{{cover || '/assets/covers/activity-default.jpg'}}" mode="aspectFill" />
|
||||
<view class="ui-act__fee-badge">公益活动</view>
|
||||
</view>
|
||||
<view class="ui-act__body">
|
||||
<view class="ui-act__title">{{activity.title}}</view>
|
||||
<view class="ui-act__meta"><text>{{activity.startAtText || activity.activityStartAt || activity.startAt}}</text></view>
|
||||
<view class="ui-act__meta"><text>{{activity.locationName || activity.location || activity.address}}</text></view>
|
||||
<view wx:if="{{activity.suitableFor}}" class="ui-act__meta">适合:{{activity.suitableFor}}</view>
|
||||
<view wx:if="{{!compact && (activity.intro || activity.description)}}" class="ui-act__intro">{{activity.intro || activity.description}}</view>
|
||||
<!-- 类型化卡片:不同类型显示不同标签与资格提示(Gate ①) -->
|
||||
<view wx:if="{{typeLabel}}" class="ui-act__type-row">
|
||||
<text class="ui-act__type-badge">{{typeLabel}}</text>
|
||||
<text wx:if="{{eligibilityText}}" class="ui-act__eligibility">{{eligibilityText}}</text>
|
||||
</view>
|
||||
<view class="ui-act__meta"><text>{{startAtText}}</text></view>
|
||||
<view class="ui-act__meta"><text>{{activity.locationName}}</text></view>
|
||||
<view wx:if="{{!compact && activity.description}}" class="ui-act__intro">{{activity.description}}</view>
|
||||
<view class="ui-act__footer">
|
||||
<view class="ui-act__capacity">
|
||||
<text wx:if="{{activity.capacity || activity.totalLimit}}">报名人数 {{activity.registeredCount !== undefined ? activity.registeredCount : ((activity.currentMaleCount || 0) + (activity.currentFemaleCount || 0))}}/{{activity.capacity || activity.totalLimit}}</text>
|
||||
<text wx:if="{{capacity}}">报名人数 {{registeredCount}}/{{capacity}}</text>
|
||||
</view>
|
||||
<view class="ui-act__status-action {{activity.status !== 'registration_open' ? 'disabled' : ''}}">{{activity.status === 'registration_open' ? '立即报名' : (activity.status === 'ongoing' ? '进行中' : '查看详情')}}</view>
|
||||
</view>
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
.ui-act{overflow:hidden;border-radius:28rpx;background:#fff;box-shadow:0 10rpx 32rpx rgba(34,35,91,.08)}.ui-act__cover{position:relative;width:100%;height:240rpx;background:#F0F1F6}.ui-act__cover-img{display:block;width:100%;height:100%}.ui-act__fee-badge{position:absolute;top:18rpx;right:18rpx;padding:7rpx 15rpx;border-radius:22rpx;background:linear-gradient(135deg,var(--mini-accent-coral),var(--mini-accent-pink));color:#fff;font-size:18rpx;font-weight:600}.ui-act__body{padding:22rpx 24rpx}.ui-act__title{overflow:hidden;color:#222746;font-size:28rpx;font-weight:700;line-height:1.35;white-space:nowrap;text-overflow:ellipsis}.ui-act__meta{margin-top:7rpx;color:#767C94;font-size:20rpx;line-height:1.45}.ui-act__intro{display:-webkit-box;overflow:hidden;margin-top:12rpx;color:#626981;font-size:21rpx;line-height:1.55;-webkit-box-orient:vertical;-webkit-line-clamp:2}.ui-act__footer{display:flex;align-items:center;justify-content:space-between;margin-top:18rpx}.ui-act__capacity{color:#858A9E;font-size:19rpx}.ui-act__status-action{min-width:126rpx;height:56rpx;display:flex;align-items:center;justify-content:center;border-radius:28rpx;background:linear-gradient(135deg,var(--mini-accent-coral),var(--mini-accent-pink));color:#fff;font-size:21rpx;font-weight:650}.ui-act__status-action.disabled{background:#EFF0F5;color:#8E93A6}.ui-act--compact{display:flex;height:200rpx}.ui-act--compact .ui-act__cover{width:200rpx;height:200rpx;flex-shrink:0}.ui-act--compact .ui-act__body{flex:1;min-width:0;padding:16rpx 18rpx}.ui-act--compact .ui-act__intro{display:none}
|
||||
.ui-act__type-row{display:flex;align-items:center;margin-top:10rpx;flex-wrap:wrap}.ui-act__type-badge{padding:4rpx 14rpx;border-radius:18rpx;background:#FFF1F0;color:#D9453C;font-size:18rpx;font-weight:600}.ui-act__eligibility{margin-left:12rpx;color:#858A9E;font-size:18rpx}
|
||||
|
||||
Reference in New Issue
Block a user