fix(ops-backend/service-station): close Phase 19 KEEP-VERIFY — 25 defects (unreachable announcements + station write privilege escalation; banner 500 + soft-delete leak; content publish dead-end; program approval dead-end/draft leak/zero counts/missing withdraw; zero-coverage test schema) ; add V042 announcement chain + 34 guard tests; full regression 602 green

Backend
- Service Station: expose the 「临时公告」 chain end-to-end (V042 table/service existed
  but had ZERO endpoints -> both admin and miniapp could never reach it): 5 operator
  endpoints (list/create/update/publish/archive) + 1 user-facing read endpoint.
- Fix privilege escalation: create/update were guarded only by the class-level
  service_location.view, making V015's service_location.manage a never-used dead
  permission -> read-only roles could edit stations. Permissions moved to method level.
- Add ServiceLocationOperatorController to OperatorEndpointPermissionCoverageTest
  (it was NOT scanned, which is why the escalation stayed invisible).
- Add create/update validation (address NOT NULL + status CHECK only blew up on PG).
- Banner: validation failures threw bare RuntimeException -> 500; now VALIDATION_ERROR
  -> 400. Add deleted_at IS NULL to both home queries (soft-deleted banners stayed live).
- Content: operator list/detail split (admin «publish» was a dead end because it reused
  the published-only public API); category deactivate/reactivate; article body on edit.
- Service Program: operator member list now includes pending (approval reachable);
  active-only public visibility (draft leak); memberCount/pendingCount aggregation;
  withdraw/removeMember; reuse row on re-apply (UNIQUE(program_id,user_id) crash).
- MeController: add the missing GET /me/service-history route.

Schema / guards
- schema-h2.sql: add service_location_announcements (+ chk_announcement_status) and
  content_categories (+ uk_content_category_code, chk_category_status); move both into
  REQUIRED_IN_TEST_SCHEMA. MigrationValidationTest baseline 41 -> 42.
- V11PermissionMatrixContractTest now scans ALL migrations instead of hardcoding V015+V029.
- New: ServiceLocationAnnouncementPhase19IntegrationTest (24),
  ServiceLocationControllerPermissionContractTest (5),
  ServiceLocationAnnouncementConstraintParityTest (5, prod DDL <-> test schema <->
  backend whitelist <-> admin labels).

Frontend
- admin-web: ServiceStationView gains announcement management (statuses, create/edit,
  publish/archive with confirm, expiry); write buttons gated by service_location.manage.
- miniapp: U50 renders the 临时公告 block via new serviceStationService; page no longer
  talks to utils/api directly; announcement failure degrades to [] instead of erroring.
- ui-tests: U37 four-group + U50 announcement contracts; /home assertion retargeted to
  the service layer.

Verification
- Negative verification: 3 injected defects (drop soft-delete filter / drop test-schema
  CHECK / downgrade write endpoint to view) -> 4 named tests fail precisely, no
  collateral failures; restored, NEG-VAL residue = 0.
