Continues the HTTP-semantics work. Fixing 401/403 exposed a worse defect on the
same axis: who is allowed to reach which endpoint.
Root cause chain:
- PlatformPrincipal has two implementations (OperatorPrincipal / UserPrincipal),
chosen by JwtAuthFilter from the token type.
- `@AuthenticationPrincipal UserPrincipal` against an OperatorPrincipal is a type
mismatch, and Spring Security returns null (errorOnInvalidType defaults to
false) instead of failing.
- 244 uses of that annotation repo-wide; only 48 null-check. The rest NPE -> 500
when reached with an operator identity.
- The fallback rule was `.authenticated()`, which an operator token satisfies, so
such requests did reach user-facing controllers.
Adding ~196 null checks would be mechanical, easy to miss, and does nothing to
constrain future code. The mismatch is between identity type and endpoint, so it
is fixed once, at the authentication layer: the fallback becomes
`.hasRole("USER")` (operator -> ROLE_OPERATOR, user -> ROLE_USER, per JwtAuthFilter).
Operator requests to user endpoints now get 403 before any controller runs.
Trap worth recording: operator endpoints are not all under /operator/ —
/counseling/services/operator/queue, /content/operator/articles,
/activities/operator/checkin/scan, /service-appointments/operator/list and others
sit deeper inside business paths. The first attempt covered them with
`requestMatchers("/**/operator/**")`, which fails at runtime:
PatternParseException: No more pattern data allowed after {*...} or ** pattern element
Spring's PathPattern permits `**` only as the final element. Worse, the symptom is
a 500 from the whole filter chain (Tomcat HTML error page, not our JSON), and E2E
went from 37 passed to 33 failed — an error far removed from its cause. Now the
nine prefixes are listed explicitly, each ending in /**.
Because explicit enumeration can be forgotten as endpoints are added, and because
the failure mode is "operators locked out of their own admin module" (invisible to
user-side tests), SecurityPathCoverageTest parses the OPERATOR rule prefixes out of
SecurityConfig source, walks every *Controller.java, joins class-level and
method-level mapping paths, and asserts every path containing /operator/ is
covered (/auth/operator/** excluded by design). Two things that would silently
neuter it, both handled: it must parse only the .hasRole("OPERATOR") chain — pulling
in the permitAll or fallback rules would mark everything covered — and the test
working directory is the repo root, not the Maven module root.
Verification: negatively validated by removing /content/operator/** -> exactly one
test turns red listing the six affected ContentController endpoints; restored.
E2E 40 passed (37 + 3 identity-isolation cases). Backend regression 664 / 0
failures / 0 errors / 3 skipped.
Phase 23B ended with an E2E assertion changed to expect "HTTP 200 + body.code
== BAD_REQUEST", documented at the time as "this project's convention". That
description was accurate about the code and wrong about the intent: it described
a defect, and I made the test accommodate it instead of fixing it.
Auditing it found the convention was already broken, not merely risky:
- ~100 sites (105, across 30 controllers) do `return ApiResponse.error(...)` /
`forbidden()` / `notFound()` with no exception and no `@ResponseStatus`, so
the HTTP status is always 200. Gateways and APM cannot separate 4xx from 5xx;
callers cannot tell "fix your request" (400) from "don't retry" (403) from
"check state first" (409) from "retry later" (429).
- miniapp/utils/api.js already had 401/403/429 branches that could never be
reached, since the backend never returned those codes. The concrete damage:
an expired token returned 403, so `onSessionExpired` never fired and users
were stuck with no way back to the login flow.
- SecurityConfig had no `authenticationEntryPoint`, so Spring's default
`Http403ForbiddenEntryPoint` returned 403 for unauthenticated calls, with a
framework body that has no `code` field at all.
- CorsConfig defaulted `allowed-origins` to `*` together with
`allowCredentials(true)`, while its comment claimed "localhost only by
default". Comment contradicting code is worse than no comment: it reads as
safe.
- Only 4 files / 15 assertions in the whole backend suite touched HTTP status,
which is how this survived.
Approach: one definition, two applications, zero controller changes.
- ApiResponseCode gains an `httpStatus` field — the single source of truth. The
constructor requires it, so a new code cannot be added without one.
- GlobalExceptionHandler reads status from the enum instead of hardcoding
`@ResponseStatus` per handler.
- New ApiResponseStatusAdvice (ResponseBodyAdvice) fills in the status just
before the body is written, covering all ~100 direct-return sites without
touching them. Two constraints, both noted in code: only downgrade when the
current status is still 2xx (the advice also sees exception-handler results,
and must not override an already-correct 401/403/409), and never alter the
body.
- SecurityConfig: entry point returns 401 with a uniform ApiResponse body;
access denied handler returns 403 likewise.
- CorsConfig: default changed to empty (deny). Both clients are same-origin
anyway — nginx proxies /api/ and Vite proxies /api — so nothing depends on it.
- miniapp api.js: 401/403/429/409 branches now prefer the server's
`body.message`, otherwise "账号已被封禁" would be flattened to "无权限访问".
Implementation trap worth remembering: `ServerHttpResponse` has `setStatusCode`
but no getter, so reading the current status requires casting to
`ServletServerHttpResponse`.
Verification: 37 E2E passed (33 + 4 new contract cases), including a live-stack
check that a controller's direct `notFound()` now yields 404 — that path throws
no exception and never reaches the exception handler, so if the advice were
inactive it would silently return 200 while the enum unit tests stayed green.
Backend regression 662 / 0 failures / 0 errors / 3 skipped (+6 new guards).
The first regression run had 3 failures, all tests asserting the old wrong
behavior (anonymous access expected 403; outsiders reading a conversation
expected 200). Fixed the assertions, not the implementation. Where 404 is used
instead of 403 for "invisible", that is deliberate — it prevents probing whether
another user's conversation exists — and the substantive assertions (e.g. that an
unauthorized response must not leak message bodies) were kept.
Not done, needs a product decision: third-party credential issuance, whether
rate limiting is actually active, publishing OpenAPI externally, per-caller
audit and quotas, pagination/idempotency coverage, desensitization boundaries.
Phase 23 closed as PARTIAL: fake coverage was gone, but the E2E layer itself
was still empty — no conftest.py anywhere, ~15 fixtures that never existed,
6 files crashing at import on os.environ["E2E_PASSWORD"], and CI never ran E2E.
STATUS's "10 E2E files / ~80+ cases ✅" described files that had never executed.
Infrastructure delivered:
- tests/e2e/conftest.py: ApiClient (trust_env=False — a host http_proxy turns
localhost calls into 502), operator_token(username) / new_user / admin_token
fixtures, and a session probe that calls pytest.exit(4) when the stack is
unreachable. NOT skip: skip would launder "did not run" into "passed".
- pytest.ini, tests/e2e/requirements.txt, and tests/e2e/sql/
seed_e2e_operators.sql — migrations contain NO operator seed at all, so a
fresh database has zero logins; the seed creates 6 e2e accounts with role
bindings, idempotent, password injected via `psql -v pwd`.
- CI job `e2e`: compose up -> poll /health/live (timeout = fail) -> seed ->
pytest -> always teardown. Gate D-23-D is now closed.
- 8 E2E files migrated off import-time env reads; tautological assertions
(`x == t or not x`), a counselor token standing in for content_editor, and a
bare `status_code == 200` claiming to prove INV-020 were replaced with real
permission/behavior assertions.
Six real production defects found only by running against real PostgreSQL
(H2 has zero coverage for all of them):
- D-23B-A (Critical): stale V042__add_appointment_operator_register_permission.sql
left in target/classes; src had replaced it with V042__add_service_location_
announcements.sql. Packing without clean put two V042 in the jar -> Flyway
"Found more than one migration with version 042" -> app would not start.
Guard added: testBuildOutputHasNoDuplicateMigrationVersions scans build output
(src is clean, so only this catches it).
- D-23B-B (High): non-UUID path variable reached the DB -> 500 + leaked
`invalid input syntax for type uuid`. Now rejected at the controller.
- D-23B-C (High): 6 E2E files raised KeyError at import -> never collected.
- D-23B-E (Critical): permissions = 48, role_permissions for super_admin = 18,
matchmaker = 0. V015's "grant all to super_admin" was a point-in-time snapshot;
the 30 permissions added later were never granted. On a fresh stack an admin
cannot create a service station and matchmakers can do nothing. V044 restores
super_admin to its documented definition (not an expansion); the matchmaker
permission set needs a product decision and is left as a residual.
- D-23B-I (Medium): IllegalArgumentException (e.g. invalid activity type) was
unmapped -> 500. Now BAD_REQUEST.
- D-23B-J (High): `operator does not exist: uuid = character varying` — Java
ids are String, columns are uuid, PostgreSQL does not implicitly cast in
comparisons. Fixed at the source with JDBC `stringtype=unspecified` (docker
profile); production datasource comes from deploy env and needs the same.
One judgement retracted: an initial read suggested service_appointment.register
was missing. It is not — the endpoint uses service.assisted_registration, seeded
in V029, and the code deliberately avoids a duplicate permission. Nothing was
changed on that basis.
Verification: 33 E2E passed against a live stack; unreachable stack and a
missing operator password both exit 4 (never skip/pass); backend regression
656 / 0 failures / 0 errors / 3 skipped (baseline 655, +1 guard, negatively
validated by injecting a duplicate V042 -> exactly one test turned red).
Phase 23 "可信系统测试重建 (REFACTOR)" re-audit. STATUS claimed "10 E2E
files / ~80+ cases" and "INV-011~020 ✅"; measurement showed the opposite.
D-23-A (Critical): V11InvariantsTest's 14 cases asserted only Constants
strings. INV-016 "Banner target types" actually asserted ARTICLE_STATUS_*;
INV-017 "home 8 entries" declared local arrays then asserted
firstRow.length == 4 (tautology, never called the home service);
INV-018/019/020 used appointment/article/account status constants as
stand-ins for permission and data-scope checks. Rewritten to 12 cases:
4 real service-behavior guards (INV-013 via real CounselingService.getDetail
— Matchmaker sees null userRequestSummary, with an assigned-Counselor
counter-proof so global sanitization cannot fake a pass; INV-016 reads the
production ALLOWED_TARGET_TYPES whitelist and asserts no external/http/url/
web entry; INV-017 calls real getHome and pins all 8 codes + order;
INV-019 asserts content_editor reads null), plus assertBehaviorTestExists
turning "行为验证见 XxxTest" pointers into executable Class.forName contracts.
D-23-B (High): 44 `pass` shells across 6 files in tests/e2e (4 files were
100% shells). Gate forbids skip/pass shells. Rewritten in place as honest
placeholders with the full scenario lists preserved.
D-23-C (High, residual): the pytest E2E suite is not executable at all —
no conftest.py anywhere, ~15 fixtures (api_client/verified_user/
banner_factory/...) do not exist. Only run_v11_tests.py really runs
(16 cases, needs a live stack + E2E_PASSWORD).
D-23-D (High, residual, NOT fixed): CI does not chain E2E as the plan
requires. Needs an infrastructure decision on how CI provisions a live
stack; not implemented rather than written unverified.
D-23-E (Medium): the CI migration job claimed to "验证 migration SQL 语法"
but only checked file non-emptiness, while the real syntax/baseline checks
live in MigrationValidationTest (already run by the backend job). Replaced
with a real sequence check (naming / non-empty / unique versions / no
gaps); verified by extracting the exact inline script and running it.
New gate: scripts/check-no-empty-tests.py (AST scan, blocks pass-only and
unconditionally skipped tests) + a CI test-shell-guard job. Verified both
ways: 0 findings on the real tree, 2 precise findings on injected shells.
Negative validation: 3 injected production defects (external_url in the
banner whitelist, swapped home entry order, isMatchmaker returning the full
record) turned exactly inv016/inv017/inv013 red, 9 others green, zero
unrelated failures; md5sum -c confirmed byte-identical restore.
Full regression: 655 tests / 652 passed / 0 failures / 0 errors / 3 skipped
(baseline 657; -2 because 14 vacuous cases were merged into 12). The 3
skips carry explicit reasons (2x @EnabledIfEnvironmentVariable(CI=true),
1x @Disabled "需要完整数据库连接") — none is a shell.
Phase 23 is PARTIALLY closed: fake coverage is gone, but the UAT gate
requires the end-to-end chain to actually execute, so "失败为零可进入 UAT"
is NOT claimed.
STATUS.md contained two duplicate legacy Phase 22 blocks still claiming:
- "application.yml 默认配置 ✅ (15个V1.1 flag)" / "application-dev.yml +
application-docker.yml 配置 ✅" — verified false: all four yml files
(application.yml / -dev / -docker / -production) contain neither a `features:`
block nor any flag key; defaults are Java field defaults only.
- "P22-T01..T05 ✅" and "P22-T07 ✅" — these are dead config: zero business
call sites for the five module toggles, zero references for the private
storage bucket flags.
Both blocks now carry a "superseded by the 2026-09-18 authoritative section"
notice with per-row measured verdicts (T06 remains ✅ as genuinely enforced).
No code change; doc-only.
KEEP-VERIFY re-audit of the Feature Flag configuration extension (STATUS's
"Phase 22 部署运维配置扩展"): marked ✅ but contained real code defects and
overstated completion.
Real defects fixed:
- D-22-A (High): FeatureFlagController.updateFlag used reflection
`getMethod("set"+cap, boolean.class)` and only supported boolean flags.
7 non-boolean flags (livenessProvider, localEvidenceAllowedTypes,
counselingAttachmentsBucket, localEvidenceMaxFiles, localEvidenceMaxFileSizeMB,
localEvidenceRetentionDays) failed to update via the API. Now dispatches by
field type (boolean/int/String).
- D-22-B (Medium): `body.getOrDefault("value", false)` silently set false when
value was missing and crashed on non-boolean casts. Now returns 400 on missing
value / type mismatch and leaves the original value unchanged.
Doc overclaims corrected (no silent scope creep):
- T01-T05 module-toggle flags (isCounselingEnabled etc.) have zero business
call sites -> dead config (exposed but not gating). Left for each module's
own phase to wire; documented as residual.
- T07 private-storage bucket flags have zero references; storage already uses
object-storage.bucket-private. Documented as residual.
- STATUS claimed "application.yml defaults (15 flags)"; yml has no `features:`
block, defaults are Java field defaults. Functionally fine, docs corrected.
T06 upload policy verified actually enforced in LocalEligibilityService +
PhotoService (kept ✅).
Guard tests: new FeatureFlagControllerTest (7 cases: 21 keys exposed, boolean/
string/int update reflected, unknown flag / missing value / wrong type -> 400).
Negative validation: reverting to boolean-only dispatch made exactly 2 update
cases fail red, then green after restore — proves guards are real.
Full regression: 657 tests / 0 failures / 0 errors / 3 skipped (baseline 650, +7).
KEEP-VERIFY re-audit of account lifecycle (EXTEND): STATUS marked ✅ but
contained hard Gate violations and dead code.
Real defects fixed:
- D-21-A: AuthService.checkAccountStatus did not reject CLOSED → closed
account could still log in (Gate ❌→✅); added CLOSED branch before
BANNED/SUSPENDED.
- D-21-B: notifyActiveMatches was a TODO stub → active Matches/conversations
not closed on withdrawal (Gate ❌→✅); now finds active matches, sets
UNMATCHED + closes conversation via chatService.closeConversationForMatch.
- D-21-C: cancelPendingActivityRegistrations was a TODO stub → future
activity registrations not cancelled (Gate ❌→✅); now cancels pending/approved.
- D-21-D: anonymizeServiceHistory existed but was never wired into
closeAccount → personal identity + counseling text leaked (Gate ❌→✅);
wired in, anonymizes id_number/real_name with placeholder, plus
counseling_followups content.
- D-21-E: closeAccount hard-threw on blockers → all cancelXxx unreachable
(dead code contradiction); changed to informational log + continue cleanup.
- D-21-F: 7× assertTrue(true) fake-green tests → replaced with 11 real DB
integration tests + 3 login-guard tests (Redis-decoupled via
ReflectionTestUtils).
Cross-module defenses (RecommendationEngine/MatchEligibility/Capability
reject non-NORMAL) confirmed effective. Remaining (no silent scope creep):
matchmaker in-progress interception still hardcodes 0 (no table), data
retention delete flow is a product decision, privacy-settings endpoint stub.
Full regression: 650 tests / 0 failures / 0 errors / 3 skipped (baseline 648, +2).
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.
Phase 15 (activity registration / review / checkin) verification closure.
Two CRITICAL defects, both "production always had it, test schema never did":
- D1: generateCheckinToken wrote the token to Redis with value "used", while
scanCheckin rejects any token marked "used" -> every freshly issued check-in
code was dead on arrival, staff scanning failed 100% of the time.
Issuing now writes "issued"; "used" is set only after a successful scan.
- D9: UNIQUE(activity_id, user_id) exists in production, but re-registering
after cancel/reject went through INSERT -> users who cancelled once could
never register for that activity again (duplicate key 500). The H2 test
schema lacked the UNIQUE, so the path had zero coverage. Now the original
row is reused via an atomic conditional UPDATE.
Checkin (D2-D8):
- D2: duplicate check-in was read-then-write; now atomic checkinIfNotCheckedIn
and repeat scans are idempotent (alreadyCheckedIn) without duplicate audit.
- D3: no data scope at all -> added activity staff assignment (V040) and
assertCheckinScope; INV-020 (org relation grants nothing).
- D4: manualCheckin did not validate activity existence/cancellation.
- D5: getCheckinStats NPE on missing activity; remainingCount was semantically
"free seats" -> replaced with notCheckedInCount.
- D6: malformed timestamp caused 500 -> 400. D7: constant-time signature compare.
Registration / review / cancel (D10-D13):
- D10: backend registration list only queried status='approved' -> the review
screen never saw pending rows (review unusable).
- D11: registration_start_at / registration_end_at were never enforced.
- D12: cancel had no audit log (the only write without one).
- D13: reject/cancel were read-then-full-update -> rejectIfPending/cancelIfActive.
Capacity groups (D14, plan item 2): groups were stored and read back but never
participated in capacity -> incrementIfAvailable / decrementIfPositive now make
approved_count real; activities without groups are unaffected.
Frontend contract (F1-F2):
- F1: admin-web had no check-in surface at all -> ActivityCheckinView.vue
(paste/scan token, manual check-in, stats) + /activities/checkin route & nav.
- F2: miniapp had no cancel-registration wrapper although the endpoint existed.
Test fidelity (T1-T3):
- T1: test schema gained UNIQUE(activity_id,user_id) + chk_reg_status +
chk_checkin_status; two new guards parse the real V010 DDL and compare.
- T2: 3 assertTrue(true) placeholders in CheckinServiceTest (covering duplicate
check-in, expiry, no-sensitive-plaintext) replaced with 5 real guards that
parse the real source and real production DDL.
- T3: activity_registrations + activity_staff_assignments moved into
REQUIRED_IN_TEST_SCHEMA; MigrationValidationTest baseline 39 -> 40.
Note: ActivityStaffAssignment must NOT extend BaseEntity - the table is a
composite PK (activity_id, operator_id); BaseEntity would add id/updated_at/
deleted_at to every INSERT and break on PostgreSQL (caught by
EntityMigrationParityTest immediately).
Tests: ActivityPhase15Test (21), CheckinServiceTest (5), 2 new constraint
guards. Negative verification: 3 injected defects -> 8 assertions turned red,
all restored.
Regression: 474 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(baseline 450). admin-web vue-tsc 0 error, vite build OK.
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.