Files
danyang-match/miniapp/pages/activity/activity.ts
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

187 lines
5.8 KiB
TypeScript
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.
/**
* 公益活动列表页 U31
*
* 重构要点(遵循 AGENTS.md 不变量):
* - 数据:ActivityStore.fetchActivities / loadMoreActivities(游标分页)
* - 列表项:ui-activity-card 组件,"公益免费"为主视觉标识(AGENTS 第 1 条:用户永久免费)
* - 跳转:点击卡片 → activity-detail,不在列表页处理报名
* - 单向意向保密:本页不接入互选意向提交(仅在 activity-matches 页由用户主动操作,AGENTS 第 6 条)
* - 下拉刷新 + onReachBottom 加载更多
*/
import { activityStore, appStore, authStore } from '../../stores';
import { to } from '../../constants';
interface ActivityCategory {
code: string;
label: string;
}
/**
* 列表项。沿用共享 Activity 类型,并允许服务端附加分类字段用于本地筛选。
* 分类字段在共享类型中尚未收敛,此处显式声明,避免用 any 绕过类型检查。
*/
interface ActivityListItem {
activityId?: string;
id?: string;
activityType?: string;
category?: string;
[key: string]: unknown;
}
interface ActivityPageData {
activities: ActivityListItem[];
loading: boolean;
loadingMore: boolean;
hasMore: boolean;
firstLoaded: boolean;
activeCategory: string;
categories: ActivityCategory[];
emptyConfig: {
title: string;
description: string;
illustration: string;
actions: Array<{ text: string; event: string; variant: string }>;
};
}
Page({
data: {
activities: [] as ActivityListItem[],
loading: false,
loadingMore: false,
hasMore: true,
firstLoaded: false,
activeCategory: 'all',
/**
* 分类 Tabs。
*
* <p><b>Phase 14 修复</b>:code 必须**逐字等于**后端 {@code activities.activity_type}
* 的取值(生产约束 {@code chk_activity_type} 白名单)。此前这里写的是
* {@code classroom},而生产白名单里根本没有该值(婚恋课堂是
* {@code relationship_education})—— 点「婚恋课堂」Tab 恒为空白页。
*
* <p>守卫 {@code ActivityConstraintGuardTest#miniappCategoryCodesWithinProductionWhitelist}
* 会解析真实 DDL 校验本数组,改错即刻变红。
*/
categories: [
{ code: 'all', label: '全部' },
{ code: 'matchmaking', label: '公益联谊' },
{ code: 'interest_social', label: '兴趣社交' },
{ code: 'relationship_education', label: '婚恋课堂' },
{ code: 'premarital_guidance', label: '婚前辅导' },
{ code: 'marriage_custom_culture', label: '婚俗文化' },
{ code: 'anti_fraud', label: '反诈宣传' },
] as ActivityCategory[],
/** @type {{title:string, description:string, illustration:string, actions:Array}} */
emptyConfig: {
title: '暂无进行中的活动',
description: '近期暂无报名中的公益活动,请稍后再来',
illustration: 'activity',
actions: [
{ text: '我的活动', event: 'my', variant: 'secondary' },
],
},
} as ActivityPageData,
onLoad() {
if (!authStore.state.isLoggedIn) return;
(this as any)._unsub = activityStore.bindToPage(this, {
activities: 'activities',
activitiesHasMore: 'activitiesHasMore',
});
this.fetchActivities(true);
},
onShow() {
if (!authStore.state.isLoggedIn) return;
// 从活动详情返回后,若列表为空则刷新(避免详情页报名后列表不同步)
if (this.data.firstLoaded && this.data.activities.length === 0) {
this.fetchActivities(true);
}
},
onUnload() {
if ((this as any)._unsub) (this as any)._unsub();
},
/** 首次或下拉刷新拉取活动列表 */
async fetchActivities(_first = false) {
if (this.data.loading) return;
this.setData({ loading: true });
try {
await activityStore.fetchActivities();
this.applyActivityFilter();
this.setData({
loading: false,
firstLoaded: true,
hasMore: activityStore.state.activitiesHasMore,
});
} catch (err) {
this.setData({ loading: false, firstLoaded: true });
appStore.toastError(err, '加载活动失败');
}
},
/** 加载更多(onReachBottom 触发) */
async loadMore() {
if (this.data.loadingMore || !this.data.hasMore) return;
this.setData({ loadingMore: true });
try {
await activityStore.loadMoreActivities();
this.applyActivityFilter();
this.setData({
loadingMore: false,
hasMore: activityStore.state.activitiesHasMore,
});
} catch (err) {
this.setData({ loadingMore: false });
appStore.toastError(err, '加载更多失败');
}
},
onPullDownRefresh() {
this.fetchActivities(true).then(() => {
wx.stopPullDownRefresh();
});
},
onReachBottom() {
this.loadMore();
},
onCategoryTap(e: WechatMiniprogram.TouchEvent) {
this.setData({ activeCategory: e.currentTarget.dataset.code as string });
this.applyActivityFilter();
},
applyActivityFilter() {
const source = (activityStore.state.activities || []) as ActivityListItem[];
const { activeCategory } = this.data;
this.setData({
activities: activeCategory === 'all'
? source
: source.filter((item) => (item.activityType || item.category) === activeCategory),
});
},
/** 点击活动卡片 → 跳转详情 */
onActivityTap(e: any) {
const activityId = e.detail.activityId;
if (!activityId) return;
wx.navigateTo({ url: to.activityDetail(activityId) });
},
/** 空状态:去我的活动 */
onEmptyAction(e: any) {
const { event } = e.detail;
if (event === 'my') {
wx.navigateTo({ url: '/pages/my-activities/my-activities' });
}
},
/** 顶部"我的活动"入口 */
onMyActivities() {
wx.navigateTo({ url: '/pages/my-activities/my-activities' });
},
});