- Backend full regression: 602 tests, 0 failures, 0 errors, 3 skipped (baseline 420).
- admin-web: vue-tsc --noEmit EXIT=0, vite build EXIT=0. Miniapp: tsc --noEmit EXIT=0.
- ui-tests 14/14 PASS.
This commit is contained in:
WorkBuddy
2026-09-17 22:21:31 +08:00
parent 3f60611a74
commit 72b4e9b846
70 changed files with 5847 additions and 308 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
page{background:var(--color-bg-page)}.service-page{min-height:100vh;padding-bottom:calc(140rpx + env(safe-area-inset-bottom))}.hero{padding:44rpx 32rpx 54rpx;background:linear-gradient(140deg,var(--color-primary-lighter),#FFF8EE);position:relative;overflow:hidden}.eyebrow{display:inline-flex;padding:7rpx 14rpx;border-radius:999rpx;background:rgba(255,255,255,.72);color:var(--color-primary);font-size:21rpx}.hero-title{display:block;margin-top:18rpx;font-size:40rpx;font-weight:700;line-height:1.35}.hero-desc{display:block;width:82%;margin-top:14rpx;font-size:25rpx;color:var(--color-text-secondary);line-height:1.7}.content{padding:0 var(--spacing-page-x)}.block{margin-top:var(--spacing-section)}.block-title{display:block;font-size:31rpx;font-weight:650}.block-subtitle{display:block;margin-top:8rpx;font-size:23rpx;color:var(--color-text-tertiary)}.card{margin-top:18rpx;padding:28rpx;background:var(--color-bg-card);border:1rpx solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-card)}.feature-row,.menu-row,.record-row{display:flex;align-items:center;gap:20rpx;min-height:112rpx;padding:18rpx 0;border-bottom:1rpx solid var(--color-divider)}.feature-row:last-child,.menu-row:last-child,.record-row:last-child{border-bottom:0}.feature-icon{width:72rpx;height:72rpx;display:flex;align-items:center;justify-content:center;flex:none;border-radius:22rpx;background:var(--color-primary-light);color:var(--color-primary);font-weight:700}.feature-copy{flex:1;min-width:0}.feature-title{display:block;font-size:28rpx;font-weight:600}.feature-desc,.meta{display:block;margin-top:7rpx;font-size:23rpx;color:var(--color-text-tertiary);line-height:1.55}.arrow{font-size:40rpx;color:var(--color-text-disabled)}.tag{display:inline-flex;margin-right:10rpx;margin-top:10rpx;padding:6rpx 13rpx;border-radius:999rpx;background:var(--color-success-light);color:var(--color-success);font-size:20rpx}.tag.warning{background:var(--color-warning-light);color:var(--color-warning)}.primary-btn,.secondary-btn{height:88rpx;display:flex;align-items:center;justify-content:center;border-radius:44rpx;font-size:28rpx;font-weight:600}.primary-btn{background:linear-gradient(135deg,var(--mini-accent-coral),var(--mini-accent-pink));color:var(--color-text-inverse);box-shadow:0 10rpx 24rpx rgba(255,92,130,.22)}.primary-btn.disabled{background:#E6E8EF;color:#989DAF;box-shadow:none}.secondary-btn{background:var(--color-primary-light);color:var(--color-primary)}.fixed-action{position:fixed;left:0;right:0;bottom:0;padding:18rpx var(--spacing-page-x) calc(18rpx + env(safe-area-inset-bottom));background:rgba(255,255,255,.96);border-top:1rpx solid var(--color-divider);z-index:100}.field{margin-top:26rpx}.label{display:block;margin-bottom:12rpx;font-size:25rpx;font-weight:600}.input,.select-box,.textarea{width:100%;box-sizing:border-box;background:#FCFAF8;border:1rpx solid var(--color-border);border-radius:var(--radius-md);font-size:26rpx}.input,.select-box{height:88rpx;padding:0 24rpx;display:flex;align-items:center}.textarea{min-height:180rpx;padding:22rpx}.notice{margin-top:20rpx;padding:22rpx;border-radius:var(--radius-md);background:#F3F7FC;color:var(--color-text-secondary);font-size:23rpx;line-height:1.65}.empty-panel{text-align:center;padding:70rpx 30rpx}.empty-symbol{width:96rpx;height:96rpx;margin:auto;display:flex;align-items:center;justify-content:center;border-radius:30rpx;background:var(--color-primary-light);color:var(--color-primary);font-size:36rpx;font-weight:700}.empty-title{display:block;margin-top:22rpx;font-size:30rpx;font-weight:600}.article-card{margin-top:18rpx;background:var(--color-bg-card);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-card)}.article-cover{height:220rpx;padding:24rpx;background:linear-gradient(135deg,#F5E5DE,#F9F1E8);display:flex;align-items:flex-end}.article-cover text{font-size:42rpx;color:rgba(125,76,63,.52);font-weight:700}.article-body{padding:24rpx}.article-title{display:block;font-size:29rpx;font-weight:650;line-height:1.45}.article-meta{display:block;margin-top:10rpx;font-size:22rpx;color:var(--color-text-tertiary)}.article-content{padding:32rpx;background:var(--color-bg-card);font-size:28rpx;line-height:1.9;color:var(--color-text-secondary)}.article-content .lead{display:block;font-size:31rpx;color:var(--color-text-primary);font-weight:600;margin-bottom:24rpx}.danger-card{background:#FFF8F4;border-color:#F5DED2}.danger-card .feature-icon{background:#FFF0EA;color:#C85D43}@media(max-width:340px){.hero-title{font-size:36rpx}.hero-desc{width:92%}}
page{background:var(--color-bg-page)}.service-page{min-height:100vh;padding-bottom:calc(140rpx + env(safe-area-inset-bottom))}.hero{padding:44rpx 32rpx 54rpx;background:linear-gradient(140deg,var(--color-primary-lighter),#FFF8EE);position:relative;overflow:hidden}.eyebrow{display:inline-flex;padding:7rpx 14rpx;border-radius:999rpx;background:rgba(255,255,255,.72);color:var(--color-primary);font-size:21rpx}.hero-title{display:block;margin-top:18rpx;font-size:40rpx;font-weight:700;line-height:1.35}.hero-desc{display:block;width:82%;margin-top:14rpx;font-size:25rpx;color:var(--color-text-secondary);line-height:1.7}.content{padding:0 var(--spacing-page-x)}.block{margin-top:var(--spacing-section)}.block-title{display:block;font-size:31rpx;font-weight:650}.block-subtitle{display:block;margin-top:8rpx;font-size:23rpx;color:var(--color-text-tertiary)}.card{margin-top:18rpx;padding:28rpx;background:var(--color-bg-card);border:1rpx solid var(--color-border);border-radius:var(--radius-lg);box-shadow:var(--shadow-card)}.feature-row,.menu-row,.record-row{display:flex;align-items:center;gap:20rpx;min-height:112rpx;padding:18rpx 0;border-bottom:1rpx solid var(--color-divider)}.feature-row:last-child,.menu-row:last-child,.record-row:last-child{border-bottom:0}.feature-icon{width:72rpx;height:72rpx;display:flex;align-items:center;justify-content:center;flex:none;border-radius:22rpx;background:var(--color-primary-light);color:var(--color-primary);font-weight:700}.feature-copy{flex:1;min-width:0}.feature-title{display:block;font-size:28rpx;font-weight:600}.feature-desc,.meta{display:block;margin-top:7rpx;font-size:23rpx;color:var(--color-text-tertiary);line-height:1.55}.arrow{font-size:40rpx;color:var(--color-text-disabled)}.tag{display:inline-flex;margin-right:10rpx;margin-top:10rpx;padding:6rpx 13rpx;border-radius:999rpx;background:var(--color-success-light);color:var(--color-success);font-size:20rpx}.tag.warning{background:var(--color-warning-light);color:var(--color-warning)}.primary-btn,.secondary-btn{height:88rpx;display:flex;align-items:center;justify-content:center;border-radius:44rpx;font-size:28rpx;font-weight:600}.primary-btn{background:linear-gradient(135deg,var(--mini-accent-coral),var(--mini-accent-pink));color:var(--color-text-inverse);box-shadow:0 10rpx 24rpx rgba(255,92,130,.22)}.primary-btn.disabled{background:#E6E8EF;color:#989DAF;box-shadow:none}.secondary-btn{background:var(--color-primary-light);color:var(--color-primary)}.fixed-action{position:fixed;left:0;right:0;bottom:0;padding:18rpx var(--spacing-page-x) calc(18rpx + env(safe-area-inset-bottom));background:rgba(255,255,255,.96);border-top:1rpx solid var(--color-divider);z-index:100}.field{margin-top:26rpx}.label{display:block;margin-bottom:12rpx;font-size:25rpx;font-weight:600}.input,.select-box,.textarea{width:100%;box-sizing:border-box;background:#FCFAF8;border:1rpx solid var(--color-border);border-radius:var(--radius-md);font-size:26rpx}.input,.select-box{height:88rpx;padding:0 24rpx;display:flex;align-items:center}.textarea{min-height:180rpx;padding:22rpx}.notice{margin-top:20rpx;padding:22rpx;border-radius:var(--radius-md);background:#F3F7FC;color:var(--color-text-secondary);font-size:23rpx;line-height:1.65}.select-box.is-readonly{background:#F4F2F0;color:var(--color-text-secondary)}.empty-panel{text-align:center;padding:70rpx 30rpx}.empty-symbol{width:96rpx;height:96rpx;margin:auto;display:flex;align-items:center;justify-content:center;border-radius:30rpx;background:var(--color-primary-light);color:var(--color-primary);font-size:36rpx;font-weight:700}.empty-title{display:block;margin-top:22rpx;font-size:30rpx;font-weight:600}.article-card{margin-top:18rpx;background:var(--color-bg-card);border-radius:var(--radius-lg);overflow:hidden;box-shadow:var(--shadow-card)}.article-cover{height:220rpx;padding:24rpx;background:linear-gradient(135deg,#F5E5DE,#F9F1E8);display:flex;align-items:flex-end}.article-cover text{font-size:42rpx;color:rgba(125,76,63,.52);font-weight:700}.article-body{padding:24rpx}.article-title{display:block;font-size:29rpx;font-weight:650;line-height:1.45}.article-meta{display:block;margin-top:10rpx;font-size:22rpx;color:var(--color-text-tertiary)}.article-content{padding:32rpx;background:var(--color-bg-card);font-size:28rpx;line-height:1.9;color:var(--color-text-secondary)}.article-content .lead{display:block;font-size:31rpx;color:var(--color-text-primary);font-weight:600;margin-bottom:24rpx}.danger-card{background:#FFF8F4;border-color:#F5DED2}.danger-card .feature-icon{background:#FFF0EA;color:#C85D43}@media(max-width:340px){.hero-title{font-size:36rpx}.hero-desc{width:92%}}
.service-shell{min-height:100vh;padding-bottom:calc(136rpx + env(safe-area-inset-bottom));background:#F4F5FA}.service-content{padding:8rpx 24rpx 36rpx}.service-status{display:flex;align-items:center;justify-content:space-between;margin-top:24rpx;padding:20rpx 24rpx;border-radius:22rpx;background:#fff;box-shadow:0 8rpx 26rpx rgba(35,35,95,.06)}.service-status__label{color:#7B8198;font-size:21rpx}.service-status__value{padding:6rpx 14rpx;border-radius:18rpx;background:#EAF8F2;color:#329472;font-size:19rpx;font-weight:650}.service-status__value.disabled{background:#FFF0F0;color:#D45A60}.service-topics{display:grid;grid-template-columns:repeat(2,1fr);gap:16rpx;margin-top:18rpx}.service-topic{min-height:148rpx;padding:20rpx;border-radius:24rpx;background:#fff;box-shadow:0 8rpx 26rpx rgba(35,35,95,.06)}.service-topic__icon{display:flex;align-items:center;justify-content:center;width:54rpx;height:54rpx;border-radius:17rpx;background:#FFF0F5}.service-topic__icon image{width:34rpx;height:34rpx}.service-topic__title{display:block;margin-top:13rpx;color:#283056;font-size:24rpx;font-weight:650}.service-topic__desc{display:block;margin-top:6rpx;color:#858A9E;font-size:19rpx;line-height:1.45}.service-actions{display:flex;gap:14rpx}.service-actions .secondary-btn{width:190rpx;flex:none}.service-actions .primary-btn{flex:1}.privacy-note{display:flex;align-items:flex-start;gap:14rpx;margin-top:22rpx;padding:20rpx;border-radius:22rpx;background:#F1F4FF;color:#69718E;font-size:20rpx;line-height:1.55}.privacy-note image{width:34rpx;height:34rpx;flex:none}