Author SHA1 Message Date
WorkBuddy c561bf46af docs(idempotency): 统一报告中的计数口径(25 处调用点 / 30 处声明)并补 CI E2E 缺陷到结论行 2026-09-18 16:40:09 +08:00
WorkBuddy 2766e53fe2 docs: 幂等键检修报告;勘误 API_INVENTORY 与 HTTP 报告;同步 STATUS/JIRA
新增 docs/幂等键检修报告-2026-09-18.md:记录双向断裂(后端零处读取该头、
前端 Date.now 每次生成新键)、五处审计发现、机制设计与五个取舍
(先占位后执行 / 重放 / 失败释放占位 / 孤儿占位接管 / 作用域含 actor_id)、
列类型不变量扫描、E2E 与守门证据、三处同形状问题的连带修复。

同轮勘误:

- docs/HTTP语义检修报告-2026-09-18.md:其中「Idempotency-Key 已支持」一句
  在写下时不成立(后端当时零处读取),已显式标注勘误并保留原文,
  以免「结论先于核实」被当作既成事实继续引用。
- docs/api/API_INVENTORY.md:原记载 POST /activities/{id}/interests
  「提交活动互选」,后端从未实现(Phase 16 已并入 /feedback 的
  interestTargetUserIds),按真实端点重列并附勘误说明。

docs/STATUS.md:迁移范围更新为 V001~V046;回归行更新为 669 / 0 / 0 / 3;
新增「幂等键检修」章节;补登 HTTP 语义检修章节在两份文档中的缺失条目。
docs/JIRA_BACKLOG.md:补登 HTTP 语义检修与幂等键检修两行
(此前均未登记)。
2026-09-18 16:37:47 +08:00
WorkBuddy af5412646b test(e2e): 运营登录只用 seed 账号;补幂等 E2E 与两处机械守卫
1) 修复一处从未暴露的 CI 缺陷

库中 operator 账号没有任何迁移种子,DemoDataSeeder 也不创建运营账号
(只统计数量),全新库唯一的账号来源是 seed_e2e_operators.sql。
而 conftest 的 admin_token 写死 username="admin" —— 那是某台开发机上
手工创建的遗留账号:本机有它所以本地一直跑绿,CI 全新库没有它 →
该 fixture 必然 401 → e2e job 从未真正通过过。
同一文件里「外加迁移 seed 的 admin」这句注释也是纯粹的虚构。

改用 seed 创建的 e2e_admin(已绑定 super_admin,语义等价)。

2) fixture_account_guard_test.py(2 用例)

机械拦住「fixture 依赖本机手工数据」:从 seed SQL 解析账号清单,
扫描 conftest 的所有登录用户名,任何一个不在 seed 内即变红;
另一条锁住推理前提(迁移里确实没有 operator 种子)。
两条都带「解析失败即失败」断言 —— 守卫自身静默失效是本项目反复踩过的坑。

3) idempotency_test.py(7 用例)

同键重放响应逐字节相同、同键重复提交只产生一条记录、body 字段幂等未回归、
不同键相互独立、同键作用域按 actor 隔离、5 线程并发同键业务恰好落库一次、
无键重复 Like 不得返回 500。

4) conftest.ApiClient.call 支持 headers 覆盖,供幂等用例携带自定义键。
2026-09-18 16:37:33 +08:00
WorkBuddy 8dee67040c fix(miniapp): 幂等键改为按操作稳定;删除指向 404 的互选死代码
1) 幂等键(配合后端机制)

原写法把 Date.now() 拼进幂等键,每次调用都生成新键,即使后端实现了幂等
也永远不会命中 —— 声明端与实现端各自都坏。

api.idempotencyKey(scope, subject):键缓存在 _pendingIdempotency(TTL 10min),
同一逻辑操作在成功之前复用同一把键,收到 200+success 才释放;失败(含网络错误)
不释放,下次重试沿用同一把键。25 处调用点(8 个 service + 2 个 TS 页面)全部替换,
Date.now 幂等键写法残留经 grep 为 0。

2) 删除死代码 submitInterests

它请求 POST /activities/{id}/interests —— 后端从未实现该端点
(Phase 16 已把表达意向合并进 /activities/{id}/feedback 的
interestTargetUserIds)。全仓确认无任何页面/组件调用方。
两个文件各留注释说明删除原因与恢复条件:若产品确认需要独立的
「提交互选」端点,应先定义语义并补后端实现,再恢复前端方法。

store 头部关于 AGENTS 第 6 条(单向意向保密)的注释同步改为引用
submitFeedback 返回的 newMutuals。
2026-09-18 16:37:33 +08:00
WorkBuddy 69cf6a2cc7 feat(idempotency): 为 30 个写端点实现 Idempotency-Key
规范第八章要求 7 类写端点支持 Idempotency-Key,第 146 章验收口径为
「最终不能生成重复核心数据」。检修前的实际状态:后端零处读取该头,
小程序 25 处调用点发送全部石沉大海。

一处实现、零业务代码改动、逐端点声明:
- V045 新建 idempotency_records + 唯一索引 uk_idempotency_scope
- V046 修正 actor_id 列类型 VARCHAR -> uuid(见下)
- IdempotencyInterceptor:先占位后执行 / 重放首次响应 / 处理中回 409 /
  孤儿占位 300s 可接管 / 失败释放占位
- IdempotencyResponseCacheFilter:缓存响应体供逐字节重放
- WebMvcConfig:本项目第一个通用 WebMvcConfigurer
- @Idempotent 声明 30 处(含补 CheckinController 手工签到的漏标)
- GlobalExceptionHandler:IllegalStateException(30 处业务状态冲突)与
  DuplicateKeyException(并发重复提交的最后防线)此前均无映射 → 落 500,
  而 500 的语义是「可重试」,调用方于是重试并制造更多重复。现映射为 409;
  刻意不映射父类 DataIntegrityViolationException(CHECK 违反应由 500 暴露)。
- schema-h2.sql 补 idempotency_records

V046 的必要性:项目全局注册的 UUIDStringTypeHandler 对任何合法 UUID 形式的
String 参数以显式 uuid 类型发送,而 SQL 侧 stringtype=unspecified 只影响以
unknown 类型发送的参数,覆盖不到它。因此真正的不变量不是「JDBC 参数配好了」,
而是「列类型必须是 uuid」。V045 初版把 actor_id 建成 VARCHAR(64) 时,
带幂等键的请求在真实 PG 上全部 500。

全库扫描确认该不变量已收敛:所有 *_id VARCHAR 列中 actor_id 是唯一违规,
client_message_id / provider_request_id 为外部 id,
resource_id / source_id 在 Java 侧零处作查询条件。
2026-09-18 16:37:11 +08:00
WorkBuddy ed725daccd docs(status): record identity scope convergence and the PathPattern ** trap 2026-09-18 15:06:06 +08:00
WorkBuddy cbb4f64fdc fix(security): stop operator identity from reaching user endpoints; guard path rules
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.
2026-09-18 15:05:41 +08:00
WorkBuddy 3a4e03a9f9 docs(status): record HTTP semantics remediation; note 3 test assertions that encoded old wrong behavior 2026-09-18 14:38:59 +08:00
WorkBuddy 7682abd85c fix(api): give failures real HTTP semantics; fix 401/403 confusion and CORS default
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.
2026-09-18 14:34:58 +08:00
WorkBuddy 730dddde30 test(e2e): Phase 23B — rebuild E2E infrastructure; fix 6 real prod defects it exposed
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).
2026-09-18 13:50:19 +08:00
WorkBuddy ae1bd2b734 test(phase23): KEEP-VERIFY — remove 44 pass shells + fake INV tests, add shell guard
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.
2026-09-18 11:49:18 +08:00
WorkBuddy 050d005c0a docs(status): correct two stale duplicate Phase 22 ✅ tables disproved by KEEP-VERIFY
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.
2026-09-18 11:08:12 +08:00
WorkBuddy 2fbdbc32d0 fix(feature-flags): close Phase 22 KEEP-VERIFY — 2 real defects + doc overclaims
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).
2026-09-18 11:00:08 +08:00
WorkBuddy 0580da0c62 fix(account-lifecycle): close Phase 21 KEEP-VERIFY — 6 real defects + fake-green tests
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).
2026-09-18 10:12:34 +08:00
WorkBuddy 22c778b111 fix(analytics): close Phase 20 KEEP-VERIFY — 20 defects (fake metric definitions, hardcoded zeros, systemic soft-delete leak, module-wide 403, fake-green tests); add MetricCatalog + unified calculator + V043 grants + 51 guard tests; full regression 648 green
口径造假:
- D1 实名人数用 user_profiles.profile_status != 'rejected' 冒充 → in_progress/need_edit 被算成已实名
- D3 Chat Started 直接数会话行数 → system/risk_notice 系统通知计入
- D4 Match 不分来源 → 活动 10 对互选被当 10 对成功引用(Gate)
- D6 Banner 曝光/点击硬编码 0 → 改返回 null + NOT_INSTRUMENTED 标记

死代码 / 占位:
- D5 活动漏斗全部硬编码 0 + 一行把"用户数"当"活动数"的死代码
- D20 getDailySummary(days) 无边界校验

系统性软删缺陷(D12):
- BaseEntity.deletedAt 无 @TableLogic 且全局无逻辑删除配置
  → selectCount 把软删数据全部计入统计且无任何报错
- 修复 45 处统计调用显式 deleted_at IS NULL(2 轮复验,首轮漏扫 5 处次级路径:
  服务用户数分域明细 / 服务站统计 / 性别与年龄分布)

结构类:
- D15 Dashboard/用户漏斗/专项页各实现一遍同名指标且互不相同 → 抽 AnalyticsMetricsCalculator 统一口径

权限(Gate ⑩):
- data_viewer 在 V001 定义却全仓从未授权(空壳角色)
- platform_admin 的 V015 扩展条件 id >= '20000000-…' 漏掉 V1.0 老权限段
  → 整个 Analytics 模块除 super_admin 外全员 403
- V043 授予两角色;刻意不授予 organization_admin(相关表无组织维度,无范围全量即越权)

假绿测试:
- 删除 AnalyticsServiceTest(5× assertTrue(true),零断言)

新增:
- 指标字典 MetricCatalog(36 指标 × 12 字段)+ MetricStatus/MetricDefinition + docs/ANALYTICS_METRICS.md
- 守卫 51 用例:MetricCatalogGuardTest(11) / AnalyticsMetricsPhase20IntegrationTest(21)
  / AnalyticsSoftDeleteGuardTest(4,结构性) / AnalyticsPermissionGrantTest(6)
  / LocalEligibilityStatusVocabularyGuardTest(9)

跨阶段修复(缺陷本体属账号生命周期模块,按"发现即修"处理):
- X1(Critical)注销写入 user_identities 状态域的 'failed' → 违反生产 V017 chk_elig_verify_status
  → 真实 PG 上 closeAccount 事务回滚、账号注销 100% 不可用(负向验证实测 DataIntegrityViolation)
- X2 checkCanWithdraw 资格拦截用 submitted/pending(该表永不可能出现)→ 死代码
- X3 测试库补 chk_elig_type/chk_elig_verify_status,暴露 ActivityPhase14Test 夹具违规

验证:
- 负向验证两轮共 7 处注入 → 7 个用例精准变红且无无关失败(一处被源码结构+运行时行为两层捕获)
- 还原后 md5sum 逐字节一致,NEG-VAL 残留 0
- 后端全量 648 tests / 0 failures / 0 errors / BUILD SUCCESS;admin-web vue-tsc EXIT=0
2026-09-17 23:25:31 +08:00
WorkBuddy ff22c0d06a docs(phase19): cite the recovery tag instead of a moving commit sha 2026-09-17 22:25:10 +08:00
WorkBuddy d74f0ea099 docs(phase19): record tag/bundle location + commit sha in the recovery report 2026-09-17 22:24:56 +08:00
WorkBuddy 72b4e9b846 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.
2026-09-17 22:21:31 +08:00
WorkBuddy 3f60611a74 fix(appeal): close Phase 18 KEEP-VERIFY — 8 defects (zero-coverage appeals table + 6 fake-green tests; operator-notification FK crash; appealType CHECK; escalated dead-end; processAppeal race; soft-delete filters; hardcoded resolved; evidence contract); add AppealPhase18IntegrationTest (15); full regression 494 green 2026-09-17 21:07:25 +08:00
WorkBuddy 307c9efb36 fix(risk/report): close Phase 17 KEEP-VERIFY/EXTEND — 6 defects + 1 hidden compile defect (jakarta) + test-coverage blind zone (3 missing tables); add sensitive-access audit + ReportAndRiskPhase17IntegrationTest (7); full regression 488 green 2026-09-17 20:42:09 +08:00
WorkBuddy 556f95a562 fix(activity-mutual): close Phase 16 KEEP-VERIFY/EXTEND — 7 defects + 1 hidden compile defect (reopenEpisode) + 1 test-fidelity; unify dual-impl to confirmActivityInterest; INV-006 fix; +7 tests (481 green) 2026-09-17 16:47:10 +08:00
WorkBuddy 29112eed6e fix(activity): close Phase 15 KEEP-VERIFY/EXTEND — 2 critical + 14 other defects, 3 test-fidelity fixes
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.
2026-09-17 15:07:52 +08:00
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
WorkBuddy 7a2693b239 fix(counseling): close Phase 13A KEEP-VERIFY/EXTEND — 9 real defects + 4 test-infra defects
Phase 13A(婚恋咨询与婚前辅导)复验关闭。历史 ✅ 不可信:counseling 模块存在双实现,
线上权威 CounselingService(167 行)零测试,唯一被测的 CounselingServiceApp(280 行)
是孤儿类(无注入点、无 HTTP 端点可达,生产永不执行)→ 已合并并删除。

生产缺陷(D1~D9):
- D1 Critical 生产故障 · INV-013 泄露:咨询正文 userRequestSummary 原文被写入 WARN 日志
  (同 Phase 10 D2 一族)→ 改为只记 serviceId + riskLevel,绝不记正文。
- D2 High 越权:申请 userId 取自请求体而非认证主体 → Controller 强制 principal.id()。
- D3 High 并发:分配用「先读后写」→ 两管理员并发分配静默覆盖;
  改 assignIfPending 条件更新(WHERE status='pending' AND assigned_operator_id IS NULL),
  affectedRows=0 明确报错。
- D4 High 越权读:counseling_manager 经 getDetail 可读 followups 正文 → INV-013 绕过通道;
  manager 只加回 userRequestSummary,不装配 followups。
- D5 Medium:无状态机 → 「已完成」可被再取消;新增 ALLOWED_TRANSITIONS + assertTransition。
- D6 Medium 生产故障:GET /counseling/services/intro 后端不存在(且被 /services/{id} 吞掉)
  → 小程序 intro 页 100% 无数据;补端点并声明在变量路径之前。
- D7 Medium 契约:Admin 调用 5 条不存在的 /services/operator/*(404)+ 缺四态
  → 后端补 8 个运营端端点(@PreAuthorize),前端整体重写 + 注册 /counseling 路由。
- D8 Medium 契约:小程序字段名错(userRequestSummary/preferredServiceMode)+ 无幂等
  + my-counseling 未检查 success(403 静默空列表)+ 跳转不存在的页 + WXML 内调用方法。
- D9 Medium 测试保真度:测试库 counseling 两表零 CHECK 约束,
  counseling_services 未列入 EntityMigrationParityTest 任何集合(= 零覆盖)。

测试基建缺陷(T1~T4,不修即恒真假绿):
- T1 守卫找不到 DDL:surefire workingDirectory = ${project.basedir}/.. 使 CWD 为仓库根,
  且 backend/ 是子目录非祖先 → 改为候选后缀 + 逐级向上定位,失败大声抛异常。
- T2 全限定 @com.baomidou...TableField(exist=false) 漏解析(假漂移警报)→ 正则放宽 + 回归守卫。
- T3 OperatorEndpointPermissionCoverageTest 扫到私有辅助方法(假阳性)→ 只扫描真实端点 + 自检。
- T4 两处测试自身逻辑缺陷:陈旧内存快照比较、Instant 纳秒 vs DB 微秒精度。

迁移:新增 V039(counseling 幂等键 + 三个 CHECK 白名单显式固化),基线 38 → 39。

负向验证两轮(均已还原,grep -c NEG-VAL = 0):
- ① DDL 常量守卫:从 V039 移除 'scheduled' → 常量守卫精准变红
  (证明解析的是真实 DDL 而非副本)。
- ② 行为守卫:注入「并发回退先读后写 / manager 读 followups / 状态机守卫失效」
  → 三个用例各自精准变红。

测试:新增 CounselingPhase13ATest(25)+ CounselingConstraintGuardTest(5,解析真实 DDL);
删除孤儿类及其 3 个 DTO、删除测死代码的 CounselingDataScopeTest。
全量回归:Tests run: 420, Failures: 0, Errors: 0, Skipped: 3 / BUILD SUCCESS(4:49)。
基线 391 → 420(净 +29)。admin-web vue-tsc EXIT=0;miniapp tsc EXIT=0。

Tag: phase13a-recovery-2026-09-17
Report: docs/Phase-13A-V11-Recovery-Completion-Report-2026-09-17.md
2026-09-17 01:25:12 +08:00
WorkBuddy 5b3bad326e fix(matchmaker,referral): close Phase 13 KEEP-VERIFY/EXTEND — 7 real defects
KEEP-VERIFY/EXTEND 复验(历史 ✅ 不可信;测试覆盖函数 ≠ 覆盖运行时路径)。
逐条核对运行时路径与生产约束后修复 7 处缺陷,其中 3 处「只在真实 PostgreSQL 上炸」。

D1 (Critical, 生产故障)
  matchmaker_recommendations.status 代码写 liked/passed/confirmed_mutual,
  而 V009 chk_rec_status 只允许 pending_view/viewed/responded 且从未扩展
  → 真实 PG 上用户接受/跳过推荐必崩。测试库未复刻该 CHECK,故长期静默。

D2 (Critical, 生产故障)
  控制器缺省 serviceType="recommendation" 不在 chk_matchmaker_service_type
  白名单 → 提交红娘服务必崩。
  D1+D2 同修于新增 V038__align_matchmaker_status_constraints.sql
  (沿用 V018/V020/V025 的 DROP CONSTRAINT IF EXISTS + 重加白名单惯例)。

D3 (High, 越权写)
  MatchmakerServiceService.recommendPartner 无 assignedOperatorId 归属校验,
  且可把用户推荐给自己 → 补校验 + 禁止自我推荐。

D4 (High, 越权读)
  getServiceDetail 无归属校验且返回完整用户资料(userId/description/
  userProfile/followups)→ 签名加 requesterId,按调用者返回完整/脱敏视图。

D5 (High, 越权写)
  ReferralService 的 userId 取自请求体且零授权校验 → 新增
  assertMatchmakerServesUser,在 referToServiceStation/referToActivity 入口
  强制校验存在进行中的服务关系。

D6 (High, 生产故障)
  MatchmakerFollowupMapper.findByServiceId 只有声明无 @Select
  → Invalid bound statement (not found),getServiceDetail 必崩。
  该缺陷由本轮新增测试首次暴露(历史测试从未调用该路径)→ 补 @Select。

D7 (Medium, 契约缺口)
  admin-web 5 处 API 缺口(4 条 404 + C 端端点被运营端误用)+ 小程序缺转介服务层
  → 后端补 GET /operator/matchmaker/recommendations 与
  GET /referrals/operator/history 两个运营端端点;前端改路径、
  assign 改 POST + body operatorId;新增 miniapp/services/referralService.js。

测试保真度
  - 测试库补 matchmaker_services/recommendations/followups 三表(此前该模块零真实覆盖),
    约束与 V038 对齐;EntityMigrationParityTest 三表移入 REQUIRED_IN_TEST_SCHEMA;
    MigrationValidationTest LATEST_MIGRATION 37 → 38。
  - 新增 MatchmakerReferralPhase13Test(17 用例,@SpringBootTest 走真实方法路径 + 真实落库)。
  - 重写 MatchmakerServiceTest(6 用例):直接解析 db/migration/V*.sql,按文件名顺序应用
    DROP CONSTRAINT IF EXISTS + ADD CONSTRAINT 取"最终有效"白名单,并内置解析器自检。
    【关键】首版守卫硬编码白名单副本,注入 D1 后仍全绿(对漂移零反应)→ 必须解析真实 DDL。
  - 删除 3 处假测试占位(MatchmakerServiceTest.testMatchmakerCannotLike 的 assertTrue(true);
    CoreInvariantsTest INV-007 断言的是状态常量、INV-010 亦为 assertTrue(true)),
    已如实改名并注明行为验证位置。

负向验证(两轮)
  注入 D1(回退 chk_rec_status 白名单)→ 行为守卫 DataIntegrityViolation
  + 常量守卫 Multiple Failures (3 failures) → Tests run: 23, Failures: 1, Errors: 2
  / BUILD FAILURE,守卫有效性得证。缺陷已还原。

全量回归(离线容器,实测)
  [WARNING] Tests run: 391, Failures: 0, Errors: 0, Skipped: 3
  [INFO] BUILD SUCCESS / Total time: 04:59 min
  Phase 12 基线 371 → 391(净 +20)。

报告:docs/Phase-13-V11-Recovery-Completion-Report-2026-09-16.md
Phase 13A 未自动进入,等待用户确认。
2026-09-17 00:01:53 +08:00
WorkBuddy c607e87b45 fix(relationship): Phase 12 复验 —— 修复实体↔生产迁移系统性漂移(生产故障族)/ 双路径 episode 与 NPE / 测试库缺表致首页零覆盖 / 计划项 2 未实现
KEEP-VERIFY 复验发现并修复 4 处真实缺陷:
- D1(Critical,生产故障·同一族系统性缺陷)实体与 db/migration 系统性漂移:23 表 50 列在生产
  迁移中不存在,而测试库 schema-h2.sql 手写补齐 → 全量绿=假绿。其中 11 表属"INSERT 必崩"级
  (缺 id/created_at/updated_at,被 AutoFillHandler 强制填充),含 matches.episode、user_preferences
  权重列、permissions/roles、consent_records、risk_events 等;真实 PG 上 Match 创建、择偶偏好保存、
  权限/角色写入、同意记录、风险事件全部失败。修复:新增 V037(幂等 ADD COLUMN IF NOT EXISTS
  + 存量 episode 回填)。
- D2(High)RelationshipService.createMatch 不写 episode/lastActivityAt,与 MatchService 双路径
  填充不一致;且 episode 为 null 时 m.getEpisode()+1 对 Integer 自动拆箱抛 NPE。修复:统一按
  AGENTS.md #20 推导 + null 兜底。
- D3(High)测试库缺 user_identities 等 7 表 → GET /api/v1/home 在测试环境 BadSqlGrammar,
  首页接口零回归覆盖。修复:schema-h2.sql 补齐 7 表。
- D4(Medium)Phase 12 计划项 2 实际未实现:getting_to_know 分支只产出 counseling。修复:
  HomeApplicationService 补 premarital 引导(与 counseling 并列)。

测试保真度:
- 新增永久守卫 EntityMigrationParityTest(5 用例):把"实体字段 ⊆ 生产迁移累积列"与"测试库覆盖
  关键业务表"固化为自动不变量,区分"必崩级/潜伏级";经两轮负向验证证明会变红(第一轮分级过窄
  已修正:episode 由"潜伏级"纠正为"必崩级")。
- 新增 RelationshipProgressPhase12Test(14 用例,含 8 线程并发确认)与
  HomePremaritalGuidancePhase12Test(5 用例)。
- 删除 2 个假测试类中的 assertTrue(true) 占位(AGENTS.md 明令禁止),改走真实服务方法验证。

Gate:单方确认不改变最终关系 ✅ / 双确认后才 successful ✅ / 历史 Episode 可追踪 ✅ /
新 Episode 不恢复旧聊天权限(conversations.match_id UNIQUE)✅ / 非成员不可代确认 ✅

全量回归:371 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(71 类,5m07s)
2026-09-16 23:01:13 +08:00
WorkBuddy 52e11a6c65 fix(notification): Phase 11 复验 —— 修复通知类型违反生产 CHECK 约束(生产故障)/ 幂等无库兜底 / 婚前辅导通知未接线
复验模式:KEEP-VERIFY/EXTEND(不轻信历史 ✅,逐条核对运行时路径与生产约束)。

== D3(Critical · 生产故障)通知 type 全量违反生产 CHECK 约束 ==
NotificationService 的 10 个业务模板传入复合 type
(appointment_submitted / counseling_assigned / le_verified / referral_created 等),
而生产 V020__extend_notification_types.sql 的 chk_notification_type 白名单只允许 12 个短名。
真实 PostgreSQL 下这些通知全部落库失败;测试库 schema-h2.sql 未复刻该约束,故历史 334 测试全绿 → 假绿。
修复:
- type 归一为白名单短名,业务细分交由 businessType/title 承担:
  appointment_submitted|_confirmed|_changed -> appointment
  counseling_assigned|_scheduled|_completed  -> counseling
  premarital_scheduled                       -> premarital
  le_need_evidence|le_verified|le_rejected   -> local_eligibility
  referral_created                           -> matchmaker
- 重写 buildPrivacySafeCopy:原以复合 type 为 key 的 Map.ofEntries 归一后产生重复 key 会抛
  IllegalArgumentException(运行期崩溃),改为按短名 switch。
- schema-h2.sql 补齐生产 CHECK 约束(测试保真度下沉)。

== D1(High)通知幂等仅 SELECT-then-INSERT,无 DB 兜底 ==
NotificationMapper.findExisting 为查后插,并发下可重复落库。
修复:新增 V036__add_notification_idempotency_index.sql,建 partial unique index
uk_notification_business(user_id, type, business_type, business_id, content)
WHERE business_id IS NOT NULL(先按 created_at ASC 去重存量数据)。
schema-h2.sql 同步唯一索引(H2 无 partial index,标准 UNIQUE 对 NULL 多行共存语义等价)。

== D2(Medium)婚前辅导通知未接线 ==
premaritalScheduled 零调用;CounselingServiceApp.scheduleCounseling(P13A-T22 管理端路径)完全不发通知。
修复:CounselingService.schedule 与 CounselingServiceApp.scheduleCounseling 均按 service_type 分流,
premarital 走 premaritalScheduled,其余走 counselingScheduled;
管理端路径包 try/catch —— 通知失败不回滚排期事实。

== 测试(基线 334 → 346,+12) ==
- NotificationPhase11IntegrationTest(5):回滚不发通知(Gate 前半)、提交后可见、
  顺序重试幂等、绕过 findExisting 由唯一索引兜底抛异常且最终仅 1 条、business_id IS NULL 多行共存。
- NotificationPrivacyCopyTest(5):反射校验 4 类隐私文案,咨询类不含「咨询」且标题为「服务通知」,
  不泄露姓名/时间/证件类型;未知 type 安全回退;ALLOWED_TYPES 守卫。
- NotificationTemplateTypeGuardTest(2):关键 —— 调用模板方法本身(而非直接传字面量 send),
  断言 10 个模板全部以白名单短名落库。
- MigrationValidationTest:LATEST_MIGRATION 35 -> 36,DDL 校验补 CREATE UNIQUE INDEX 识别。

== 负向验证(两轮,含第一轮失效教训) ==
第一轮:注入复合 type 后 NotificationPhase11IntegrationTest 仍全绿 ——
  因该测试直接 send(..., "appointment", ...) 传字面量,不经过模板方法,属无效守卫。
第二轮:改为 NotificationTemplateTypeGuardTest 调用模板本身,注入后精准抛
  DataIntegrityViolation,守卫有效性得证。教训已写入报告:守卫必须经负向验证证明会变红。

== 证据 ==
全量回归 346 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS
(离线 Docker:danyang-match-backend-test:latest + 卷 danyang-m2-cache)
迁移校验 12/12;定向 19/19。

== Gate 判定 ==
回滚事务不发通知 ✅ | 通知失败不回滚核心事实 ✅ | 隐私安全文案 INV-010 ✅
已提交事务通知失败可重试且不产生重复业务记录 ✅(修复后成立)

== 遗留 gap(登记不自动实现) ==
sendExternal 在事务内同步执行外部副作用,当前为占位实现;接入真实微信推送前必须改为
@TransactionalEventListener(AFTER_COMMIT) / REQUIRES_NEW —— 全仓当前无任何 afterCommit 机制。
2026-09-16 21:50:56 +08:00
WorkBuddy fd7195ecb0 fix(safety): Phase 10 复验 —— 修复图片上传魔数校验死代码(生产故障)/ 高风险词写入日志
对 Phase 10(Image & Chat Safety)执行 KEEP-VERIFY 复验:不采信历史 ✅,逐条核对
图片私有访问、类型/大小/魔数校验、审核与水印、风险识别/举报/违规处理、日志脱敏。
Gate 判定通过,发现并修复 2 处缺陷,并推翻旧报告 2 项安全声明。

D1(Critical,生产故障)图片上传的类型与魔数校验整条是死代码
  - PhotoService.uploadAndSave 用 `header = new byte[Math.min(12, stream.available())]`
    仅分配零值缓冲,从不调用 stream.read(...) → validateMagicNumber 对任何文件都收到
    12 个 0x00 → 恒返回 false → 恒抛"无效的图片文件头"。
  - 影响链(已确认线上触发):小程序 local-eligibility-supplement.ts 以
    photoType='local_eligibility' 调 POST /photos/upload → 本地资格佐证上传 100% 失败。
  - 次生问题:isAllowedMimeType(image-only 白名单)在 local_eligibility 分支之后
    无条件执行,即使魔数修好,application/pdf 仍被挡,而 FeatureFlagConfig
    的 localEvidenceAllowedTypes 明确允许 pdf;另有未 mark 的 stream.reset() 无效调用。
  - 测试为何没发现:PhotoServiceTest 只对 validateMagicNumber 传手写字节做单测,
    从不经过 uploadAndSave,该路径缺陷完全无覆盖。
  - 修复:改用 try(InputStream){ header = s.readNBytes(12); } 真正读取;
    MIME 校验按 photoType 分流(local_eligibility 走自己的白名单含 pdf);
    魔数校验仅对图片执行、PDF 跳过;移除死 reset();上传用全新流与校验流隔离;
    generateFileKey 重载按 Content-Type 推断扩展名,PDF 不再拿到误导性 .jpg key。

D2(Low)高风险词写入普通日志
  - ChatSafetyService.scanContent 把命中的高风险词原样写入 log.warn,
    与同文件 CONTACT 分支"只记 pattern 不记内容"矛盾,且违反本阶段 Gate。
  - 修复:改为 log.warn("Chat content matched HIGH_RISK keyword (blocked)")。

推翻的旧声明(docs/Phase-10-Completion-Report.md)
  - 「伪装恶意图片被拒(Magic Number校验)」不成立:该校验运行时恒失败,
    连合法图片也一律被拒(fail-closed 到不可用),安全属性并非由它提供。
  - 「EXIF清理预留(PhotoService.stripExif占位)」不存在:全仓库无该符号。
    且 getUploadToken 客户端直传路径服务端无法介入清理 EXIF。
  - 已在旧报告顶部加状态注记,指向本次复验报告。

测试
  - 新增 PhotoUploadValidationTest(6 用例),用 MockMultipartFile 真正走一遍
    uploadAndSave:合法 JPEG/PNG 通过且确实调用 putObject、伪造 Content-Type 被魔数挡、
    PDF 佐证不被 image-only 挡且 key 以 .pdf 结尾、非法 MIME 被拒、超大被拒。
  - 负向验证:临时把魔数守卫改为 if(false && ...) → 恰好 spoofedContentTypeRejectedByMagic
    一个用例变红,其余 5 个仍绿;随后还原并 grep 确认无 NEGATIVE-CHECK-TEMP 残留。
  - 全量回归(离线容器 danyang-match-backend-test,mvn -o test):
    334 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(基线 328,+6)。

登记 gap(不自动实现,待产品确认)
  - 图片水印:全仓库无实现;主计划未列硬需求,仅"开发执行计划修订版"提"高级动态水印"。
  - EXIF 清理:旧声明不存在;客户端直传路径服务端无法介入,需改为接收侧重编码。
  - 直传凭证路径(getUploadToken)无服务端内容校验且不落 DB 记录 → 后续 getSignedUrl
    归属校验会查不到 UserPhoto 行,属契约层缺口,超出本阶段安全 Gate 范围。
  - 违规自动升级:ChatSafetyService 注释明确属 Phase 17 范围,当前 high 仅拦截不落库。

文档:新增 Phase-10-V11-Recovery-Completion-Report-2026-09-16.md;
旧报告加状态注记;主计划 Phase 10 复选框置 ✅;STATUS / JIRA_BACKLOG 同步(334 tests)。

Phase 11 未启动,按计划每阶段出报告后停止等待确认。
2026-09-16 21:00:52 +08:00
WorkBuddy 03a03c7c11 fix(chat): Phase 09 复验 —— 修复 WebSocket 下行死链路 / Unmatch 后可发消息 / Block 未落库 / 聊天 IDOR
对 Phase 09(Text Chat)执行 KEEP-VERIFY/EXTEND 复验:不采信历史 ✅,逐条核对
REST 发送 + WebSocket 接收、幂等、重连、未读计数、非 active Match 禁聊、Unmatch/
Block 即时禁聊、活动双确认会话复用。共发现并修复 4 处真实缺陷 + 1 处规范不符。

D1(Critical)WebSocket 下行整条链路是死代码
  - 无 HandshakeInterceptor,无任一环节把 userId 写入 session attributes,
    extractUserId 恒返回 null;sendToUser/sendToConversation 全仓库零调用点。
  - 新增 ChatHandshakeInterceptor:从 ?token= 或 Authorization: Bearer 解析 JWT,
    校验通过后写 session attr userId;失败 fail-closed 拒绝握手;仅接受 type=access。
  - 重写 ChatWebSocketHandler:按规范 {event,data} 信封推送;未认证连接直接
    close(NOT_ACCEPTABLE);同账号重连由新连接接管、旧连接 close(NORMAL);
    上行仅支持 ping->pong,禁止客户端自订阅。
  - ChatService.sendText 落库+递增未读后推送 message.new;RiskNotice 同推。
  - 移除 WebSocketConfig 的 .withSockJS()(SockJS 改写端点致原生 WS 无法直连)。

D2(Critical)Unmatch 后仍可发消息
  - unmatch 只改 Match,不联动会话;sendText 仅校验 conv.status==active。
  - 新增 ChatService.assertMatchAlive(conv):按 matchId 查 Match,状态非
    {active,getting_to_know} 抛 "匹配已解除,无法发送消息"。sendText 在 conv.status
    校验之后调用。
  - MatchController.unmatch 落库后调 chatService.syncFromMatch 推 conversation.closed。
  - syncFromMatch 重写:alive 恢复 active;非 alive 只推 conversation.closed,
    不改 status(会话仍以灰显态留在列表,属未授权产品决策,登记待决)。

D3(Critical)RelationService.block 调静态 MatchService.unmatch 只改内存态不落库
  - 结果:Block 后 matches.status 仍为 active。
  - 补 matchMapper.updateById(m) 持久化 + chatService.closeConversationForMatch(
    CONVERSATION_STATUS_BLOCKED) 通知对端。RelationshipService.block 同步补会话关闭通知。

D4(Critical 安全)聊天 4 个接口无成员校验 -> IDOR 越权读私聊
  - MessageController.load/markRead、ConversationController.detail/unread 补
    chatService.isMember(conversationId, userId) 校验;非成员返回
    ApiResponse.notFound()(body 级 success=false code=NOT_FOUND,HTTP 200,
    与 MatchController.authorizedMatch 同约定),不区分"不存在"与"无权"以防探测。

D5(Medium)上行订阅/信封不符规范
  - REST API 接口详细设计规范.md 第124-126章要求禁止客户端自订阅,事件名
    message.new/message.read/conversation.closed,信封 {event,data}。已对齐。

测试保真度 + 测试补齐
  - schema-h2.sql 新增 uk_message_client_id(sender_id, client_message_id),
    对齐生产 V004:71;H2 不支持过滤索引故省略 WHERE(NULL 语义等价)。
    否则 service 的 SELECT-then-INSERT 竞态在测试中无 DB 兜底,形成测试盲区。
  - 新增 ChatPhase09IntegrationTest(13 用例):幂等重试不重复投递、推送信封字段、
    未读增减清零、Match unmatched 拒发、Unmatch/Block 端点即时禁聊+收 closed 事件、
    活动双确认复用同一消息体系、未形成 Match 无法建会话、非成员读消息/详情/未读
    被拒且不泄露正文、DB 唯一索引兜底重复 clientMessageId、无幂等键系统消息可共存。
  - 新增 ChatWebSocketHandlerTest(7 用例):userId 路由、信封格式、未认证关连、
    断开不推、重连替换旧连接、上行仅 ping。

验证证据
  - 全量回归(离线容器 danyang-match-backend-test,mvn -o test):
    328 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(基线 308,+20)。
  - 负向验证:临时注释 assertMatchAlive + 两处 isMember,恰好 3 个用例变红,
    证明守卫有效;随后还原并 grep 确认无残留。

文档
  - 新增 Phase-09-V11-Recovery-Completion-Report-2026-09-16.md(完整报告)。
  - Phase-09-V11-Completion-Report.md 顶部加状态注记,推翻其 4 条 WebSocket 结论。
  - 计划/STATUS/JIRA_BACKLOG 同步 Phase 09 完成态与测试总数(328)。

Phase 10 未启动,按计划每阶段出报告后停止等待确认。
2026-09-16 16:42:48 +08:00
WorkBuddy 4e478ce6ba fix(invariants): Phase 08 复验 —— 修复 INV-004 搜索/详情绕过 Block + matches.source 取值越界
复验(KEEP-VERIFY)在既有 Phase 08 实现中发现 3 处真实缺陷并修复:

D1(严重)DiscoverService.searchUsers 不含查看者身份,查询条件既无排除自己也无任何 Block 条件,被拉黑者仍可被搜到并点开 —— INV-004「搜索不可见」整体失效。改为接收 viewerUserId,排除自己,并用新增的 UserRelationMapper.findBlockedUserIdsBothWays(UNION 双向)一次取回后 notIn 批量排除,避免 N+1。

D2 详情路径(DiscoverService / UserProfileService)均无 Block 判断,知道 userId 即可读到被拉黑者资料。两条路径统一加双向判断并返回 null,由 Controller 转 404(不用「已隐藏」以免被用来探测存在);UserProfileService 中该判断排在隐私设置之前,拉黑不能被对方「允许搜索可见」覆盖。

D3(潜伏,仅 PostgreSQL 暴露)RelationshipService.like() 把关系类型值 "liked" 当 match source 写入 matches.source,违反 V003:91 的 chk_match_source。测试库 schema-h2.sql 该列是裸 VARCHAR(32)、无约束,故长期静默通过。改传 MATCH_SOURCE_USER_LIKE。canonical 路径 RelationService.like 传值本就正确,线上未触发。

测试保真度对齐:schema-h2.sql 补 chk_match_source(与 V003 逐字对齐)与 user_profiles 表(此前测试库无此表,资料查询无法端到端测试)。

新增 DiscoverBlockInvisibilityIntegrationTest(9 用例,@SpringBootTest 真实 H2 + 真实 SQL),覆盖正向/反向拉黑、对称性、排除自己、详情 404 与查自己不受限。P0FixVerificationTest 补 source 回归守卫。DiscoverServiceTest 同步构造签名。

负向验证:临时还原 D3 修复后,4 个用例因 CHK_MATCH_SOURCE 约束违反报错,证明守卫有效。

证据:后端全量 308 tests / 0 failures / 0 errors / 3 skipped(基线 299);UI 契约 13/13 GREEN。报告 docs/Phase-08-V11-Recovery-Completion-Report-2026-09-16.md。

同步 STATUS.md / JIRA_BACKLOG.md(EPIC-P08-V11 置 ✅)/ 客户需求基线计划 Phase 08 复选框;首轮报告加状态注记(其「Known Issues 无」已被推翻)。.gitignore 忽略 .workbuddy/。
2026-09-16 15:11:38 +08:00
Codex e4af367609 docs(brand): 补提交遗漏文件 —— 部署架构运维文档的 Codex 署名
上一提交 60eeb8a 的 staging 通配符未覆盖根目录这份文件名以「部」开头的文档,
其 11 处 Codex 署名(生成者/执行者标注)已是 WorkBuddy,但未入库。本次补齐。
2026-09-16 14:38:05 +08:00
Codex 60eeb8aef5 docs(brand): 全仓库 Codex 署名统一为 WorkBuddy + 5 个文件重命名
## 背景
用户指令:「先将 codex 的任务一并改为 workbuddy 的任务」。
Codex 已不再运行,当前由 WorkBuddy 承担开发执行,文档署名需同步。

## 判定规则(用大小写精确区分)
| 类别 | 写法 | 处置 |
|---|---|---|
| 工具署名 | 大写 Codex | → WorkBuddy |
| 分支名 | codex/ui-v11-fidelity | → workbuddy-ui-v11-fidelity(去斜杠,规避本仓库 ref 清理) |
| 已删除文件的历史路径 | 小写 2026-09-11-codex-baseline-cleanup* | 保持原样(记录真实曾存在的文件名,改了即失真) |

结果:26 个文件、126 处署名替换。残留小写 codex 仅 8 处,全部是历史路径。

## 文件重命名(git mv,保留历史)
- Codex开发执行总指令、项目规则与任务拆解.md → WorkBuddy 同名
- Codex首轮启动提示词 + Phase 01~Phase 26 Jira级任务清单 + 每阶段验收条件.md → WorkBuddy 同名
- docs/善缘丹阳 V1.1 Codex 开发执行计划修订版.md → docs/善缘丹阳 V1.1 WorkBuddy 开发执行计划修订版.md
- docs/善缘丹阳 V1.1 Codex-Jira 最终开发任务增量清单.md → docs/善缘丹阳 V1.1 WorkBuddy-Jira 最终开发任务增量清单.md
- docs/善缘丹阳 V1.1 Codex最终主启动提示词(整合版).md → docs/善缘丹阳 V1.1 WorkBuddy最终主启动提示词(整合版).md

引用已同步:AGENTS.md 6 处、AGENTS_SUMMARY.md、以及 4 份计划/规格文档。

## AGENTS.md 两处特别处理
- 「git config user.name = Codex」一句改为不点名工具的表述
- 「feature/ fix/ codex/ ui/ 前缀」示例去掉 codex/

## 有意未改动
- ui-tests/ui-document-baseline-contracts.mjs 的废弃清单仍列 codex-baseline-cleanup*
  那是「必须不存在」的断言目标,改了会让哨兵失去意义
- 仓库 git config user.name 仍是 Codex —— 未擅自改动环境身份。
  如需彻底交接:git config user.name WorkBuddy && git config user.email workbuddy@local

验证:UI 契约门禁 13/13;AGENTS.md 零 Codex 残留;5 个新文件名与全部引用一致
2026-09-16 14:37:44 +08:00
Codex 6ae08b89fb docs(agents): 新增「版本控制约定」强制节 + 修正 UI 验收口径
## 背景:refs/heads 嵌套分支名被实时清理,导致 HEAD 悬空

提交 bc4531a 后,refs/heads/codex/ui-v11-fidelity 被某个常驻监听进程删除(连 codex/ 目录一起),
HEAD 悬空 → `git log` 报 does not have any commits yet,`git status` 把全仓库文件标成新增。
`git update-ref` 重建返回退出码 0,但 0.2 秒内文件又消失(假成功)。

定向实验(14:06~14:10)定位的真实规则,与最初判断完全不同:

| ref 位置                        | 结果                     |
|---------------------------------|--------------------------|
| refs/heads/<单段名>             | 存活(连续观测 30s 稳定)|
| refs/heads/<任意>/<子名>        | 0.2s 内删除(连父目录)  |
| refs/tags/<任意深度>            | 存活                     |
| .git 下非 refs 的普通子目录     | 存活                     |

→ 删除器只针对 refs/heads 的子目录,且是事件驱动(0.2s),不是定时轮询(schtasks 无匹配项)。
→ 推论:标准 Git Flow 的 feature/ fix/ codex/ ui/ 前缀在本仓库全部不可用。

## 更正一处此前的错误结论

refs/codex/turn-diffs/checkpoints/... 是 09-11 / 09-14 的存量 ref,不代表有活跃 Codex 进程;
且仓库 git config user.name 本来就是 Codex,故 reflog 中所有条目都署名 Codex
(包括非 Codex 工具产生的提交)→ **reflog 署名不能用来判断是否存在并发进程**。

## 仓库处置

- HEAD 由悬空的 refs/heads/codex/ui-v11-fidelity 改为有效的 refs/heads/main
  (symbolic-ref + reset -q,仅同步索引,未触碰任何工作区文件)
- main 由 b8c33b9(09-11)快进至 bc4531a —— 纯快进:main 是 bc4531a 的严格祖先,
  `git rev-list --left-right --count main...bc4531a` = 0 58,无分叉、零冲突、零文件变化
- 保留顶层分支 feat-ui-v11 与 tag ui-v11-accepted-and-me-routes 作为冗余锚点

## AGENTS.md 变更

- 新增「版本控制约定(本机环境强制,务必先读)」一节:
  分支命名约束(禁用斜杠)、提交后自保清单(tag + bundle + 回读验证)、悬空 HEAD 的恢复方式
- 修正 UI 口径:首批范围(小程序 11 页 + 管理端框架/工作台)已于 2026-09-16 通过用户验收
  (基本达标),关闭「视觉否决 / REOPENED」判定;管理端桌面端目视复验仍待用户执行
- 头部日期更新为 2026-09-16

## STATUS.md

- 分支引用由已不存在的 codex/ui-v11-fidelity 改为 main

验证:UI 契约门禁 13/13;工作区 vs bc4531a 差异 0 行
2026-09-16 14:31:26 +08:00
Codex bc4531a734 feat(api): 补齐 /me/* 命名空间,恢复 17 个 404 调用 + 修 /me/status 过期桩
背景:小程序 services/*.js 按 REST 规范(§16 / §22-§40 / §80 / §83-§84)统一调用
/me/*,但后端从未注册 /me 基路径,个人资料、照片、择偶条件、隐私设置、关系状态、
我的举报/申诉等约 17 个调用全部 404。

后端
- 新增 me/controller/MeController:把既有 service 能力暴露到规范路径(纯委托,不复制
  业务规则,AGENTS.md #26)。旧路径(/profile/*、/photos、/relationship/*、
  /account/privacy-settings、/risk/reports/my、/appeals/my)全部保留。
- 响应形状按小程序 JSDoc 适配(小程序被 86 页引用,是权威契约):
  · /me/relationship-status 返回 status(旧 /relationship/status 返回 relationshipStatus,
    字段名不同,前端读的是 result.status)
  · /me/profile/review-status 返回 {status, items, submittedAt, reviewedAt},时间统一 epoch 毫秒
  · /me/profile/submit-review 返回 {reviewStatus, submittedAt}
- 小程序三条历史写法一并兼容:/me/privacy-settings、/me/photos/{id}/main、/me/appeals

修复
- AuthService.getMeStatus:verified / profileStatus / reviewStatus 三项写死为
  false / not_started / not_submitted,注释标注"Phase 03/04/05 填充",但那三个 Phase
  早已交付,导致 /me/status 长期返回错误的实名与审核状态。改为 capabilityService
  + userProfileMapper + reviewTaskMapper 的真实来源(与首页聚合同一判定)。
- PhotoController.deletePhoto:原用 ((Map<?,?>) p).get("id") 强转 UserPhoto 实体,
  必然抛 ClassCastException,删除照片恒定 500。改用实体主键比对。
- PhotoService.setMainAvatar(新增):数据模型以 photo_type='avatar' 标识头像(见
  UserPhotoMapper#findAvatar),无 is_main 字段,故"设主图"= 换类型并保证至多一张。
  注意:插入该方法时曾把 deletePhoto 原有的 @Transactional 挤到新方法上,导致
  "Transactional is not a repeatable annotation" 并引发 Lombok 全面停摆(满屏
  找不到 getter/setter),已修正注解归属。

验证
- 后端全量:299 tests / 0 failures / 0 errors / 3 skipped / BUILD SUCCESS(+2 为新增契约测试)
- 新增 MeRouteContractTest(2 例):同时断言 HTTP 方法,锁定 21 条 /me/* 与 13 条旧路径
- 小程序 tsc --noEmit 通过;UI 契约门禁 13/13

文档
- STATUS.md:/me/* 从"待裁决"改为"已完成";核心待清债务 1 项 → 0 项
- API_INVENTORY.md:顶部"待收敛"警告改为"已收敛",补全 /me/* 端点表
2026-09-16 12:54:39 +08:00
Codex ce172ffd4f feat(ui): activity 页迁 TS + 修复过期契约 + 新增 11 页静态预览器
## 背景
2026-09-13 UI 重设计方案的 13 个契约门禁中 3 个为红,逐一查明性质后分类处理。

## 1. activity 页迁移 TypeScript(11/11 页现已全 TS)
- activity.js → activity.ts(139 行),业务逻辑零改动,仅补类型
- 此前 STATUS.md 记「9/11 TS-only、3 页待迁」,但 messages/matchmaker
  已在 4f91be0 迁完,实际只剩 activity 一页,本提交后清零

## 2. miniapp-reference-contracts 契约自身过期(非代码问题)
- 契约引用 messages.js / matchmaker.js / activity.js,但页面已 TS 化,
  readFileSync 抛裸 ENOENT 栈,看不出「契约引用了陈旧文件名」这一真实原因
- 改为统一走 readPageLogic(page):断言 .ts 存在 + .js 不存在(锁单一运行源),
  找不到文件时给出可读断言而非崩溃
- 核心 11 页循环内统一加 TS-only 校验,防止 JS 回流

## 3. phase07-home-contracts 契约断言架构写反(非代码问题)
- 契约要求 HomeApplicationService 字面出现 userIdentityMapper.findByUserId,
  但实际是 capabilityService.isIdentityVerified(userId)(HomeApplicationService:116)
- 这符合 AGENTS.md #26「前端入口可见性由服务端 CapabilityService 返回决定」,
  让首页穿透 Capability 直接持有 mapper 反而是架构重复
- 且契约自身另一条已断言 CapabilityService 必须有 isIdentityVerified,
  两条要求互相矛盾
- 改为:查库断言落在 caps 侧;首页断言必须委托 capabilityService.isIdentityVerified;
  新增 doesNotMatch(home, /userIdentityMapper/) 禁止首页绕过 Capability
- 保留原有 doesNotMatch(home, /setVerified\(true\)/) 防硬编码断言

## 4. 新增 11 页静态预览器(本机无微信开发者工具)
- scripts/preview-miniapp.mjs:把 WXML + WXSS 转成可浏览器打开的静态 HTML
- 能力:wx:if/wx:elif/wx:else 链、wx:for 列表、嵌套三元、比较/取反运算、
  自定义组件按属性展开、slot 替换、rpx→px 换算、组件样式收集
- mock 数据严格对齐 types/index.d.ts 与各页 WXML 实际读取字段
  (如 regionName/heightCm/educationCode、status: registration_open),
  不用想象字段,避免误导验收判断
- 产出 artifacts/ui-redesign/miniapp/preview.html(11 页可切换,284K)
- 明确边界:非真机渲染、不执行页面 JS、不连后端,
  只用于视觉走查,不得作为功能验收证据

## 验证
- 契约门禁 12/13 通过(唯一红的 ui-document-baseline-contracts 涉及
  STATUS.md 验收口径,待用户看真机效果后裁决,本轮不动)
- 11 页渲染体量:116/70/47/56/94/68/39/39/41/37/38 个类名元素,
  零残留 {{}} 插值、零残留原始组件标签
- 小程序 tsc --noEmit 通过
2026-09-16 12:33:48 +08:00
Codex 3244e02e7e refactor(api): 关系/匹配域采用 2026-09-14 canonical 路径,消除三代并存
## 问题:同一能力存在三代路径,前端指向的是已废止的一代

按 mtime 排查后发现"最新"并非上一轮以为的 /relations/*,而是 2026-09-14 的 match 模块:

| 代际 | 位置 | 日期 | 路径 |
|---|---|---|---|
| canonical | platform.match: Like/Block/MatchController | 09-14 12:13~12:14 | /likes /blocks /matches |
| 二代 | platform.relationship: Relationship(Status)Controller | 09-10 10:59 | /relations/* /relationship/* |
| 一代(规范) | REST API 接口详细设计规范.md | 08-28 | /relationships/*(多一个 hips) |
| 前端 | miniapp/services/matchService.js | 08-29 | 混用二代 + 一代 |

后果:缘分三榜、黑名单页、成功牵手流程全部 404;另有两份重复 success 实现 + 一份桩实现。

## 裁决:采用 2026-09-14 最新路径,前端与二代让位(用户指令)

### 补齐 canonical 模块缺口(此前只有 javadoc 声明或服务方法,无控制器)
- RelationService.unlike() + LikeController `DELETE /likes/{targetUserId}`
- 新增 PassController `POST /passes`(RelationService.pass 在 09-14 模块内却无控制器,补齐与 /likes、/blocks 对称)
- BlockController `GET /blocks`(javadoc 早声明"我的 Block 列表"但未实现,mapper findBlockedByUser 已就绪)
- MatchController `POST /matches/{id}/success/request|confirm|decline`(javadoc 标"Phase 12 预留"),
  委派真实实现 RelationshipStatusService;authorizedMatch 对"不存在"与"无权"统一回 404,避免探测他人 Match
- LikeResponse 对齐前端 LikeResult:{ relationStatus, mutualMatch, matchedConversationId, matchId }
  Mutual 时调 ChatService.getOrCreateConversation 取会话 id → "双向喜欢→跳聊天"链路真正打通
- LikeController/PassController/BlockController 补 targetUserId 空值校验

### 移除被取代的路由与桩实现
- RelationshipController 移除 like/cancelLike/pass/block/unblock/matches/unmatch/success,仅保留 /fate/*
- RelationshipStatusController 移除 /success/*(已由 /matches/{id}/success/* 取代),保留关系状态生命周期
- RelationshipService 删除 requestSuccess/confirmSuccess 桩 ——
  前者自述 "TODO: 查询 relationship_success_requests 表" 却返回硬编码 status="pending",
  等于绕过 AGENTS 硬规则 6「成功牵手必须双确认」
- 注意:RelationshipService.like/block/pass 被 P0FixVerificationTest /
  RelationshipBlockPriorityPhase08Test 直接测试,方法体保留

### 列表信封统一
- CursorPage 从 chat.dto 提升到 shared.dto(消除 match 模块反向依赖 chat DTO)
- /relations/fate/mutual|liked|likers 与 GET /matches 改返回 { items, nextCursor }

### 前端对齐
- matchService.js 全部路径改到 canonical;补 declineSuccess
- matchStore 补 _decorateBlockedList,修复"模板渲染 blockedAtText 但全仓从未计算"的缺陷
  (与上一轮 lastMessageAtText 同源,同类字段一律在 store 装饰一次)

### 文档
- docs/api/API_INVENTORY.md 关系/缘分/匹配三节重写为 canonical 路径;
  顶部加 /me/* 路径族显式警告(小程序按规范写 /me/*,后端实为 /profile/*、/photos、/relationship/*,
  约 15 个端点会 404,收敛方向未裁决,防止后续照抄踩坑)
- docs/STATUS.md:Backend 回归债务由「待清(277 tests 4F/8E)」更新为「已清偿(297 tests 全绿)」

## 验证
- 新增 CanonicalRouteContractTest(3 例):用 RequestMappingHandlerMapping 断言
  canonical 路径已注册、被取代的旧路径不再注册、关系状态生命周期保留 —— 路由再漂移即红
- 后端 297 tests, 0 failures, 0 errors, 3 skipped, BUILD SUCCESS
- 小程序 tsc --noEmit 通过
2026-09-15 23:16:27 +08:00
Codex 687a156350 fix(chat): 会话/消息响应契约对齐小程序,修复聊天页字段断链
## 问题(三处真实断链)
后端 chat 模块字段名与小程序契约不一致,但自动化测试此前未覆盖,故一直未被发现:

1. 会话 DTO 用 otherUserId/otherDisplayName/otherAvatarUrl,
   而前端(types/index.d.ts + pages/chat/chat.ts + components/conversation-item)
   只读 targetUserId/targetDisplayName/targetAvatarUrl。
   → chat.ts 取不到对方 userId(无兜底,落到空串),
     导致"查看资料 / 拉黑 / 举报"点击后提示"暂无更多信息"并失效。
2. 消息响应只给 createdAt(ISO 字符串),前端读 Message.sentAt: number(epoch 毫秒)。
   → chat.ts 时间分隔 ts 恒为 0,消息时间分隔与时间文案不显示。
3. 会话对象缺 relationStatus,前端 conversation-item 依赖它做灰显与"已拉黑"提示。

## 裁决依据
按"以文件修改时间为准"的裁决统一到前端约定:
- 前端 types/index.d.ts 被 86 个页面引用,是声明式契约
- pages/chat/chat.ts(09-04)是最新的真实消费者
- targetUserId 已是项目内既有惯例(ActivityMatchItemDTO)
- admin-web 不消费 /conversations,无第二方受影响

## 变更
- ConversationDTO:字段改名 target*;新增 relationStatus;lastMessageAt 改 epoch 毫秒
- ChatService.toConversationDTO:同步映射;relationStatus 按 Block > unmatched > mutual_match > neutral 推导
- Constants:新增 RELATION_STATUS_NEUTRAL / MUTUAL_MATCH / UNMATCHED(不硬编码)
- MessageResponse:新增 sentAt(epoch 毫秒)
- miniapp/utils/datetime.js(新增):收敛时间格式化,
  补上模板依赖但全仓从未计算的 lastMessageAtText
- messageStore:会话进入 store 时统一装饰 lastMessageAtText
- chat.ts:_formatTime 改为复用共享工具;移除已失效的 otherDisplayName 兜底

## 验证
- 新增 ChatContractJsonTest(4 例)锁定字段名、sentAt 数值类型、
  并断言 other*/status 旧字段名不再出现
- 后端 294 tests, 0 failures, 0 errors(原 290 + 新增 4)
- 小程序 tsc --noEmit 通过

遗留(未在本轮处理):平台尚未实现头像 URL 解析(user_photos 只存 fileKey),
targetAvatarUrl 保持 null,前端 ui-avatar 回退昵称首字,不影响展示。
2026-09-15 22:45:46 +08:00
Codex eaf8fdf195 refactor(backend): P0 阻断修复 + 按最新修改时间收敛重复模块与契约
## P0 阻断修复
- 编译:修复实体类与服务类同名导致 Lombok 注解轮次中止的问题
  (MatchmakerService/CounselingService -> *Record),196 个编译错误归零
- 权限矩阵:platform_admin 按设计文档收紧为拒绝 5 项高危权限(不改测试阈值)
- 启动:修复 8 组 MyBatis-Plus 同名 bean 冲突(ConflictingBeanDefinition)
  与 URL 路由歧义(Ambiguous mapping),应用可正常启动

## 模块收敛(裁决规则:以文件修改时间为准)
- messaging 包整体删除,能力迁移至 chat 包
  * 会话列表/详情/未读端点、ConversationDTO、CursorPage
  * 消息游标分页统一为 { items, nextCursor }
- relationship 的 Match/UserRelation entity 与 mapper 合并进 match 包
- MatchmakerUserController 合并进 MatchmakerController,删除冗余 Controller
- 补齐 INV-004:chat sendText 增加双向 Block 校验,拉黑后禁止发送

## 契约对齐
- 红娘服务提交请求兼容前端 { requirement } 字段
- 消息与会话分页契约前后端统一

## 测试
- 迁移 6 个风控用例至 ChatSafetyServiceTest
- 清理 messaging 测试包遗留:6 个真实常量断言归位 chat 包,
  移除 2 个 assertTrue(true) 占位假测试(Block 真实行为已由
  P0FixVerificationTest / RelationshipBlockPriorityPhase08Test 覆盖)
- schema-h2.sql 对齐 PostgreSQL 真实表结构(matches 补列、
  conversation_members 改复合主键)

结果:290 tests, 0 failures, 0 errors, 3 skipped, BUILD SUCCESS
2026-09-15 22:29:26 +08:00
Codex 9d73087eb0 feat(admin-web): matchmaker management view — D3 debt cleared
New module admin-web/src/modules/matchmaker/MatchmakerManagementView.vue (2 tabs):
- Service request queue: assign operator / create recommendation
- Recommendations list: pending_view / viewed / liked / passed / confirmed_mutual

Route added: /matchmaker -> MatchmakerManagementView.vue (permissions: matchmaker.service.view)

D3 debt cleared: admin-web matchmaker recommendation now has a real view.
vue-tsc --noEmit -> 0 errors.
2026-09-14 15:02:51 +08:00
Codex 4f91be0cab feat(miniapp): final 3 pages migrated to TypeScript (matchmaker/messages/mine)
D1 debt cleared: all 11 miniapp core pages now TS-only.
tsc --noEmit -> 0 errors. JS sources deleted.
2026-09-14 14:59:28 +08:00
Codex 918368159c feat(phase-13A): CounselingService — INV-012/013 + 婚恋咨询 + 婚前辅导
Phase 13A Counseling — ADD new domain module per AGENTS.md rule #21/#22.

counseling module (5 files):
- Entity: CounselingService -> counseling_services
- Entity: CounselingFollowup -> counseling_followups
- Mapper: CounselingServiceMapper (findFull + findSanitized INV-013)
- Service: CounselingService
  INV-012: assertAssigned() guards schedule/complete
  INV-013: getDetail() returns sanitized for matchmaker/operator,
           full for user/assigned-counselor/manager
- Controller: 7 endpoints (user/apply/list/detail, counselor/assigned/schedule/complete, manager/assign)

Permission model (Service layer guarded):
  User(self) -> full detail
  Assigned Counselor -> full detail
  counseling_manager -> full detail
  Matchmaker/Operator -> sanitized only (no user_request_summary)

service_type: counseling(婚恋咨询) / premarital(婚前辅导)
2026-09-14 14:51:25 +08:00
Codex 5cbd931fa2 feat(phase-13): MatchmakerService — referral + manual recommendation + user confirmation
Phase 13 Matchmaker — EXTEND per JIRA. P13-V11-T01 through P13-V11-T05.

New matchmaker module (5 files):
- Entity: MatchmakerService (request), MatchmakerRecommendation (manual rec)
- Mapper: MatchmakerServiceMapper, MatchmakerRecommendationMapper
- Service: MatchmakerService
    T01 createReferral(type=counseling) — referral only, does NOT submit on behalf
    T02 createReferral(type=activity)
    T03 createReferral(type=service_station)
    T04 referral_source追溯: every referral carries operator_id + service_id + type
    T05 用户拥有最终决定权: acceptRecommendation() calls RelationService.like()
        as the USER, not the matchmaker. Matchmaker NEVER creates Match directly.
        If mutual, updates Match.source='matchmaker' for traceability.
- Controller: user endpoints (submit/list/pending-recommendations/accept/pass/viewed)

Core AGENTS.md #5 compliance:
- Matchmaker can only 'recommend', NEVER 'like on behalf of'
- User must actively accept recommendation (calls acceptRecommendation -> RelationService.like)
- createRecommendation creates pending_view record + notification, nothing else
- confirm mutual only when BOTH users independently Like (existing Like flow)

Status machines:
- matchmaker_services: submitted -> assigned -> processing -> completed
- matchmaker_recommendations: pending_view -> viewed -> liked/passed/confirmed_mutual
2026-09-14 14:48:23 +08:00
Codex 0418856ce3 feat(phase-12): RelationshipStageService — home service suggestions
Phase 12 Relationship — KEEP/EXTEND per JIRA. 不增加复杂状态.

New RelationshipStageService (match module):
  P12-V11-T01 Successful match -> premarital_suggestion
  P12-V11-T02 GettingToKnow match -> matchmaker + counseling
  P12-V11-T03 7-day silence in GettingToKnow -> counseling_recommendation

Rules are simple, based on existing Match.status + lastActivityAt:
- successful  -> 婚前辅导建议
- getting_to_know -> 红娘服务 + 婚恋咨询建议
- getting_to_know + 7d silence -> 婚恋咨询促活建议
- no Match    -> 默认红娘 + 婚恋课堂

Integration point: HomeApplicationService.buildRecommendations()
just inject RelationshipStageService and append suggestions to home DTO.
2026-09-14 14:40:13 +08:00
Codex e2a8a8c85f feat(phase-11): NotificationService — 10 business templates + privacy-safe external copy
Phase 11 Notification — EXTEND per JIRA. P11-V11-T01 through P11-V11-T11.

New notification module (4 files):
- Entity: Notification -> notifications (existing DB table)
- Mapper: findByUser/countUnread/markRead/markAllRead + findExisting(idempotent)
- Service: NotificationService with 10 business templates
  T01 appointmentSubmitted  T02 appointmentConfirmed  T03 appointmentChanged
  T04 counselingAssigned    T05 counselingScheduled   T06 counselingCompleted
  T07 premaritalScheduled   T08 leNeedEvidence        T09 leVerified
  T10 leRejected
  T11 buildPrivacySafeCopy -> external lock-screen copy NEVER reveals
  sensitive counseling type names (INV-010 compliance)
- Controller: GET list, GET unread, PUT markRead/{id}, PUT readAll

Design invariants:
- sendExternal() wrapped in try/catch -> failure does NOT rollback core service
- Idempotency via (user, type, business_type, business_id, content) dedup
- External copy uses neutral words '服务/预约' for counseling events, not '咨询'
2026-09-14 14:33:37 +08:00
Codex cf3a84e725 feat(phase-10): Chat Safety — content scan + rate limit + RiskNotice
Phase 10 Chat Safety — KEEP per JIRA. P10-V11-T01/P10-V11-T02.

New ChatSafetyService (chat module):
- scanContent(): HIGH_RISK keyword + CONTACT regex (phone/wechat/QQ/scan)
- checkRateLimit(): Redis counter + fail-open fallback, stricter for new Match (first 5 min)
- buildRiskNoticeBody(): system message JSON with anti-fraud targetPage

ChatService.sendText() integrated Phase 10:
  1. Redis rate limit check (newMatch-aware)
  2. Content scan -> risk_level on Message
  3. high/critical -> throw, block send
  4. warning -> peer gets RiskNotice system message (P10-V11-T01: anti-fraud entry)
                                        (P10-V11-T02: RiskNotice -> anti-fraud content page)
  5. Log enriched with risk_level

Constants already had RISK_LEVEL_* and MESSAGE_TYPE_SYSTEM — no new constants needed.
2026-09-14 14:16:47 +08:00
Codex eda2ce19a8 feat(phase-09): Text Chat full backend + WebSocket
Phase 09 Chat — KEEP per JIRA. P09-V11-T01 Activity Match source compatibility.

New chat module (11 files):
- Entity: Conversation, ConversationMember (复合主键), Message
- Mapper: ConversationMapper, ConversationMemberMapper, MessageMapper
- Service: ChatService (getOrCreateConversation/sendText/loadMessages/markAsRead/syncFromMatch)
- Controller: ConversationController (POST /from-match, GET /{id}, GET list)
              MessageController (POST send, GET load history, PUT mark read)
- WebSocket: WebSocketConfig + ChatWebSocketHandler (/ws/chat, JSON protocol)

Core invariants:
- INV-005: ChatService requires Match status active/getting_to_know
- Conversation.status sync with Match.status (syncFromMatch)
- Member.canSend + Conversation.status + sender membership all validated
- Client-side idempotency via client_message_id
- P09-V11-T01: Conversation binds only match_id — works identically for
  user_like / activity_mutual / matchmaker Match sources

WebSocket note: Memory-based session map; multi-node will need Redis Pub/Sub.
2026-09-14 14:00:04 +08:00
Codex 8b62b5b5df feat(phase-08): Like / Match / Block full backend implementation
Phase 08 Like/Match/Block — KEEP per JIRA, incremental P08-V11-T01.

New entities (match module):
- UserRelation -> user_relations (liked/passed/blocked)
- Match -> matches (source: user_like/activity_mutual/matchmaker, episode-aware)

New mappers:
- UserRelationMapper: findOne/countBlockedEitherWay/countReverseLike
- MatchMapper: findActiveBetween/findLatestBetween/findActiveByUser/findHistoryByUser

New services:
- RelationService: like/pass/block/unblock + mutual detection + capability guard
  - INV-004: Block priority highest (auto-unmatch, mutual block check)
  - INV-005: Like only creates Match on Mutual
- MatchService (static): createOrReuseMatch factory
  - AGENTS.md #19: existing active Match -> reuse
  - AGENTS.md #20: prior unmatched -> new Match with episode+1
  - source-aware: user_like / activity_mutual / matchmaker

New controllers:
- POST /api/v1/likes
- POST /api/v1/blocks, DELETE /api/v1/blocks/{target}
- GET /api/v1/matches, GET /api/v1/matches/{id}, POST /api/v1/matches/{id}/unmatch

Integrates with CapabilityService for base eligibility checks on Like.
2026-09-14 12:15:27 +08:00
Codex 0da82c9914 docs(gate): add phase 4 gate output artifacts 2026-09-14 11:44:02 +08:00
Codex 984827f415 docs: phase 4 acceptance report + STATUS sync
- STATUS.md updated: 阶段0-4全部完成, Ready for User Acceptance
- New acceptance report: 三端构建/13契约/源码健康度/债务清单
- CapabilityService independentized (AGENTS.md #26 compliance)
- discover 筛选完整重设计 (5 chip + 4 section + bottom sheet)
- miniapp C组 content/anti-fraud/service-station TS-only
- 三端构建全绿: miniapp tsc=0, admin-web vue-tsc=0, vite build OK
- 13/13 contracts all green
2026-09-14 11:43:46 +08:00
Codex dd58ab6eae feat(capability): extract CapabilityService — AGENTS.md rule #26 compliance
New module backend/capability/:
- CapabilityService: getCapabilities(entry point for frontend),
  plus public helpers (isAccountNormal/isIdentityVerified/isProfileApproved/
  isSearching/isBaseEligible/hasLocalEligibility/entry builder)
- CapabilityController: GET /api/v1/capabilities endpoint

HomeApplicationService refactored:
- Injects CapabilityService, delegates buildCapabilities()
  and QuickEntry construction + recommendation eligibility checks
- Removes duplicated private methods (isSearching/isIdentityVerified/
  isProfileApproved/entry) — single source of truth now in CapabilityService

Contracts updated to check CapabilityService.java for capability-related
assertions (setDisabledReason/isIdentityVerified/ACCOUNT_STATUS_NORMAL)
2026-09-14 11:38:30 +08:00
Codex 22325ec2b0 feat(discover): chip-targeted filter panel — scroll to + highlight target section
- Each filter chip (年龄/学历/婚姻) carries data-target pointing to its section
- openFilterPanel receives TouchEvent, maps target to section id, sets panelScrollTo + panelHighlight
- Panel uses scroll-view scroll-into-view with scroll-with-animation for smooth scroll
- Targeted section gets pink outline highlight via .hl class
- Non-targeted sections remain normally visible for context
2026-09-14 11:27:52 +08:00
Codex adfa4ff31e fix(discover): filter panel as bottom sheet with mask, add debug log
- Changed filter panel from in-flow to fixed bottom drawer with semi-transparent mask
- Mask click closes panel (closeFilterPanel)
- Added console.log in openFilterPanel for debugging event firing
- Panel now z-index:11 above mask z-index:10, guaranteeing visibility
2026-09-14 11:19:25 +08:00
Codex 3e5d2314aa fix(discover): restore discover-filter-chips class for contract 2026-09-14 11:09:53 +08:00
Codex 146ebab61d feat(discover): rewrite filter UI to align with backend DiscoverController 5 params
- Top chips: 推荐(重置) / 丹阳本地(regionCode=321181) / 年龄 / 学历 / 婚姻
- Each chip has real active state bound to applied filter value
- Panel expanded to 4 sections: age slider + region + education + maritalStatus
- Param names fixed: minAge/maxAge/regionCode/educationCode/maritalStatus
- Data flow: panel temp -> onApplyFilter -> applied -> _buildParams -> API
- Panel opens with current applied values, closes with openFilterPanel/closeFilterPanel
2026-09-14 11:09:23 +08:00
Codex 0f06d82a92 fix(discover): filter chips use applied state and apply immediately on top-chip tap
- Top chip '推荐' now active only when no active filter (was hardcoded)
- Top chip '丹阳本地' uses applied.region (was tempRegion)
- Top chip '年龄' uses applied age range
- New onTopRegionTap applies region filter immediately and refreshes results
- Panel onRegionTap preserved for filter-panel multi-select flow
2026-09-14 10:59:43 +08:00
Codex 7009ec079c fix(miniapp): delete redundant api.ts to avoid build conflict with CommonJS api.js
WeChat DevTools TypeScript compilation overwrites the CommonJS api.js
output with the ES-module api.ts compiled output, breaking require()
consumers (authStore.js getToken, authService.js post). The remaining
api.js has the identical ApiClient class plus extra patch() method.
2026-09-14 10:29:15 +08:00
Codex 751f14d5ee chore: remove obsolete .js sources for migrated C-group pages 2026-09-14 10:14:27 +08:00
Codex 93fc9b0d88 feat(miniapp): migrate C-group pages to TypeScript, update contracts 2026-09-14 10:14:05 +08:00
Codex e59038790c docs(status): sync with phase 0 gate results, V035 migration and clean git tree 2026-09-14 10:04:08 +08:00
Codex fd5f4d6e80 chore: add artifacts/backups to gitignore 2026-09-14 10:02:58 +08:00
Codex ea2bb2439d chore: complete infrastructure config, contracts, reference images and ui redesign artifacts 2026-09-14 10:02:34 +08:00
Codex 63cbbff2de feat: implement miniapp C-group pages with real API calls and design tokens 2026-09-14 10:02:22 +08:00
Codex bb380d40f6 feat: enhance home aggregate, review atomicity, local eligibility and match engine (phase 04-07) 2026-09-14 10:02:07 +08:00
Codex 10887f40ed feat: add V034-V035 flyway migrations and phase 04-07 test classes 2026-09-14 10:01:50 +08:00
Codex 0518cda95c docs: add phase 04-06 recovery reports, gate outputs and execution plan 2026-09-14 10:01:41 +08:00
Codex 1aea6f7791 feat: redesign public relationship services 2026-09-14 08:51:26 +08:00
Codex a6037ded59 feat: redesign activity and profile tabs 2026-09-14 07:05:20 +08:00
Codex b7dc52b517 feat: redesign discovery and messages tabs 2026-09-14 06:49:49 +08:00
Codex 36b15d9ca8 feat: rebuild miniapp reference home 2026-09-14 06:45:36 +08:00
Codex df47543501 feat: add miniapp reference assets and primitives 2026-09-14 06:38:44 +08:00
Codex 311f251609 test: lock miniapp reference page scope 2026-09-14 06:19:11 +08:00
Codex 8b8972a77b test: verify admin reference UI fidelity 2026-09-13 22:09:15 +08:00
Codex 72b0758e8c feat: recreate admin dashboard from reference 2026-09-13 21:50:44 +08:00
Codex fbafe693b6 feat: add admin dashboard data adapter 2026-09-13 21:44:58 +08:00
Codex 9279359678 feat: rebuild admin shell from reference 2026-09-13 21:39:32 +08:00
Codex ad9d32f544 feat: establish admin reference design foundation 2026-09-13 21:34:43 +08:00
Codex 3a634b4ef2 docs: finalize UI cleanup report 2026-09-13 21:26:30 +08:00
Codex 097c4c4778 chore: finalize miniapp page source cleanup 2026-09-13 21:25:50 +08:00
Codex 61ca737e02 docs: record UI cleanup checkpoint 2026-09-13 21:24:54 +08:00
Codex eed4400611 refactor: remove duplicate miniapp page sources 2026-09-13 21:19:05 +08:00
Codex c66e42dd8a docs: retire conflicting UI baselines 2026-09-13 21:12:50 +08:00
Codex 1deda1e943 chore: inventory obsolete UI files 2026-09-13 20:15:33 +08:00
Codex 56e8329b59 docs: plan reference-driven dual-client UI redesign 2026-09-13 20:07:43 +08:00
Codex 8b5d2b35f8 docs: define reference-driven dual-client UI redesign 2026-09-13 19:54:31 +08:00
admin d8f3c2a42e feat: deliver admin redesign pilot 2026-09-11 17:27:12 +08:00
admin d8338f225b fix: preserve admin warm-white contract 2026-09-11 17:06:46 +08:00
admin 564fc88fc8 feat: establish admin visual foundation 2026-09-11 17:02:42 +08:00
admin 47dacb5a1b feat: add original Shanyuan Danyang brand assets 2026-09-11 16:52:32 +08:00
admin aad666d1a0 test: freeze full UI redesign page matrix 2026-09-11 16:08:17 +08:00
admin 159772f615 docs: plan full dual-client UI redesign 2026-09-11 15:51:03 +08:00
admin 8550ab980d docs: define dual-client UI redesign 2026-09-11 15:09:44 +08:00
admin b8c33b9a0c chore: remove legacy build artifacts 2026-09-11 10:19:22 +08:00
admin ff451d3185 docs: plan Codex baseline cleanup 2026-09-11 10:15:38 +08:00
admin 8432587b83 docs: define Codex baseline cleanup 2026-09-11 10:13:02 +08:00
admin ac2327b499 chore: checkpoint before switching to main 2026-09-11 09:07:41 +08:00
616 changed files with 49766 additions and 12888 deletions
+124 -9
View File
@@ -79,15 +79,130 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Validate Flyway migrations
working-directory: backend
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
# Phase 23 KEEP-VERIFY:本步骤原注释称「验证 migration SQL 语法」,实际只检查文件非空,
# 属名不副实的假校验(SQL 语法与基线表完整性由后端测试 MigrationValidationTest 覆盖,
# 该测试随 backend job 的 `mvn verify` 运行)。现改为该测试未覆盖的**序列**校验。
- name: 迁移序列校验(命名 / 非空 / 版本号唯一 / 无缺号)
run: |
# 验证 migration SQL 语法(通过检查文件是否存在且非空)
find src/main/resources/db/migration -name "V*.sql" -type f | sort | while read f; do
echo "Validating: $f"
if [ ! -s "$f" ]; then
echo "ERROR: Empty migration file: $f"
exit 1
python3 - <<'PY'
import re, sys, pathlib, collections
d = pathlib.Path("backend/src/main/resources/db/migration")
files = sorted(d.glob("V*.sql"))
if not files:
sys.exit("ERROR: 未找到任何迁移文件")
vers, bad_name, empty = [], [], []
for f in files:
m = re.match(r"V(\d+)__", f.name)
if not m:
bad_name.append(f.name)
continue
if f.stat().st_size == 0:
empty.append(f.name)
vers.append(int(m.group(1)))
problems = []
if bad_name:
problems.append(f"迁移文件命名不合规(应为 V<序号>__<描述>.sql): {bad_name}")
if empty:
problems.append(f"空迁移文件: {empty}")
dups = sorted(v for v, c in collections.Counter(vers).items() if c > 1)
if dups:
problems.append(f"迁移版本号重复: {dups}")
gaps = [v for v in range(min(vers), max(vers) + 1) if v not in set(vers)]
if gaps:
problems.append(f"迁移版本号缺号: {gaps}")
if problems:
for p in problems:
print("ERROR: " + p, file=sys.stderr)
sys.exit(1)
print(f"OK: {len(files)} 个迁移文件,版本 {min(vers)}~{max(vers)} 连续且唯一")
PY
test-shell-guard:
name: Test Shell Guard (Phase 23 Gate)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
# Phase 23 Gate:「不允许 skip/pass 空壳」。
# 机械阻断空壳回归 —— 详见 scripts/check-no-empty-tests.py。
- name: 禁止 skip/pass 空壳测试
run: python3 scripts/check-no-empty-tests.py tests
e2e:
name: E2E (live stack)
runs-on: ubuntu-latest
# Phase 23 遗留 D-23-D:计划要求 CI 串联 E2E。此前 E2E 从未真正执行过——
# 无 conftest、fixture 不存在、文件在 import 期就崩。本 job 起真实栈后执行。
needs: [backend, migration]
env:
E2E_BASE_URL: http://localhost:8085/api/v1
# CI 专用测试口令,仅存在于 runner 运行期,不入库、不用于任何真实环境
E2E_OPERATOR_PASSWORD: ci-e2e-only-password
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install E2E dependencies
run: pip install -r tests/e2e/requirements.txt
- name: 生成 CI 测试用 .env(仅运行期存在,不入库)
run: |
cat > infrastructure/.env <<'ENV'
DB_PASSWORD=ci-e2e-db-password
REDIS_PASSWORD=ci-e2e-redis-password
MINIO_ACCESS_KEY=ci-e2e-minio
MINIO_SECRET_KEY=ci-e2e-minio-secret
JWT_ACCESS_SECRET=ci-e2e-access-secret-only-for-ci
JWT_REFRESH_SECRET=ci-e2e-refresh-secret-only-for-ci
ENCRYPTION_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
WECHAT_APP_SECRET=
ENV
- name: 起真实栈(postgres + redis + backend)
working-directory: infrastructure
run: docker compose up -d --build postgres redis backend
- name: 等待 backend 就绪(超时即失败,不静默跳过)
run: |
for i in $(seq 1 60); do
if curl -sf "$E2E_BASE_URL/health/live" > /dev/null; then
echo "backend READY after ${i} checks"; exit 0
fi
sleep 5
done
echo "All migration files validated."
echo "::error::backend 未在 5 分钟内就绪"
docker compose -f infrastructure/docker-compose.yml logs backend
exit 1
- name: Seed E2E 运营账号(迁移不含 operator 种子,全新库无账号可登录)
working-directory: infrastructure
run: |
docker compose exec -T postgres psql -U postgres -d danyang_match \
-v pwd="$E2E_OPERATOR_PASSWORD" -f - < ../tests/e2e/sql/seed_e2e_operators.sql
- name: Run E2E
run: pytest -v
- name: Teardown(无论成败都清理并留日志)
if: always()
run: |
docker compose -f infrastructure/docker-compose.yml logs backend || true
docker compose -f infrastructure/docker-compose.yml down -v || true
+6
View File
@@ -41,6 +41,7 @@ tests/fixtures/*.json
# Local workspace and developer settings
.worktrees/
.zcode/
.workbuddy/
project.private.config.json
/nul
/infrastructure/nul
@@ -54,3 +55,8 @@ docker-compose.prod.yml
infrastructure/secrets/
infrastructure/certs/
infrastructure/backups/
# Local artifacts and backups
artifacts/backups/
*.dump
*.bundle
+55 -11
View File
@@ -1,7 +1,7 @@
# 善缘丹阳 V1.1 - AGENTS.md
> 本文件为项目最高执行规则摘要,Codex 每次启动时必须首先阅读。
> 版本:V1.1 | 最后更新:2026-09-08 | 基线:v1.0.0-RC.2
> 本文件为项目最高执行规则摘要,WorkBuddy 每次启动时必须首先阅读。
> 版本:V1.1 | 最后更新:2026-09-16 | 基线:v1.0.0-RC.2
---
@@ -9,18 +9,20 @@
以下文件共同构成当前 V1.1 需求,不得只读取其中一份:
1. `docs/善缘丹阳 V1.1 Codex最终主启动提示词(整合版).md`:执行纪律、产品红线、不变量和阶段门禁;
1. `docs/善缘丹阳 V1.1 WorkBuddy最终主启动提示词(整合版).md`:执行纪律、产品红线、不变量和阶段门禁;
2. `docs/善缘丹阳 V1.1 页面级功能清单与交互流程修订版.md`:页面、功能、状态和用户路径;
3. `docs/善缘丹阳 V1.1 前端 UI-UX 高保真原型修订说明.md`:视觉、组件、响应式和前端验收;
3. `docs/superpowers/specs/2026-09-13-reference-driven-dual-client-ui-redesign-design.md`:当前唯一 UI 视觉、组件、响应式和前端验收基线;
4. `docs/善缘丹阳 V1.1 数据库与API增量设计.md`:数据模型、API、事务、幂等和权限契约;
5. `docs/善缘丹阳 V1.1 Codex 开发执行计划修订版.md`:Phase 顺序和增量实施策略;
6. `docs/善缘丹阳 V1.1 Codex-Jira 最终开发任务增量清单.md`:任务粒度、依赖和验收条件。
5. `docs/善缘丹阳 V1.1 WorkBuddy 开发执行计划修订版.md`:Phase 顺序和增量实施策略;
6. `docs/善缘丹阳 V1.1 WorkBuddy-Jira 最终开发任务增量清单.md`:任务粒度、依赖和验收条件。
冲突时先服从用户最新明确指令,再由上述文件各自在其专业域内裁决。文档中的“已完成、已部署、✅”是历史进度声明,不是需求本身;只有当前代码、测试和人工验收可以证明完成。
V1.1 是 V1.0 的增量升级。正确的 V1.0 核心按 KEEP 处理,不得因为重新审查而推倒重写;错误首页和活动互选规则必须替换,新增业务按 EXTEND / REFACTOR / REPLACE / ADD 执行。
当前唯一执行计划:`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md`。
业务 Phase 唯一执行计划:`docs/善缘丹阳 V1.1 客户需求基线增量开发与修复计划.md`。
当前 UI 重设计唯一总计划:`docs/superpowers/plans/2026-09-13-reference-driven-dual-client-ui-redesign.md`。首批范围(小程序 11 个核心页面 + 管理端框架/工作台)已于 **2026-09-16 通过用户验收(结论:基本达标)**,此前"视觉否决 / REOPENED"的判定随之关闭,UI 契约门禁 13/13 全绿。管理端桌面端目视复验仍待用户执行。
---
@@ -152,13 +154,55 @@ Phase 完成后必须输出 Completion Report,不得自动进入下一阶段
---
# 版本控制约定(本机环境强制,务必先读)
## 分支命名:禁止使用带 `/` 的嵌套名
本机存在一个**常驻监听进程**,会**即时(< 0.2 秒)删除 `refs/heads/` 下的嵌套子目录**。
2026-09-16 定向实验结果:
| ref 位置 | 结果 |
|---|---|
| `refs/heads/<单段名>`(如 `feat-ui-v11`) | **存活**(连续观测 30s 稳定) |
| `refs/heads/<任意>/<子名>`(嵌套) | **0.2s 内被删除**(连父目录一起) |
| `refs/tags/<任意深度>` | **存活** |
| `.git/` 下非 refs 的普通子目录 | **存活** |
**后果**:分支指针被删后 **HEAD 会悬空**(`ref: refs/heads/<被删分支>`)。此时
任何 `git commit` 会生成**根提交**,切断全部历史;`git status` 还会把全仓库文件标成新增。
**因此标准 Git Flow 的 `feature/`、`fix/`、`ui/` 等带斜杠前缀在本仓库一律不可用**,
必须使用**顶层单段名**(如 `feat-ui-v11`、`ui-acceptance`)。
## 提交后自保(本机环境必做)
```bash
git tag <name> <sha> # tag 不受清理影响,让提交变为可达,免被 gc 回收
git bundle create <file>.bundle <tag-name> # 参数必须是 ref 名;传裸 sha 会报 empty bundle
sleep 2 && ls .git/refs/heads/ && git log --oneline -1 # 回读验证,别信退出码
```
- `git update-ref` **返回退出码 0 不代表持久化成功**,必须回读验证。
- 判断是否有并发进程**不能看 reflog 署名**:本仓库 `git config user.name` 是一个与真实执行工具无关的固定占位值,
所有 reflog 条目都署名同一个名字,与该条目实际由谁产生无关。
- 若发现 HEAD 悬空(`git log` 报 `does not have any commits yet`),按以下方式恢复,
**不要触碰任何工作区文件**:
```bash
git symbolic-ref HEAD refs/heads/<顶层单段分支>
git reset -q # 仅同步索引,不改工作区
git status --short # 应干净
```
---
# 文档
- 主启动提示词: `docs/善缘丹阳 V1.1 Codex最终主启动提示词(整合版).md`
- Jira 任务清单: `docs/善缘丹阳 V1.1 Codex-Jira 最终开发任务增量清单.md`
- 开发执行计划: `docs/善缘丹阳 V1.1 Codex 开发执行计划修订版.md`
- 主启动提示词: `docs/善缘丹阳 V1.1 WorkBuddy最终主启动提示词(整合版).md`
- Jira 任务清单: `docs/善缘丹阳 V1.1 WorkBuddy-Jira 最终开发任务增量清单.md`
- 开发执行计划: `docs/善缘丹阳 V1.1 WorkBuddy 开发执行计划修订版.md`
- 数据库/API设计: `docs/善缘丹阳 V1.1 数据库与API增量设计.md`
- 前端 UI/UX: `docs/善缘丹阳 V1.1 前端 UI-UX 高保真原型修订说明.md`
- 前端 UI/UX: `docs/superpowers/specs/2026-09-13-reference-driven-dual-client-ui-redesign-design.md`
- 页面级功能: `docs/善缘丹阳 V1.1 页面级功能清单与交互流程修订版.md`
- 状态跟踪: `docs/STATUS.md`
- 待确认项: `docs/PENDING_DECISIONS.md`
+1 -1
View File
@@ -1,6 +1,6 @@
# 丹阳公益婚恋服务平台 - 项目规则与任务拆解(摘要)
> 完整规则见:`Codex开发执行总指令、项目规则与任务拆解.md`
> 完整规则见:`WorkBuddy开发执行总指令、项目规则与任务拆解.md`
## 最高原则
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+1 -1
View File
@@ -1,7 +1,7 @@
# 丹阳公益婚恋服务平台 V1.0 — 上线前剩余工作
> 本文件为上线前必须完成的工作清单,按优先级排序。
> 交由 Codex/zcode 执行。
> 交由 WorkBuddy 执行。
---
@@ -1,16 +1,16 @@
# 丹阳公益婚恋服务平台
# V1.0 Codex开发执行总指令、项目规则与任务拆解
# V1.0 WorkBuddy开发执行总指令、项目规则与任务拆解
版本:V1.0
文档级别:最高优先级开发执行文件
适用对象:Codex、研发人员、测试人员、后续维护人员
适用对象:WorkBuddy、研发人员、测试人员、后续维护人员
执行原则:严格继承既有设计,不擅自扩展,不擅自删减,不擅自重新定义产品
---
# 第一章 文档定位
本文件用于指导Codex完整开发:
本文件用于指导WorkBuddy完整开发:
> 丹阳公益婚恋服务平台 V1.0
@@ -34,7 +34,7 @@
任务拆解
```
Codex在整个项目开发周期内,应将本文件视为:
WorkBuddy在整个项目开发周期内,应将本文件视为:
> **项目最高执行规则。**
@@ -114,7 +114,7 @@ Codex在整个项目开发周期内,应将本文件视为:
# 第五章 明确禁止偏离产品方向
Codex不得将平台开发成:
WorkBuddy不得将平台开发成:
```text
商业婚恋VIP平台
@@ -382,7 +382,7 @@ Preferred:
> 可参与评分。
Codex不得把Required自动降级。
WorkBuddy不得把Required自动降级。
---
@@ -1308,7 +1308,7 @@ Node.js + NestJS
尚未锁死。
Codex如果当前仓库没有既有技术栈:
WorkBuddy如果当前仓库没有既有技术栈:
优先建议:
@@ -1632,7 +1632,7 @@ Match active pair
- 历史记录
- Active Pair Unique
具体表结构由Codex合理实现。
具体表结构由WorkBuddy合理实现。
---
@@ -1665,7 +1665,7 @@ closed
属于建议技术模型。
如果Codex优化命名:
如果WorkBuddy优化命名:
> 语义必须一致。
@@ -2050,9 +2050,9 @@ UI细节
---
# 第一百二十章 Codex开发模式
# 第一百二十章 WorkBuddy开发模式
Codex必须采用:
WorkBuddy必须采用:
> 分阶段、可运行、可验证。
@@ -2090,7 +2090,7 @@ Codex必须采用:
# 第一百二十二章 不允许静默改变需求
如果Codex发现:
如果WorkBuddy发现:
- 规则冲突
- 前文未确认
@@ -2260,7 +2260,7 @@ ADR-003-object-storage.md
---
# 第一百三十一章 Codex项目记忆文件
# 第一百三十一章 WorkBuddy项目记忆文件
建议仓库根目录建立:
@@ -2270,7 +2270,7 @@ AGENTS.md
用于放:
> Codex执行规则摘要。
> WorkBuddy执行规则摘要。
---
@@ -2311,7 +2311,7 @@ docs/STATUS.md
待确认事项
```
Codex每阶段更新。
WorkBuddy每阶段更新。
---
@@ -2994,7 +2994,7 @@ MFA强化
# 第一百八十章 V1明确不做列表
Codex遇到以下需求不得自行开发:
WorkBuddy遇到以下需求不得自行开发:
```text
直播
@@ -3203,7 +3203,7 @@ UI走:
# 第一百九十二章 核心Invariant
Codex必须在代码、测试中明确保护以下Invariant:
WorkBuddy必须在代码、测试中明确保护以下Invariant:
```text
未Mutual不可聊天
@@ -3316,7 +3316,7 @@ Operator
每完成模块:
Codex必须运行:
WorkBuddy必须运行:
> 相关测试。
@@ -3458,7 +3458,7 @@ SQLSTATE
---
# 第二百零九章 Codex每阶段输出要求
# 第二百零九章 WorkBuddy每阶段输出要求
每完成一个阶段:
@@ -3533,7 +3533,7 @@ Appeal是否V1首发
---
# 第二百一十三章 Codex不得把待确认项硬编码
# 第二百一十三章 WorkBuddy不得把待确认项硬编码
必须:
@@ -3795,7 +3795,7 @@ V1不需要大量SDK。
# 第二百三十三章 License
Codex新增开源库时:
WorkBuddy新增开源库时:
必须确认License。
@@ -3935,7 +3935,7 @@ is_test_account
# 第二百四十四章 小程序发布前
Codex必须准备:
WorkBuddy必须准备:
```text
微信合法域名清单
@@ -4017,7 +4017,7 @@ agreed_at
# 第二百五十章 删除/注销
Codex必须设计:
WorkBuddy必须设计:
> 正式后端流程。
@@ -4119,7 +4119,7 @@ V1至少保证:
---
# 第二百五十九章 Codex不得擅自加入“内部用户价值评分”
# 第二百五十九章 WorkBuddy不得擅自加入“内部用户价值评分”
禁止:
@@ -4341,7 +4341,7 @@ Mutual
---
# 第二百七十二章 Codex阶段验收机制
# 第二百七十二章 WorkBuddy阶段验收机制
每完成Phase:
@@ -4357,7 +4357,7 @@ Mutual
---
# 第二百七十三章 Codex禁止声明完成的情况
# 第二百七十三章 WorkBuddy禁止声明完成的情况
如果:
@@ -4449,7 +4449,7 @@ Phase 26 Production
---
# 第二百七十八章 Codex每次启动工作时
# 第二百七十八章 WorkBuddy每次启动工作时
必须先阅读:
@@ -4464,7 +4464,7 @@ docs/PENDING_DECISIONS.md
---
# 第二百七十九章 Codex发现旧代码与文档冲突
# 第二百七十九章 WorkBuddy发现旧代码与文档冲突
优先级:
@@ -4506,7 +4506,7 @@ docs/PENDING_DECISIONS.md
# 第二百八十一章 已知需要纠正的旧示例
Codex特别注意:
WorkBuddy特别注意:
以下不是固定产品要求:
@@ -4573,7 +4573,7 @@ Codex特别注意:
线下闭环
```
Codex投入重点必须放在这些地方。
WorkBuddy投入重点必须放在这些地方。
---
@@ -4744,7 +4744,7 @@ Rollback Pass
# 第二百九十六章 项目最终交付物
Codex必须至少交付:
WorkBuddy必须至少交付:
```text
完整源码
@@ -4840,7 +4840,7 @@ Tests
---
# 第三百零一章 Codex最终执行宣言
# 第三百零一章 WorkBuddy最终执行宣言
在整个开发过程中必须始终遵守:
@@ -4876,7 +4876,7 @@ Tests
---
# 第三百零二章 Codex项目最终原则
# 第三百零二章 WorkBuddy项目最终原则
整个项目只围绕一件事情设计:
@@ -4910,9 +4910,9 @@ Tests
---
# 第三百零三章 Codex收到本文档后的第一条执行指令
# 第三百零三章 WorkBuddy收到本文档后的第一条执行指令
Codex不得立即开始无序编码。
WorkBuddy不得立即开始无序编码。
首先执行:
@@ -4947,7 +4947,7 @@ Codex不得立即开始无序编码。
---
# 第三百零四章 Codex首次仓库审计必须输出
# 第三百零四章 WorkBuddy首次仓库审计必须输出
```text
当前技术栈
@@ -4994,7 +4994,7 @@ Pending Decisions
Test Result
```
保证下一次Codex恢复上下文时:
保证下一次WorkBuddy恢复上下文时:
> 可以从项目状态继续,而不是重新理解整个项目。
@@ -1,20 +1,20 @@
# 丹阳公益婚恋服务平台
# Codex首轮启动提示词 + Phase 01~Phase 26 Jira级任务清单 + 每阶段验收条件
# WorkBuddy首轮启动提示词 + Phase 01~Phase 26 Jira级任务清单 + 每阶段验收条件
版本:V1.0
适用对象:Codex
使用方式:首次启动项目时完整提供给Codex;后续每次开发任务继续要求Codex先读取 `AGENTS.md`、`docs/STATUS.md`、`docs/PENDING_DECISIONS.md` 后再执行。
适用对象:WorkBuddy
使用方式:首次启动项目时完整提供给WorkBuddy;后续每次开发任务继续要求WorkBuddy先读取 `AGENTS.md`、`docs/STATUS.md`、`docs/PENDING_DECISIONS.md` 后再执行。
文档性质:研发启动执行包,不重新定义产品需求。
---
# 第一部分:Codex首轮启动提示词
# 第一部分:WorkBuddy首轮启动提示词
以下内容可以直接复制给Codex执行。
以下内容可以直接复制给WorkBuddy执行。
---
## 一、Codex首轮完整启动提示词
## 一、WorkBuddy首轮完整启动提示词
你现在负责完整开发一个正式项目:
@@ -631,7 +631,7 @@ Step 8
---
# 十三、Codex每次开发前
# 十三、WorkBuddy每次开发前
必须读取:
@@ -646,7 +646,7 @@ docs/PENDING_DECISIONS.md
---
# 十四、Codex每次完成任务后
# 十四、WorkBuddy每次完成任务后
必须更新:
@@ -2134,7 +2134,7 @@ INV-010 跨Organization不能越权
---
# 第五部分:Codex每阶段输出格式
# 第五部分:WorkBuddy每阶段输出格式
每个Phase完成以后必须输出以下报告。
@@ -2192,9 +2192,9 @@ Phase XX+1
---
# 第六部分:Codex首轮实际执行任务
# 第六部分:WorkBuddy首轮实际执行任务
首次把本文档交给Codex后,不要让它直接执行26个Phase。
首次把本文档交给WorkBuddy后,不要让它直接执行26个Phase。
第一轮只执行以下任务。
@@ -2319,7 +2319,7 @@ Status
# 三十一、首轮任务06:Phase 01详细Task Breakdown
Codex把P01继续拆成:
WorkBuddy把P01继续拆成:
> 0.5~1天左右粒度的工程任务。
@@ -2704,7 +2704,7 @@ Jira
# 第十二部分:最终执行要求
Codex必须将整个项目理解为:
WorkBuddy必须将整个项目理解为:
> **一个长期可运营的公益婚恋服务系统。**
@@ -2744,7 +2744,7 @@ Codex必须将整个项目理解为:
---
# 第十三部分:给Codex的首轮最终指令
# 第十三部分:给WorkBuddy的首轮最终指令
现在开始执行本项目。
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
*.log
.env*
pnpm-lock.yaml
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

+735 -61
View File
@@ -1,68 +1,742 @@
<template>
<el-container class="main-layout">
<el-aside :width="isCollapse ? '72px' : '220px'" class="sidebar">
<div class="brand" :class="{ compact: isCollapse }">
<div class="brand-symbol">缘</div>
<div v-if="!isCollapse" class="brand-copy"><strong>善缘丹阳</strong><span>公益婚恋运营平台</span></div>
<div class="admin-scene" :class="{ 'sidebar-collapsed': isCollapse }">
<header class="scene-header" aria-label="善缘丹阳公益服务品牌信息">
<div class="scene-brand">
<img src="/brand/logo-mark.png" alt="善缘丹阳" />
<div class="scene-brand-copy">
<strong>善缘丹阳</strong>
<span>公益婚恋服务平台</span>
</div>
<div class="scene-title">
<h1>管理端(运营后台)</h1>
<p>用专业的服务,帮助更多人遇见对的人</p>
</div>
</div>
<el-scrollbar class="menu-scroll">
<el-menu :default-active="activeMenu" :collapse="isCollapse" :router="true" class="sidebar-menu">
<el-menu-item index="/dashboard"><el-icon><DataBoard /></el-icon><template #title>工作台</template></el-menu-item>
<el-menu-item-group v-if="showGroup(['review.view','user.view'])" title="资料与用户">
<el-menu-item v-if="hasPerm('review.view')" index="/review"><el-icon><DocumentChecked /></el-icon><template #title>资料审核</template></el-menu-item>
<el-menu-item v-if="hasPerm('user.view')" index="/users"><el-icon><User /></el-icon><template #title>用户管理</template></el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="showGroup(['counseling.service.view','service_program.manage','referral.manage'])" title="婚恋服务">
<el-menu-item v-if="hasPerm('counseling.service.view')" index="/counseling"><el-icon><ChatDotRound /></el-icon><template #title>婚恋咨询</template></el-menu-item>
<el-menu-item v-if="hasPerm('service_program.manage')" index="/service-program"><el-icon><Service /></el-icon><template #title>专项公益服务</template></el-menu-item>
<el-menu-item v-if="hasPerm('referral.manage')" index="/referral"><el-icon><Connection /></el-icon><template #title>服务转介</template></el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="showGroup(['activity.review_registration'])" title="活动运营">
<el-menu-item index="/activities/interests"><el-icon><Calendar /></el-icon><template #title>活动与互选</template></el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="showGroup(['banner.view','content.view'])" title="内容运营">
<el-menu-item v-if="hasPerm('banner.view')" index="/banner"><el-icon><Promotion /></el-icon><template #title>首页 Banner</template></el-menu-item>
<el-menu-item v-if="hasPerm('content.view')" index="/content"><el-icon><Reading /></el-icon><template #title>课堂与反诈内容</template></el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="showGroup(['service_location.view','service_appointment.view'])" title="服务阵地">
<el-menu-item v-if="hasPerm('service_location.view')" index="/service-station"><el-icon><Location /></el-icon><template #title>善缘服务站</template></el-menu-item>
<el-menu-item v-if="hasPerm('service_appointment.view')" index="/service-appointment"><el-icon><Memo /></el-icon><template #title>服务预约</template></el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="showGroup(['risk.report.view','risk.report.process'])" title="安全治理">
<el-menu-item v-if="hasPerm('risk.report.view')" index="/risk/reports"><el-icon><Warning /></el-icon><template #title>举报中心</template></el-menu-item>
<el-menu-item v-if="hasPerm('risk.report.process')" index="/appeals"><el-icon><CircleCheck /></el-icon><template #title>申诉管理</template></el-menu-item>
</el-menu-item-group>
<el-menu-item-group v-if="showGroup(['organization.manage','permission.manage','user.view'])" title="组织与系统">
<el-menu-item v-if="hasPerm('organization.manage')" index="/organizations"><el-icon><OfficeBuilding /></el-icon><template #title>组织管理</template></el-menu-item>
<el-menu-item v-if="hasPerm('permission.manage')" index="/operators"><el-icon><Setting /></el-icon><template #title>工作人员</template></el-menu-item>
<el-menu-item v-if="hasPerm('user.view')" index="/audit"><el-icon><Document /></el-icon><template #title>操作日志</template></el-menu-item>
</el-menu-item-group>
<el-menu-item v-if="hasPerm('analytics.view')" index="/analytics"><el-icon><TrendCharts /></el-icon><template #title>数据统计</template></el-menu-item>
</el-menu>
</el-scrollbar>
<button class="collapse-control" type="button" @click="isCollapse=!isCollapse"><el-icon><Fold v-if="!isCollapse"/><Expand v-else/></el-icon><span v-if="!isCollapse">收起导航</span></button>
</el-aside>
<el-container class="workspace">
<el-header class="header">
<div><div class="context-kicker">善缘丹阳 · 公益服务</div><el-breadcrumb separator="/"><el-breadcrumb-item :to="{path:'/dashboard'}">工作台</el-breadcrumb-item><el-breadcrumb-item v-if="currentTitle!=='工作台'">{{currentTitle}}</el-breadcrumb-item></el-breadcrumb></div>
<div class="header-right"><div class="service-state"><i></i>服务正常</div><el-dropdown><span class="user-info"><el-avatar :size="36">{{(operator?.name||'工')[0]}}</el-avatar><span><strong>{{operator?.name||'工作人员'}}</strong><small>运营人员</small></span><el-icon><ArrowDown/></el-icon></span><template #dropdown><el-dropdown-menu><el-dropdown-item @click="handleLogout">退出登录</el-dropdown-item></el-dropdown-menu></template></el-dropdown></div>
</el-header>
<el-main class="main-content"><router-view v-slot="{Component}"><keep-alive><component :is="Component"/></keep-alive></router-view></el-main>
<div class="scene-slogan" aria-hidden="true">
<span>善缘相牵</span>
<strong>爱在丹阳</strong>
</div>
<div class="scene-benefits" aria-label="平台服务原则">
<span><el-icon><StarFilled /></el-icon><b>公益为本</b><small>不以营利为目的</small></span>
<span><el-icon><Lock /></el-icon><b>真实可靠</b><small>严格审核 多重保障</small></span>
<span><el-icon><LocationFilled /></el-icon><b>本地服务</b><small>深耕丹阳 服务家乡</small></span>
<span><el-icon><Opportunity /></el-icon><b>成就幸福</b><small>让爱在丹阳生根发芽</small></span>
</div>
</header>
<el-container class="admin-shell">
<el-aside :width="isCollapse ? '76px' : '208px'" class="admin-sidebar">
<div class="brand" :class="{ compact: isCollapse }">
<img class="brand-mark" src="/brand/logo-mark.png" alt="" />
<div v-if="!isCollapse" class="brand-copy">
<strong>善缘丹阳</strong>
<span>公益婚恋服务平台</span>
</div>
</div>
<el-scrollbar class="menu-scroll">
<nav aria-label="管理后台主导航">
<el-menu
:default-active="activeMenu"
:collapse="isCollapse"
:router="true"
class="sidebar-menu"
>
<el-menu-item
v-for="item in visibleNavigation"
:key="item.label"
:index="item.path"
>
<el-icon><component :is="item.icon" /></el-icon>
<template #title>{{ item.label }}</template>
</el-menu-item>
</el-menu>
</nav>
</el-scrollbar>
<div v-if="!isCollapse" class="sidebar-motto">
<el-icon><StarFilled /></el-icon>
<span>汇聚善意<br />成就良缘</span>
</div>
<button
class="collapse-control"
type="button"
:aria-label="isCollapse ? '展开导航' : '收起导航'"
@click="isCollapse = !isCollapse"
>
<el-icon><Fold v-if="!isCollapse" /><Expand v-else /></el-icon>
<span v-if="!isCollapse">收起导航</span>
</button>
</el-aside>
<el-container class="workspace">
<el-header class="admin-topbar">
<div class="page-context">
<button class="mobile-collapse" type="button" aria-label="切换导航" @click="isCollapse = !isCollapse">
<el-icon><Expand v-if="isCollapse" /><Fold v-else /></el-icon>
</button>
<el-breadcrumb separator="/">
<el-breadcrumb-item :to="{ path: '/dashboard' }">工作台</el-breadcrumb-item>
<el-breadcrumb-item v-if="currentTitle !== '工作台'">{{ currentTitle }}</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="header-tools">
<el-input
v-model="globalSearch"
class="global-search"
clearable
aria-label="搜索后台页面"
placeholder="搜索用户、活动、内容…"
@keyup.enter="handleSearch"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<button class="notification-button" type="button" aria-label="查看系统通知" @click="showNotifications">
<el-icon><Bell /></el-icon>
<span class="notification-badge" aria-hidden="true">{{ notificationCount }}</span>
</button>
<el-dropdown trigger="click">
<span class="user-info">
<el-avatar :size="34">{{ (operator?.name || '工')[0] }}</el-avatar>
<strong>{{ operator?.name || '工作人员' }}</strong>
<el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item disabled>{{ currentRoleName }}</el-dropdown-item>
<el-dropdown-item divided @click="handleLogout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</el-header>
<el-main class="admin-content">
<router-view v-slot="{ Component }">
<keep-alive><component :is="Component" /></keep-alive>
</router-view>
</el-main>
</el-container>
</el-container>
</el-container>
<footer class="scene-footer">
<strong>善缘丹阳</strong><span>公益婚恋服务平台</span>
<small>真实 · 真诚 · 公益 · 幸福</small>
</footer>
</div>
</template>
<script setup lang="ts">
import {ref,computed} from 'vue'
import {useRoute,useRouter} from 'vue-router'
import {DataBoard,DocumentChecked,User,OfficeBuilding,Setting,Calendar,Warning,ChatDotRound,TrendCharts,Document,Service,Location,Memo,Promotion,Reading,Connection,CircleCheck,Fold,Expand,ArrowDown} from '@element-plus/icons-vue'
import {useAuthStore} from '@/stores/auth'
const route=useRoute();const router=useRouter();const authStore=useAuthStore();const isCollapse=ref(false)
const activeMenu=computed(()=>route.path);const currentTitle=computed(()=>(route.meta?.title as string)||'页面');const operator=computed(()=>authStore.operator)
function hasPerm(permission:string){return (authStore.permissions||[]).includes(permission)}
function showGroup(perms:string[]){return perms.some(hasPerm)}
function handleLogout(){authStore.logout();router.push('/login')}
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowDown,
Bell,
Calendar,
ChatDotRound,
Clock,
Connection,
DataAnalysis,
DataBoard,
DocumentChecked,
Expand,
Fold,
House,
Location,
LocationFilled,
Lock,
Memo,
Opportunity,
Picture,
Reading,
Search,
Setting,
StarFilled,
Tickets,
User,
UserFilled,
Warning,
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
interface NavigationItem {
label: string
path: string
permissions: string[]
icon: unknown
}
const navigation: NavigationItem[] = [
{ label: '工作台', path: '/dashboard', permissions: [], icon: DataBoard },
{ label: '用户管理', path: '/users', permissions: ['user.view'], icon: User },
{ label: '资料审核', path: '/review', permissions: ['review.view'], icon: DocumentChecked },
{ label: '匹配管理', path: '/activities/interests', permissions: ['activity.review_registration'], icon: StarFilled },
{ label: '公益红娘', path: '/referral', permissions: ['referral.manage'], icon: Connection },
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.service.view'], icon: Connection },
{ label: '公益咨询', path: '/counseling', permissions: ['counseling.service.view'], icon: ChatDotRound },
{ label: '活动管理', path: '/activities', permissions: ['activity.view'], icon: Calendar },
{ label: '活动签到', path: '/activities/checkin', permissions: ['activity.checkin'], icon: Calendar },
{ label: 'Banner管理', path: '/banner', permissions: ['banner.view'], icon: Picture },
{ label: '内容管理', path: '/content', permissions: ['content.view'], icon: Reading },
{ label: '善缘服务站', path: '/service-station', permissions: ['service_location.view'], icon: Location },
{ label: '服务预约', path: '/service-appointment', permissions: ['service_appointment.view'], icon: House },
{ label: '专项服务', path: '/service-program', permissions: ['service_program.manage'], icon: Tickets },
{ label: '举报风控', path: '/risk/reports', permissions: ['risk.report.view'], icon: Warning },
{ label: '申诉管理', path: '/appeals', permissions: ['risk.report.process'], icon: Memo },
{ label: '数据统计', path: '/analytics', permissions: ['analytics.view'], icon: DataAnalysis },
{ label: '组织人员', path: '/organizations', permissions: ['organization.manage'], icon: UserFilled },
{ label: '操作日志', path: '/audit', permissions: ['user.view'], icon: Clock },
{ label: '系统设置', path: '/operators', permissions: ['permission.manage'], icon: Setting },
]
const route = useRoute()
const router = useRouter()
const authStore = useAuthStore()
const isCollapse = ref(false)
const globalSearch = ref('')
const notificationCount = ref(0)
const activeMenu = computed(() => {
if (route.path === '/users/detail') return '/users'
return route.path
})
const currentTitle = computed(() => (route.meta?.title as string) || '页面')
const operator = computed(() => authStore.operator)
const permissions = computed(() => authStore.permissions || [])
const currentRoleName = computed(() => {
// 按权限从高到低判定,覆盖七类后台角色(平台管理员 / 咨询管理员 / 内容编辑员 /
// 风控运营 / 公益咨询 / 服务站运营 / 资料审核 / 公益红娘)。
const perms = permissions.value
if (perms.includes('permission.manage')) return '平台管理员'
if (perms.includes('counseling.service.assign') || perms.includes('counseling.note.view')) return '咨询管理员'
if (perms.includes('content.publish')) return '内容编辑员'
if (perms.includes('risk.report.process')) return '风控运营人员'
if (perms.includes('counseling.service.view')) return '公益咨询人员'
if (perms.includes('service_appointment.view')) return '服务站运营人员'
if (perms.includes('review.view')) return '资料审核人员'
if (perms.includes('matchmaker.service.view')) return '公益红娘'
return '运营人员'
})
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
function hasPerm(permission: string) {
return permissions.value.includes(permission)
}
function handleSearch() {
const query = globalSearch.value.trim()
if (!query) {
ElMessage.info('请输入要查找的页面名称')
return
}
const destination = router.getRoutes().find((item) => {
const title = String(item.meta?.title || '')
const required = (item.meta?.permissions as string[] | undefined) || []
return item.path !== '/login' && title.includes(query) && required.every(hasPerm)
})
if (!destination) {
ElMessage.info('没有找到可访问的相关页面')
return
}
router.push(destination.path)
globalSearch.value = ''
}
function showNotifications() {
ElMessage.info(notificationCount.value ? `您有 ${notificationCount.value} 条待处理通知` : '当前没有新的系统通知')
}
function handleLogout() {
authStore.logout()
router.push('/login')
}
</script>
<style scoped>
.main-layout{height:100vh;width:100%}.sidebar{display:flex;flex-direction:column;background:#FFFCFA;border-right:1px solid var(--color-border-light);transition:width .2s ease;overflow:hidden}.brand{height:92px;padding:20px 18px;display:flex;align-items:center;gap:12px;border-bottom:1px solid var(--color-border-light)}.brand.compact{justify-content:center;padding:18px 10px}.brand-symbol{width:42px;height:42px;flex:none;display:grid;place-items:center;border-radius:14px;background:linear-gradient(145deg,var(--color-primary),#EF8A7B);color:#fff;font-size:19px;font-weight:700;box-shadow:0 8px 18px rgba(233,107,107,.2)}.brand-copy{display:flex;flex-direction:column;white-space:nowrap}.brand-copy strong{font-size:17px;letter-spacing:1px}.brand-copy span{margin-top:3px;font-size:11px;color:var(--color-text-tertiary)}.menu-scroll{flex:1}.sidebar-menu{border:0;background:transparent;padding:12px 10px}.sidebar-menu :deep(.el-menu-item){height:44px;margin:3px 0;border-radius:10px;color:var(--color-text-secondary)}.sidebar-menu :deep(.el-menu-item:hover){background:var(--color-primary-lighter);color:var(--color-primary)}.sidebar-menu :deep(.el-menu-item.is-active){background:var(--color-primary-light);color:var(--color-primary);font-weight:600}.sidebar-menu :deep(.el-menu-item-group__title){padding:18px 12px 5px!important;color:#AAA09A;font-size:11px;letter-spacing:1px}.collapse-control{height:52px;margin:10px;border:0;border-top:1px solid var(--color-border-light);background:transparent;color:var(--color-text-tertiary);display:flex;align-items:center;justify-content:center;gap:8px;cursor:pointer}.workspace{min-width:0}.header{height:var(--header-height);padding:0 24px;background:rgba(255,255,255,.94);border-bottom:1px solid var(--color-border-light);display:flex;align-items:center;justify-content:space-between}.context-kicker{font-size:10px;color:var(--color-primary);margin-bottom:4px;letter-spacing:1px}.header-right,.user-info{display:flex;align-items:center}.header-right{gap:24px}.service-state{font-size:12px;color:var(--color-success)}.service-state i{display:inline-block;width:7px;height:7px;margin-right:6px;border-radius:50%;background:var(--color-success)}.user-info{gap:9px;cursor:pointer}.user-info .el-avatar{background:var(--color-primary);color:#fff}.user-info span{display:flex;flex-direction:column}.user-info strong{font-size:13px}.user-info small{font-size:11px;color:var(--color-text-tertiary);margin-top:2px}.main-content{background:var(--color-bg-page);padding:0;overflow:auto}
@media(max-width:900px){.sidebar{width:72px!important}.brand-copy,.collapse-control span,.sidebar-menu :deep(.el-menu-item-group__title){display:none}.brand{justify-content:center}.header{padding:0 16px}.service-state{display:none}}
</style>
.admin-scene {
min-width: 1100px;
min-height: 100vh;
padding: 18px 34px 14px;
overflow: hidden;
background: #fff7f7 url('/brand/admin-danyang-backdrop.jpg') center / cover fixed no-repeat;
}
.scene-header {
display: grid;
max-width: 1840px;
height: 188px;
margin: 0 auto;
grid-template-columns: minmax(640px, 1fr) auto;
grid-template-rows: 112px 76px;
align-items: center;
column-gap: 32px;
}
.scene-brand,
.scene-benefits,
.scene-benefits span,
.scene-brand-copy {
display: flex;
align-items: center;
}
.scene-brand > img {
width: 64px;
height: 64px;
object-fit: contain;
}
.scene-slogan {
display: flex;
padding-right: 24px;
align-items: flex-end;
flex-direction: column;
color: var(--admin-coral);
font-family: KaiTi, STKaiti, serif;
line-height: 1.05;
transform: rotate(-7deg);
}
.scene-slogan span {
font-size: 17px;
}
.scene-slogan strong {
margin-top: 4px;
font-size: 25px;
font-weight: 500;
}
.scene-brand-copy {
min-width: 172px;
margin-left: 10px;
align-items: flex-start;
flex-direction: column;
color: var(--admin-coral);
}
.scene-brand-copy strong {
font-size: 27px;
line-height: 1.15;
letter-spacing: 2px;
}
.scene-brand-copy span {
margin-top: 5px;
font-size: 12px;
letter-spacing: 4px;
}
.scene-title {
min-width: 370px;
margin-left: 28px;
padding-left: 28px;
border-left: 1px solid rgba(244, 95, 104, 0.22);
}
.scene-title h1 {
font-size: clamp(26px, 2.2vw, 38px);
line-height: 1.1;
letter-spacing: 1px;
}
.scene-title p {
margin-top: 9px;
color: #66717a;
}
.scene-benefits {
grid-column: 1 / -1;
align-self: start;
justify-content: flex-start;
gap: clamp(38px, 5vw, 92px);
padding: 4px 0 0 50px;
}
.scene-benefits span {
display: grid;
grid-template-columns: 36px auto;
column-gap: 8px;
color: var(--admin-coral);
}
.scene-benefits .el-icon {
grid-row: 1 / 3;
font-size: 30px;
}
.scene-benefits b {
font-size: 13px;
}
.scene-benefits small {
margin-top: 2px;
color: #7b7f86;
font-size: 10px;
white-space: nowrap;
}
.admin-shell {
width: min(1840px, 100%);
height: calc(100vh - 238px);
min-height: 560px;
margin: 0 auto;
overflow: hidden;
background: var(--admin-shell-bg);
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: var(--admin-shell-radius);
box-shadow: var(--admin-shadow);
backdrop-filter: blur(14px);
}
.admin-sidebar {
display: flex;
flex-direction: column;
overflow: hidden;
color: var(--admin-sidebar-text);
background: linear-gradient(180deg, #202d38 0%, #192733 100%);
border-right: 1px solid rgba(255, 255, 255, 0.06);
transition: width 0.2s ease;
}
.brand {
display: flex;
height: 82px;
padding: 17px 16px;
align-items: center;
gap: 11px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.brand.compact {
justify-content: center;
padding: 16px 8px;
}
.brand-mark {
width: 42px;
height: 42px;
flex: none;
object-fit: contain;
}
.brand-copy {
display: flex;
min-width: 0;
flex-direction: column;
white-space: nowrap;
}
.brand-copy strong {
color: #fff;
font-size: 17px;
letter-spacing: 1px;
}
.brand-copy span {
margin-top: 4px;
color: #b5c0c8;
font-size: 10px;
letter-spacing: 1.5px;
}
.menu-scroll {
flex: 1;
}
.sidebar-menu {
padding: 13px 9px;
border: 0;
background: transparent;
}
.sidebar-menu :deep(.el-menu-item) {
height: 41px;
margin: 2px 0;
color: var(--admin-sidebar-text);
border-radius: 9px;
}
.sidebar-menu :deep(.el-menu-item:hover) {
color: #fff;
background: var(--admin-sidebar-surface);
}
.sidebar-menu :deep(.el-menu-item.is-active) {
color: #fff;
font-weight: 650;
background: linear-gradient(135deg, #f45f68, #ee727b);
box-shadow: 0 8px 18px rgba(244, 95, 104, 0.26);
}
.sidebar-menu :deep(.el-icon) {
font-size: 17px;
}
.sidebar-motto {
display: flex;
margin: 8px 15px 2px;
padding: 14px 12px;
align-items: center;
gap: 11px;
color: #ff969c;
font-family: KaiTi, STKaiti, serif;
font-size: 15px;
line-height: 1.45;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.sidebar-motto .el-icon {
font-size: 31px;
}
.collapse-control {
display: flex;
height: 42px;
margin: 2px 10px 10px;
align-items: center;
justify-content: center;
gap: 7px;
color: var(--admin-sidebar-muted);
cursor: pointer;
background: transparent;
border: 0;
}
.collapse-control:hover {
color: #fff;
}
.workspace {
min-width: 0;
}
.admin-topbar {
display: flex;
height: 64px;
padding: 0 24px;
align-items: center;
justify-content: space-between;
background: rgba(255, 255, 255, 0.96);
border-bottom: 1px solid #edf0f2;
}
.page-context,
.header-tools,
.user-info {
display: flex;
align-items: center;
}
.page-context {
min-width: 180px;
gap: 12px;
}
.mobile-collapse {
display: none;
color: #56616a;
cursor: pointer;
background: transparent;
border: 0;
}
.header-tools {
gap: 14px;
}
.global-search {
width: clamp(220px, 22vw, 320px);
}
.global-search :deep(.el-input__wrapper) {
background: #f6f7f8;
border-radius: 9px;
box-shadow: inset 0 0 0 1px #e8eaed;
}
.notification-button {
position: relative;
display: grid;
width: 36px;
height: 36px;
color: #4f5b65;
cursor: pointer;
background: transparent;
border: 0;
border-radius: 9px;
place-items: center;
}
.notification-button:hover {
background: #f6f7f8;
}
.notification-badge {
position: absolute;
top: 2px;
right: 1px;
min-width: 15px;
height: 15px;
padding: 0 3px;
color: #fff;
font-size: 9px;
line-height: 15px;
text-align: center;
background: var(--admin-coral);
border-radius: 999px;
}
.user-info {
gap: 8px;
cursor: pointer;
}
.user-info .el-avatar {
color: #fff;
background: var(--admin-coral);
}
.user-info strong {
max-width: 92px;
overflow: hidden;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-content {
padding: 0;
overflow: auto;
background: var(--admin-page-bg);
}
.scene-footer {
display: flex;
max-width: 1840px;
height: 32px;
margin: 0 auto;
align-items: flex-end;
gap: 10px;
color: #8d8587;
font-size: 11px;
}
.scene-footer strong {
color: var(--admin-coral);
font-size: 14px;
}
.scene-footer small {
margin-left: auto;
letter-spacing: 4px;
}
@media (max-width: 1450px), (max-height: 820px) {
.admin-scene {
padding: 12px 24px 10px;
}
.scene-header {
display: grid;
height: 120px;
grid-template-columns: minmax(520px, 1fr) auto;
grid-template-rows: 66px 54px;
column-gap: 18px;
}
.scene-slogan {
padding-right: 12px;
}
.scene-slogan span {
font-size: 14px;
}
.scene-slogan strong {
font-size: 20px;
}
.scene-brand > img {
width: 52px;
height: 52px;
}
.scene-brand-copy strong {
font-size: 22px;
}
.scene-title {
min-width: 310px;
margin-left: 18px;
padding-left: 18px;
}
.scene-title h1 {
font-size: 27px;
}
.scene-benefits {
padding: 0 0 0 28px;
gap: clamp(22px, 4vw, 54px);
}
.admin-shell {
height: calc(100vh - 170px);
}
.scene-footer {
height: 28px;
}
}
@media (max-width: 1180px) {
.scene-title {
display: none;
}
.mobile-collapse {
display: inline-grid;
}
.admin-sidebar {
width: 76px !important;
}
.brand-copy,
.collapse-control span,
.sidebar-motto {
display: none;
}
.brand {
justify-content: center;
}
}
@media (max-width: 760px) {
.admin-scene {
min-width: 0;
padding: 0;
}
.scene-header,
.scene-footer,
.global-search,
.user-info strong {
display: none;
}
.admin-shell {
height: 100vh;
min-height: 0;
border-radius: 0;
}
.admin-topbar {
padding: 0 12px;
}
}
</style>
@@ -0,0 +1,202 @@
<template>
<div class="page-container">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>活动签到工作台</span>
<el-tag v-if="stats" type="info" size="small">
已通过 {{ stats.totalApproved }} · 已签到 {{ stats.checkedInCount }} · 未到场
{{ stats.notCheckedInCount }}
</el-tag>
</div>
</template>
<!-- 活动选择:签到必须绑定活动,避免跨活动误签 -->
<el-form :inline="true" class="pick-form">
<el-form-item label="当前活动">
<el-select v-model="activityId" filterable placeholder="选择活动" style="width: 320px"
@change="loadStats">
<el-option v-for="a in activities" :key="a.id" :label="a.title" :value="a.id">
<span>{{ a.title }}</span>
<span class="opt-type">{{ typeText(a.activityType) }}</span>
</el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button :disabled="!activityId" @click="loadStats">刷新统计</el-button>
</el-form-item>
</el-form>
<el-alert v-if="!activityId" type="warning" :closable="false" show-icon
title="请先选择活动:签到码的作用域由活动决定,不可跨活动复用。" />
<el-row :gutter="16" v-else>
<!-- 扫码 / 粘贴签到码 -->
<el-col :span="12">
<el-card shadow="never" class="inner-card">
<template #header><span>扫码签到</span></template>
<el-input v-model="token" type="textarea" :rows="4"
placeholder="粘贴用户小程序上的签到码(一次性,有效期 30 分钟)" />
<div class="btn-row">
<el-button type="primary" :loading="scanning" :disabled="!token"
@click="scan">确认签到</el-button>
<el-button @click="token = ''">清空</el-button>
</div>
</el-card>
</el-col>
<!-- 手工签到 -->
<el-col :span="12">
<el-card shadow="never" class="inner-card">
<template #header><span>手工签到(特殊情形)</span></template>
<el-input v-model="manualUserId" placeholder="输入用户 ID" />
<div class="btn-row">
<el-button type="primary" :loading="saving" :disabled="!manualUserId"
@click="manual">手工签到</el-button>
</div>
<div class="form-hint">
仅可操作被指派给自己的活动;主办/协办组织关系不授予签到权限。
</div>
</el-card>
</el-col>
</el-row>
<!-- 签到结果 -->
<el-card v-if="lastResult" shadow="never" class="inner-card result-card">
<template #header><span>最近一次签到</span></template>
<el-descriptions :column="2" border size="small">
<el-descriptions-item label="用户 ID">{{ lastResult.userId }}</el-descriptions-item>
<el-descriptions-item label="活动 ID">{{ lastResult.activityId }}</el-descriptions-item>
<el-descriptions-item label="本次是否为重复签到">
<el-tag :type="lastResult.alreadyCheckedIn ? 'warning' : 'success'" size="small">
{{ lastResult.alreadyCheckedIn ? '是(已幂等,未重复记录)' : '否' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="签到时间">{{ formatTime(lastResult.checkedInAt) }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-card>
</div>
</template>
<script setup lang="ts">
/**
* U32 活动签到工作台(Phase 15)
*
* 后端端点:
* - POST /api/v1/activities/operator/checkin/scan 扫码签到(一次性 token)
* - POST /api/v1/activities/operator/{id}/checkin/manual 手工签到
* - GET /api/v1/activities/operator/{id}/checkin-stats 签到统计
*/
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { get, post } from '@/api/client'
const activities = ref<any[]>([])
const activityId = ref('')
const token = ref('')
const manualUserId = ref('')
const stats = ref<any>(null)
const lastResult = ref<any>(null)
const scanning = ref(false)
const saving = ref(false)
const TYPE_TEXT: Record<string, string> = {
matchmaking: '公益联谊',
interest_social: '兴趣社交',
relationship_education: '婚恋课堂',
premarital_guidance: '婚前辅导',
marriage_custom_culture: '婚俗文化',
anti_fraud: '婚恋反诈',
}
const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类'
const authHeader = () => ({ Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}` })
const formatTime = (v: any) => {
if (!v) return '-'
const d = new Date(v)
return Number.isNaN(d.getTime()) ? String(v) : d.toLocaleString('zh-CN', { hour12: false })
}
async function loadActivities() {
const res: any = await get('/operator/activities', {}, authHeader())
activities.value = res.data || []
}
async function loadStats() {
stats.value = null
if (!activityId.value) return
try {
const res: any = await get(`/activities/operator/${activityId.value}/checkin-stats`, {}, authHeader())
stats.value = res.data
} catch (e) {
stats.value = null
}
}
async function scan() {
if (!token.value.trim()) return
scanning.value = true
try {
const res: any = await post('/activities/operator/checkin/scan', { token: token.value.trim() }, authHeader())
lastResult.value = res.data
token.value = ''
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(重复扫码已幂等处理)' : '签到成功')
await loadStats()
} finally {
scanning.value = false
}
}
async function manual() {
if (!activityId.value || !manualUserId.value.trim()) return
saving.value = true
try {
const res: any = await post(
`/activities/operator/${activityId.value}/checkin/manual`,
{ userId: manualUserId.value.trim() },
authHeader())
lastResult.value = { ...res.data, userId: manualUserId.value.trim(), activityId: activityId.value }
manualUserId.value = ''
ElMessage.success(res.data?.alreadyCheckedIn ? '该用户已签到(未重复记录)' : '手工签到成功')
await loadStats()
} finally {
saving.value = false
}
}
onMounted(loadActivities)
</script>
<style scoped>
.pick-form {
margin-bottom: 8px;
}
.inner-card {
margin-top: 12px;
}
.btn-row {
margin-top: 10px;
display: flex;
gap: 8px;
}
.form-hint {
margin-top: 8px;
color: #909399;
font-size: 12px;
}
.opt-type {
float: right;
color: #909399;
font-size: 12px;
}
.result-card {
margin-top: 12px;
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>
@@ -0,0 +1,453 @@
<template>
<div class="page-container">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>活动管理</span>
<el-button type="primary" @click="openCreate">新建活动</el-button>
</div>
</template>
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="title" label="活动名称" min-width="180" />
<el-table-column prop="activityType" label="活动类型" width="130">
<template #default="{ row }">
<el-tag size="small">{{ typeText(row.activityType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.status === 'registration_open' ? 'success' : 'info'">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="totalLimit" label="名额" width="80" />
<el-table-column prop="startAt" label="开始时间" width="170">
<template #default="{ row }">{{ formatTime(row.startAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="260">
<template #default="{ row }">
<el-button size="small" type="success" :disabled="row.status !== 'draft'"
@click="publish(row)">发布</el-button>
<el-button size="small" @click="openOrgs(row)">组织/分组</el-button>
<el-button size="small" type="danger"
:disabled="row.status !== 'draft' && row.status !== 'registration_open'"
@click="cancel(row)">取消</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- ==================== 动态表单:字段随活动类型变化 ==================== -->
<el-dialog v-model="formVisible" :title="editId ? '编辑活动' : '新建活动'" width="720px">
<el-form :model="form" label-width="120px">
<el-form-item label="活动类型" required>
<el-select v-model="form.activityType" style="width: 100%">
<el-option v-for="t in ACTIVITY_TYPES" :key="t.code" :label="t.label" :value="t.code" />
</el-select>
<div class="form-hint">{{ typeHint(form.activityType) }}</div>
</el-form-item>
<el-form-item label="活动名称" required>
<el-input v-model="form.title" placeholder="例如:丹阳市青年公益联谊第 12 期" />
</el-form-item>
<el-form-item label="活动简介">
<el-input v-model="form.description" type="textarea" :rows="2" />
</el-form-item>
<el-form-item label="开始时间" required>
<el-date-picker v-model="form.startAt" type="datetime" placeholder="选择开始时间"
value-format="YYYY-MM-DDTHH:mm:ss" style="width: 100%" />
</el-form-item>
<el-form-item label="结束时间" required>
<el-date-picker v-model="form.endAt" type="datetime" placeholder="选择结束时间"
value-format="YYYY-MM-DDTHH:mm:ss" style="width: 100%" />
</el-form-item>
<el-form-item label="地点名称"><el-input v-model="form.locationName" /></el-form-item>
<el-form-item label="详细地址"><el-input v-model="form.locationAddress" /></el-form-item>
<!-- 动态区 1:仅相亲 / 兴趣社交按性别名额 -->
<template v-if="usesGenderQuota">
<el-form-item label="男性名额"><el-input-number v-model="form.maleLimit" :min="0" /></el-form-item>
<el-form-item label="女性名额"><el-input-number v-model="form.femaleLimit" :min="0" /></el-form-item>
</template>
<el-form-item label="总名额"><el-input-number v-model="form.totalLimit" :min="1" /></el-form-item>
<!-- 动态区 2:年龄门槛(选填,留空=不限) -->
<el-form-item label="年龄下限">
<el-input-number v-model="form.minAge" :min="18" :max="80" :value-on-clear="null" placeholder="不限" />
</el-form-item>
<el-form-item label="年龄上限">
<el-input-number v-model="form.maxAge" :min="18" :max="80" :value-on-clear="null" placeholder="不限" />
</el-form-item>
<!-- 动态区 3:仅相亲 / 兴趣社交有互选环节 -->
<el-form-item v-if="form.postEventInterestEnabled !== undefined && hasPostEventInterest" label="活动后互选">
<el-switch v-model="form.postEventInterestEnabled" />
</el-form-item>
<el-form-item label="报名需审核">
<el-switch v-model="form.needReview" />
</el-form-item>
<el-form-item label="目标群体">
<el-input v-model="form.targetGroup" placeholder="例如:丹阳市机关企事业单位青年" />
</el-form-item>
<el-form-item label="主办组织">
<el-select v-model="form.organizerOrgId" clearable style="width: 100%" placeholder="选择主办组织">
<el-option v-for="o in organizations" :key="o.id" :label="o.name" :value="o.id" />
</el-select>
</el-form-item>
<el-form-item label="协作组织">
<div v-for="(p, i) in form.partnerOrganizations" :key="i" class="partner-row">
<el-select v-model="p.organizationId" placeholder="组织" style="width: 220px">
<el-option v-for="o in organizations" :key="o.id" :label="o.name" :value="o.id" />
</el-select>
<el-select v-model="p.role" style="width: 150px">
<el-option v-for="r in ORG_ROLES" :key="r.code" :label="r.label" :value="r.code" />
</el-select>
<el-button size="small" type="danger" @click="removePartner(i)">删除</el-button>
</div>
<el-button size="small" @click="addPartner">+ 添加协作组织</el-button>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="save">保存</el-button>
</template>
</el-dialog>
<!-- ==================== 多组织 / 容量分组 ==================== -->
<el-dialog v-model="orgVisible" title="活动组织与容量分组" width="640px">
<h4>组织关系</h4>
<el-table :data="orgs" border size="small">
<el-table-column prop="organizationId" label="组织ID" min-width="160" />
<el-table-column prop="name" label="名称" min-width="140" />
<el-table-column prop="role" label="角色" width="120">
<template #default="{ row }">{{ roleText(row.role) }}</template>
</el-table-column>
</el-table>
<div class="inline-form">
<el-select v-model="newOrg.organizationId" placeholder="组织" style="width: 200px">
<el-option v-for="o in organizations" :key="o.id" :label="o.name" :value="o.id" />
</el-select>
<el-select v-model="newOrg.role" style="width: 140px">
<el-option v-for="r in ORG_ROLES" :key="r.code" :label="r.label" :value="r.code" />
</el-select>
<el-button size="small" type="primary" @click="addOrg">添加组织</el-button>
</div>
<h4 style="margin-top: 18px">容量分组</h4>
<el-table :data="groups" border size="small">
<el-table-column prop="groupCode" label="分组代码" min-width="140" />
<el-table-column prop="capacity" label="容量" width="80" />
<el-table-column prop="approvedCount" label="已通过" width="80" />
<el-table-column prop="ruleDefinition" label="规则" min-width="180" />
</el-table>
<div class="inline-form">
<el-input v-model="newGroup.groupCode" placeholder="如 male_30_40" style="width: 180px" />
<el-input-number v-model="newGroup.capacity" :min="1" style="width: 120px" />
<el-input v-model="newGroup.ruleDefinition" placeholder='如 {"gender":"male"}' style="width: 200px" />
<el-button size="small" type="primary" @click="addGroup">添加分组</el-button>
</div>
<template #footer>
<el-button @click="orgVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
/**
* 活动管理(V1.1 Phase 14)
*
* 关键点:**动态表单** —— 字段随 activityType 变化:
* - matchmaking / interest_social:显示男女名额 + 活动后互选(有互选环节)
* - relationship_education / marriage_custom_culture / anti_fraud:公益科普,
* 不显示性别名额与互选,资格门槛也不同(见 typeHint)
* - premarital_guidance:需实名,无互选
*
* 类型与角色枚举必须与后端生产 CHECK 白名单一致:
* ActivityConstraintGuardTest 会解析 db/migration/V*.sql 校验后端常量,
* 本页的下拉项同样不得偏离该白名单。
*/
import { ref, computed, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { get, post, put } from '@/api/client'
const ACTIVITY_TYPES = [
{ code: 'matchmaking', label: '公益联谊' },
{ code: 'interest_social', label: '兴趣社交' },
{ code: 'relationship_education', label: '婚恋课堂' },
{ code: 'premarital_guidance', label: '婚前辅导' },
{ code: 'marriage_custom_culture', label: '婚俗文化' },
{ code: 'anti_fraud', label: '反诈宣传' },
]
const ORG_ROLES = [
{ code: 'host', label: '主办' },
{ code: 'organizer', label: '承办' },
{ code: 'co_organizer', label: '协办' },
{ code: 'partner', label: '合作方' },
{ code: 'venue_partner', label: '场地合作方' },
]
const TYPE_TEXT: Record<string, string> = Object.fromEntries(ACTIVITY_TYPES.map((t) => [t.code, t.label]))
const ROLE_TEXT: Record<string, string> = Object.fromEntries(ORG_ROLES.map((r) => [r.code, r.label]))
/** 资格策略提示:与后端 ActivityService#resolveEligibility 保持一致 */
const TYPE_HINT: Record<string, string> = {
matchmaking: '需实名认证 + 丹阳本地资格核验;按男女名额分别扣减;活动后开放互选',
interest_social: '按男女名额分别扣减;活动后开放互选',
premarital_guidance: '需实名认证;只按总名额',
relationship_education: '公益科普,不设实名与本地资格门槛;只按总名额',
marriage_custom_culture: '公益科普,不设实名与本地资格门槛;只按总名额',
anti_fraud: '公益科普,不设实名与本地资格门槛;只按总名额',
}
const POST_EVENT_TYPES = ['matchmaking', 'interest_social']
const loading = ref(false)
const saving = ref(false)
const tableData = ref<any[]>([])
const organizations = ref<any[]>([])
const formVisible = ref(false)
const editId = ref('')
const form = ref(emptyForm())
const orgVisible = ref(false)
const currentId = ref('')
const orgs = ref<any[]>([])
const groups = ref<any[]>([])
const newOrg = ref({ organizationId: '', role: 'partner' })
const newGroup = ref({ groupCode: '', capacity: 20, ruleDefinition: '' })
const usesGenderQuota = computed(() => ['matchmaking', 'interest_social'].includes(form.value.activityType))
const hasPostEventInterest = computed(() => POST_EVENT_TYPES.includes(form.value.activityType))
function emptyForm() {
return {
activityType: 'matchmaking',
title: '',
description: '',
startAt: '',
endAt: '',
locationName: '',
locationAddress: '',
totalLimit: 50,
maleLimit: 25,
femaleLimit: 25,
minAge: undefined as number | undefined,
maxAge: undefined as number | undefined,
needReview: true,
postEventInterestEnabled: false,
targetGroup: '',
organizerOrgId: '',
partnerOrganizations: [] as Array<{ organizationId: string; role: string }>,
}
}
function authHeader() {
return { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
}
async function loadData() {
loading.value = true
try {
const res: any = await get('/operator/activities', {}, authHeader())
tableData.value = res.data || []
} catch (e) {
console.error(e)
} finally {
loading.value = false
}
}
async function loadOrganizations() {
try {
const res: any = await get('/operator/organizations', {}, authHeader())
organizations.value = res.data || []
} catch (e) {
console.error(e)
}
}
function openCreate() {
editId.value = ''
form.value = emptyForm()
formVisible.value = true
}
function addPartner() {
form.value.partnerOrganizations.push({ organizationId: '', role: 'partner' })
}
function removePartner(i: number) {
form.value.partnerOrganizations.splice(i, 1)
}
async function save() {
if (!form.value.title) {
ElMessage.warning('请填写活动名称')
return
}
if (!form.value.startAt || !form.value.endAt) {
ElMessage.warning('请填写开始与结束时间')
return
}
saving.value = true
try {
const body: Record<string, unknown> = { ...form.value }
// 非性别名额类型不下发男女名额,避免后端误按性别限制
if (!usesGenderQuota.value) {
delete body.maleLimit
delete body.femaleLimit
}
await post('/operator/activities', body, authHeader())
ElMessage.success('活动已创建')
formVisible.value = false
loadData()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '创建失败')
} finally {
saving.value = false
}
}
async function publish(row: any) {
try {
await put(`/operator/activities/${row.id}/publish`, {}, authHeader())
ElMessage.success('已发布')
loadData()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '发布失败')
}
}
async function cancel(row: any) {
try {
await put(`/operator/activities/${row.id}/cancel`, {}, authHeader())
ElMessage.success('已取消')
loadData()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '取消失败')
}
}
async function openOrgs(row: any) {
currentId.value = row.id
orgVisible.value = true
await Promise.all([loadOrgs(), loadGroups()])
}
async function loadOrgs() {
try {
const res: any = await get(`/operator/activities/${currentId.value}/organizations`, {}, authHeader())
orgs.value = res.data || []
} catch (e) {
console.error(e)
}
}
async function loadGroups() {
try {
const res: any = await get(`/operator/activities/${currentId.value}/capacity-groups`, {}, authHeader())
groups.value = res.data || []
} catch (e) {
console.error(e)
}
}
async function addOrg() {
if (!newOrg.value.organizationId) {
ElMessage.warning('请选择组织')
return
}
try {
await post(`/operator/activities/${currentId.value}/organizations`, newOrg.value, authHeader())
ElMessage.success('已添加')
await loadOrgs()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '添加失败')
}
}
async function addGroup() {
if (!newGroup.value.groupCode) {
ElMessage.warning('请填写分组代码')
return
}
try {
await post(`/operator/activities/${currentId.value}/capacity-groups`, newGroup.value, authHeader())
ElMessage.success('已添加')
await loadGroups()
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || '添加失败')
}
}
function typeText(t?: string) {
return (t && TYPE_TEXT[t]) || t || '-'
}
function roleText(r?: string) {
return (r && ROLE_TEXT[r]) || r || '-'
}
function typeHint(t?: string) {
return (t && TYPE_HINT[t]) || ''
}
function statusText(s?: string) {
const map: Record<string, string> = {
draft: '草稿',
registration_open: '报名中',
registration_closed: '报名结束',
ongoing: '进行中',
ended: '已结束',
cancelled: '已取消',
}
return (s && map[s]) || s || '-'
}
function formatTime(t?: string) {
return t ? new Date(t).toLocaleString('zh-CN') : '-'
}
onMounted(() => {
loadData()
loadOrganizations()
})
</script>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.form-hint {
margin-top: 4px;
color: #8a90a2;
font-size: 12px;
line-height: 1.5;
}
.partner-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.inline-form {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
}
</style>
@@ -32,6 +32,10 @@
<el-descriptions-item label="审核通过">{{ funnelStats.profileApproved }}</el-descriptions-item>
<el-descriptions-item label="7日活跃">{{ funnelStats.activeUsers }}</el-descriptions-item>
</el-descriptions>
<div class="metric-note">
说明:「实名用户」指身份证核验通过的人数(user_identities.verification_status = verified),
与「审核通过」(资料审核)是两个不同口径,两者互不包含。
</div>
</el-card>
<!-- Match漏斗 -->
@@ -46,6 +50,39 @@
</div>
</el-col>
</el-row>
<!-- 按来源分离 -->
<el-divider content-position="left">Match 按来源分离</el-divider>
<el-row :gutter="16">
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ matchFunnelStats.matchesBySource?.userLike ?? 0 }}</div>
<div class="mini-label">用户互相 Like</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ matchFunnelStats.matchesBySource?.activityMutual ?? 0 }}</div>
<div class="mini-label">活动互选</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ matchFunnelStats.matchesBySource?.matchmaker ?? 0 }}</div>
<div class="mini-label">红娘促成</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ matchFunnelStats.matchmakerRecommendations ?? 0 }}</div>
<div class="mini-label">红娘发出推荐</div>
</div>
</el-col>
</el-row>
<div class="metric-note">
说明:「开启聊天」只统计存在真人消息的会话(系统通知不计);Match 按来源分离统计,
不同来源的量不可互相替代。2 个单向 Like 才形成 1 个 Match。
</div>
</el-card>
<!-- 安全统计 -->
@@ -58,6 +95,109 @@
</el-col>
</el-row>
<!-- 公益服务数据 -->
<el-card shadow="never" style="margin-bottom:16px">
<template #header>
<span>公益服务数据</span>
<el-tag v-if="v11 && v11.effectiveConnections?.effectiveMatchesProvisional"
type="warning" size="small" style="margin-left:8px">含未确认口径</el-tag>
</template>
<el-row :gutter="16" style="margin-bottom:12px">
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.uniqueServiceUsers?.totalUniqueServiceUsers ?? 0 }}</div>
<div class="mini-label">服务用户数(去重)</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.uniqueServiceUsers?.totalServiceVisits ?? 0 }}</div>
<div class="mini-label">公益服务人次(不去重)</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.serviceAppointments?.total ?? 0 }}</div>
<div class="mini-label">服务预约(不含取消)</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.counseling?.total ?? 0 }}</div>
<div class="mini-label">婚恋咨询受理</div>
</div>
</el-col>
</el-row>
<!-- 活动漏斗:互选 vs 成功 -->
<el-divider content-position="left">活动互选与成功(两个不同指标)</el-divider>
<el-row :gutter="16" style="margin-bottom:12px">
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.activityFunnel?.mutualBothConfirmed ?? 0 }}</div>
<div class="mini-label">互选成立对数</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.activityFunnel?.mutualMatches ?? 0 }}</div>
<div class="mini-label">互选后建立 Match</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.activityFunnel?.mutualSuccessful ?? 0 }}</div>
<div class="mini-label">互选后成功牵手</div>
</div>
</el-col>
<el-col :span="6">
<div class="mini-stat">
<div class="mini-value">{{ v11?.activityFunnel?.checkedInCount ?? 0 }}</div>
<div class="mini-label">活动实际签到</div>
</div>
</el-col>
</el-row>
<!-- 口径警示 -->
<el-alert
v-if="v11?.effectiveConnections?.effectiveMatchesProvisional"
type="warning" :closable="false" show-icon style="margin-bottom:12px"
title="「有效交友匹配」口径尚未确认,仅供内部参考,禁止对外宣传">
<div>{{ v11?.effectiveConnections?.provisionalNote }}</div>
</el-alert>
<el-alert
v-if="v11?.banners && !v11.banners.impressionsAvailable"
type="info" :closable="false" show-icon style="margin-bottom:12px"
title="Banner 曝光量 / 点击量当前不可用(未埋点)">
<div>{{ v11?.banners?.unavailableReason }}</div>
</el-alert>
<el-descriptions :column="4" border size="small">
<el-descriptions-item label="Banner 总数">{{ v11?.banners?.totalBanners ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="生效中 Banner">{{ v11?.banners?.activeBanners ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="曝光量">
<span v-if="v11?.banners?.impressionsAvailable">{{ v11.banners.impressions }}</span>
<span v-else class="unavailable">未埋点</span>
</el-descriptions-item>
<el-descriptions-item label="点击量">
<span v-if="v11?.banners?.clicksAvailable">{{ v11.banners.clicks }}</span>
<span v-else class="unavailable">未埋点</span>
</el-descriptions-item>
<el-descriptions-item label="已发布内容">{{ v11?.content?.totalPublishedArticles ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="内容阅读量">{{ v11?.content?.totalViews ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="专项项目数">{{ v11?.servicePrograms?.totalPrograms ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="项目在册成员">{{ v11?.servicePrograms?.totalMembers ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="本地资格已核验">{{ v11?.localEligibility?.verified ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="婚前辅导受理">{{ v11?.premaritalGuidance?.totalRequests ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="安全处理中举报">{{ v11?.counseling?.pending ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="有效交友匹配(未确认口径)">
<el-tag type="warning" size="small">{{ v11?.effectiveConnections?.successConnections ?? 0 }}</el-tag>
</el-descriptions-item>
</el-descriptions>
</el-card>
<!-- 每日汇总表 -->
<el-card shadow="never" v-if="dailySummary.length > 0">
<template #header><span>每日汇总(最近{{ dailySummary.length }}天)</span></template>
@@ -84,13 +224,14 @@ const dateRange = ref<[string, string]>([
const funnelStats = ref<any>({})
const matchFunnelStats = ref<any>({})
const safetyStats = ref<any>({})
const v11 = ref<any>({})
const dailySummary = ref<any[]>([])
const funnelStages = [
{ key: 'registered', label: '累计注册', value: 0, rate: null, color: '#409EFF' },
{ key: 'verified', label: '已实名', value: 0, rate: null, color: '#67C23A' },
{ key: 'approved', label: '审核通过', value: 0, rate: null, color: '#E59A32' },
{ key: 'active', label: '7日活跃', value: 0, rate: null, color: '#E96B6B' }
{ key: 'registered', label: '累计注册', value: 0, rate: null as string | null, color: '#409EFF' },
{ key: 'verified', label: '已实名', value: 0, rate: null as string | null, color: '#67C23A' },
{ key: 'approved', label: '审核通过', value: 0, rate: null as string | null, color: '#E59A32' },
{ key: 'active', label: '7日活跃', value: 0, rate: null as string | null, color: '#E96B6B' }
]
const matchStages = [
@@ -113,21 +254,27 @@ async function loadFunnel() {
try {
const token = localStorage.getItem('accessToken')
if (dateRange.value[0] && dateRange.value[1]) {
const [userRes, matchRes, safetyRes]: any[] = await Promise.all([
const [userRes, matchRes, safetyRes, v11Res]: any[] = await Promise.all([
get('/operator/analytics/user-funnel', {
since: dateRange.value[0], until: dateRange.value[1]
}, { Authorization: `Bearer ${token}` }),
get('/operator/analytics/match-funnel', {
since: dateRange.value[0], until: dateRange.value[1]
}, { Authorization: `Bearer ${token}` }),
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${token}` })
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${token}` }),
// Phase 20:原先只接了 4/15 个端点,V1.1 公益服务的专项统计
// (服务用户数/人次、活动互选 vs 成功、Banner、专项项目)
// 后端已实现却无人调用 —— 本行起补齐。
get('/operator/analytics/v11-dashboard', {}, { Authorization: `Bearer ${token}` })
])
const userData = userRes.data || {}
const matchData = matchRes.data || {}
const safetyData = safetyRes.data || {}
const v11Data = v11Res.data || {}
funnelStats.value = userData
matchFunnelStats.value = matchData
safetyStats.value = safetyData
v11.value = v11Data
// 更新漏斗显示
funnelStages[0].value = userData.totalRegistered || 0
@@ -188,4 +335,15 @@ onMounted(loadData)
.stat-card { text-align: center; }
.stat-value { font-size: 32px; font-weight: bold; }
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
.mini-stat {
border: 1px solid #ebeef5;
border-radius: 8px;
padding: 14px;
text-align: center;
background: #fafafa;
}
.mini-value { font-size: 22px; font-weight: bold; color: #303133; }
.mini-label { font-size: 12px; color: #909399; margin-top: 6px; }
.metric-note { font-size: 12px; color: #909399; margin-top: 12px; line-height: 1.6; }
.unavailable { color: #c0c4cc; font-style: italic; }
</style>
@@ -32,7 +32,7 @@
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{row}">
<el-button v-if="row.status==='pending'" type="primary" size="small" @click="openDetail(row.id)">处理</el-button>
<el-button v-if="row.status==='pending'||row.status==='escalated'" type="primary" size="small" @click="openDetail(row.id)">处理</el-button>
<el-button size="small" @click="openDetail(row.id)">查看</el-button>
</template>
</el-table-column>
@@ -59,7 +59,7 @@
</el-descriptions-item>
</el-descriptions>
<template v-if="detail.status==='pending'||detail.status==='reviewing'">
<template v-if="detail.status==='pending'||detail.status==='reviewing'||detail.status==='escalated'">
<h4 style="margin:20px 0 12px;color:#E96B6B">审核决策</h4>
<el-form label-width="100px">
<el-form-item label="处理结果">
@@ -7,31 +7,42 @@
<el-button type="primary" @click="openCreate">新建 Banner</el-button>
</div>
</template>
<el-alert
type="info"
:closable="false"
show-icon
title="Banner 只允许跳转站内。目标值填写站内标识(如活动ID、文章ID、页面标识 home),填写 http(s):// 等外链会被拒绝。"
style="margin-bottom: 12px"
/>
<el-table :data="banners" v-loading="loading" stripe>
<el-table-column prop="title" label="标题" width="150" />
<el-table-column prop="targetType" label="跳转类型" width="120">
<el-table-column prop="buttonText" label="按钮文案" width="110">
<template #default="{ row }">{{ row.buttonText || '-' }}</template>
</el-table-column>
<el-table-column prop="targetType" label="跳转类型" width="110">
<template #default="{ row }">
<el-tag size="small">{{ bannerTargetTypeLabel(row.targetType) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="audienceScope" label="展示范围" width="120">
<el-table-column prop="audienceScope" label="展示范围" width="110">
<template #default="{ row }">
<el-tag size="small" :type="audienceScopeTag(row.audienceScope)">{{ audienceScopeLabel(row.audienceScope) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<el-table-column prop="status" label="状态" width="95">
<template #default="{ row }">
<el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序" width="80" />
<el-table-column prop="sortOrder" label="排序" width="70" />
<el-table-column label="时间范围" width="200">
<template #default="{ row }">
<span>{{ formatDateTime(row.startAt) }} ~ {{ formatDateTime(row.endAt) }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<el-table-column label="操作" width="230">
<template #default="{ row }">
<el-button size="small" @click="openPreview(row)">预览</el-button>
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="warning" @click="toggleStatus(row)">
{{ row.status === 'active' ? '下架' : '上架' }}
@@ -44,8 +55,9 @@
<!-- 新建/编辑 Dialog -->
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑 Banner' : '新建 Banner'" width="600px">
<el-form :model="form" label-width="100px">
<el-form-item label="标题"><el-input v-model="form.title" /></el-form-item>
<el-form-item label="副标题"><el-input v-model="form.subtitle" /></el-form-item>
<el-form-item label="标题" required><el-input v-model="form.title" maxlength="200" show-word-limit /></el-form-item>
<el-form-item label="副标题"><el-input v-model="form.subtitle" maxlength="300" show-word-limit /></el-form-item>
<el-form-item label="按钮文案"><el-input v-model="form.buttonText" maxlength="64" show-word-limit placeholder="如:立即体验(留空则不显示按钮)" /></el-form-item>
<el-form-item label="跳转类型">
<el-select v-model="form.targetType">
<el-option label="公益活动" value="activity" />
@@ -58,7 +70,10 @@
<el-option label="无" value="none" />
</el-select>
</el-form-item>
<el-form-item label="目标值"><el-input v-model="form.targetValue" placeholder="活动ID/文章ID等" /></el-form-item>
<el-form-item label="目标值" :required="needsExplicitTarget">
<el-input v-model="form.targetValue" maxlength="200" :placeholder="targetValuePlaceholder" />
<div class="field-hint">仅填站内标识,禁止 http(s):// 等站外地址</div>
</el-form-item>
<el-form-item label="展示范围">
<el-select v-model="form.audienceScope">
<el-option label="全部用户" value="all" />
@@ -68,40 +83,118 @@
<el-option label="成功牵手" value="successful" />
</el-select>
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="form.sortOrder" :min="0" /></el-form-item>
<el-form-item label="展示开始"><el-date-picker v-model="form.startAt" type="datetime" /></el-form-item>
<el-form-item label="展示结束"><el-date-picker v-model="form.endAt" type="datetime" /></el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="草稿" value="draft" />
<el-option label="生效中" value="active" />
<el-option label="已下架" value="inactive" />
</el-select>
<div class="field-hint">「定时」「已过期」由排期自动流转,无需手工选择</div>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="form.sortOrder" :min="0" />
<div class="field-hint">数值越小越靠前</div>
</el-form-item>
<el-form-item label="展示开始"><el-date-picker v-model="form.startAt" type="datetime" placeholder="留空表示立即生效" /></el-form-item>
<el-form-item label="展示结束"><el-date-picker v-model="form.endAt" type="datetime" placeholder="留空表示长期有效" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button @click="previewFromForm">预览</el-button>
<el-button type="primary" @click="saveBanner">保存</el-button>
</template>
</el-dialog>
<!-- 预览 Dialog:模拟小程序首页 Banner 卡片 -->
<el-dialog v-model="previewVisible" title="Banner 预览(模拟小程序首页)" width="420px">
<div class="preview-stage">
<div class="preview-card">
<div class="preview-tag">{{ audienceScopeLabel(previewBanner.audienceScope) }}</div>
<div class="preview-title">{{ previewBanner.title || '(未填写标题)' }}</div>
<div class="preview-subtitle">{{ previewBanner.subtitle || '' }}</div>
<div v-if="previewBanner.buttonText" class="preview-button">{{ previewBanner.buttonText }}</div>
</div>
</div>
<el-descriptions :column="1" border size="small" style="margin-top: 16px">
<el-descriptions-item label="跳转去向">{{ previewTargetText }}</el-descriptions-item>
<el-descriptions-item label="展示状态">{{ statusLabel(previewBanner.status) }}</el-descriptions-item>
<el-descriptions-item label="展示时间">
{{ formatDateTime(previewBanner.startAt) }} ~ {{ formatDateTime(previewBanner.endAt) }}
</el-descriptions-item>
<el-descriptions-item label="排序">{{ previewBanner.sortOrder ?? 0 }}</el-descriptions-item>
</el-descriptions>
<template #footer>
<el-button type="primary" @click="previewVisible = false">关闭</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
import { ElMessage } from 'element-plus'
const loading = ref(false)
const banners = ref<any[]>([])
const dialogVisible = ref(false)
const previewVisible = ref(false)
const editingId = ref<string | null>(null)
const form = ref({ title: '', subtitle: '', targetType: 'none', targetValue: '', audienceScope: 'all', sortOrder: 0, startAt: '', endAt: '' })
const previewBanner = ref<any>({})
const EMPTY_FORM = {
title: '', subtitle: '', buttonText: '', targetType: 'none', targetValue: '',
audienceScope: 'all', status: 'active', sortOrder: 0, startAt: '', endAt: '',
}
const form = ref({ ...EMPTY_FORM })
const TARGET_TYPE_LABEL: Record<string, string> = { activity: '活动', matchmaker: '红娘', counseling: '咨询', premarital: '婚前辅导', service_location: '服务站', article: '文章', internal_page: '内部页', none: '无' }
const TARGET_TYPE_DEST: Record<string, string> = {
activity: '公益活动列表', matchmaker: '公益红娘', counseling: '婚恋咨询',
premarital: '婚前辅导', service_location: '善缘服务站',
article: '文章详情', internal_page: '站内页面', none: '无跳转(仅展示)',
}
const NEEDS_EXPLICIT_TARGET = ['internal_page', 'article']
const AUDIENCE_SCOPE_LABEL: Record<string, string> = { all: '全部', onboarding: '未注册', searching: '寻找中', getting_to_know: '初步了解', successful: '成功牵手' }
const STATUS_LABEL: Record<string, string> = { draft: '草稿', scheduled: '定时', active: '生效中', inactive: '已下架', expired: '已过期' }
const STATUS_TAG: Record<string, string> = { draft: 'info', scheduled: 'warning', active: 'success', inactive: '', expired: 'danger' }
/** 与后端 assertInternalTarget 保持一致的禁外链前缀 */
const EXTERNAL_LINK_PREFIXES = ['http://', 'https://', '//', 'javascript:', 'data:text/html']
const needsExplicitTarget = computed(() => NEEDS_EXPLICIT_TARGET.includes(form.value.targetType))
const targetValuePlaceholder = computed(() => (form.value.targetType === 'article'
? '填写文章ID'
: form.value.targetType === 'internal_page' ? '填写站内页面标识,如 home' : '可留空,默认进入对应列表页'))
function bannerTargetTypeLabel(t: string) { return TARGET_TYPE_LABEL[t] || t }
function audienceScopeLabel(s: string) { return AUDIENCE_SCOPE_LABEL[s] || s }
function audienceScopeTag(s: string) { return s === 'all' ? 'success' : 'info' }
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
function statusLabel(s: string) { return STATUS_LABEL[s] || s || '-' }
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
function isExternalLink(value: string) {
const normalized = (value || '').trim().toLowerCase()
return EXTERNAL_LINK_PREFIXES.some((prefix) => normalized.startsWith(prefix))
}
function previewTargetText(banner: any) {
const dest = TARGET_TYPE_DEST[banner.targetType] || banner.targetType || '无'
if (!banner.targetValue) return dest
return `${dest} · ${banner.targetValue}`
}
function assignForm(source: any) {
form.value = {
title: source.title || '',
subtitle: source.subtitle || '',
buttonText: source.buttonText || '',
targetType: source.targetType || 'none',
targetValue: source.targetValue || '',
audienceScope: source.audienceScope || 'all',
status: source.status || 'active',
sortOrder: source.sortOrder ?? 0,
startAt: source.startAt || '',
endAt: source.endAt || '',
}
}
async function loadBanners() {
loading.value = true
@@ -111,10 +204,60 @@ async function loadBanners() {
} catch (e) { console.error(e) } finally { loading.value = false }
}
function openCreate() { editingId.value = null; Object.assign(form.value, { title: '', subtitle: '', targetType: 'none', targetValue: '', audienceScope: 'all', sortOrder: 0, startAt: '', endAt: '' }); dialogVisible.value = true }
function openEdit(row: any) { editingId.value = row.id; Object.assign(form.value, { title: row.title, subtitle: row.subtitle, targetType: row.targetType, targetValue: row.targetValue, audienceScope: row.audienceScope, sortOrder: row.sortOrder, startAt: row.startAt, endAt: row.endAt }); dialogVisible.value = true }
function openCreate() {
editingId.value = null
assignForm({})
dialogVisible.value = true
}
function openEdit(row: any) {
editingId.value = row.id
assignForm(row)
dialogVisible.value = true
}
/**
* 与后端同源的校验。前端拦一道是为了让运营即时看到原因,
* 不作为唯一防线(后端 HomeBannerOperatorController.validate 仍是权威)。
* @returns 校验通过返回 true
*/
function validateForm(): boolean {
const value = form.value
if (!value.title.trim()) { ElMessage.error('请填写 Banner 标题'); return false }
if (isExternalLink(value.targetValue)) {
ElMessage.error('Banner 不允许跳转站外地址,请填写站内标识')
return false
}
if (NEEDS_EXPLICIT_TARGET.includes(value.targetType) && !value.targetValue.trim()) {
ElMessage.error(`跳转类型为「${bannerTargetTypeLabel(value.targetType)}」时必须填写站内目标值`)
return false
}
if (value.startAt && value.endAt && new Date(value.startAt) > new Date(value.endAt)) {
ElMessage.error('展示开始时间不能晚于结束时间')
return false
}
if (value.sortOrder != null && value.sortOrder < 0) {
ElMessage.error('排序值不能为负数')
return false
}
return true
}
function openPreview(row: any) {
if (isExternalLink(row.targetValue)) {
ElMessage.error('该 Banner 目标值疑似站外地址,已阻止预览')
return
}
previewBanner.value = row
previewVisible.value = true
}
function previewFromForm() {
if (!validateForm()) return
previewBanner.value = { ...form.value }
previewVisible.value = true
}
async function saveBanner() {
if (!validateForm()) return
try {
if (editingId.value) {
await put(`/operator/banners/${editingId.value}`, form.value)
@@ -125,15 +268,32 @@ async function saveBanner() {
}
dialogVisible.value = false
loadBanners()
} catch (e) { ElMessage.error('操作失败') }
} catch (e) {
// 错误提示由 api/client 的响应拦截器统一弹出,此处不重复 toast
console.error(e)
}
}
async function toggleStatus(row: any) {
try {
const newStatus = row.status === 'active' ? 'inactive' : 'active'
await put(`/operator/banners/${row.id}`, { ...row, status: newStatus })
// 只回传可编辑字段:id/createdBy/updatedAt 等由服务端维护,避免把服务端字段带回写坏
await put(`/operator/banners/${row.id}`, {
title: row.title,
subtitle: row.subtitle,
buttonText: row.buttonText,
targetType: row.targetType,
targetValue: row.targetValue,
audienceScope: row.audienceScope,
status: newStatus,
sortOrder: row.sortOrder,
startAt: row.startAt,
endAt: row.endAt,
})
await loadBanners()
} catch (e) { ElMessage.error('操作失败') }
} catch (e) {
console.error(e)
}
}
onMounted(loadBanners)
@@ -141,4 +301,22 @@ onMounted(loadBanners)
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.field-hint { width: 100%; font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; }
.preview-stage { padding: 20px; background: #f5f6f8; border-radius: 12px; }
.preview-card {
padding: 20px 18px;
border-radius: 16px;
background: linear-gradient(135deg, #ffe9df, #fff7f2);
box-shadow: 0 6px 16px rgba(200, 120, 100, 0.16);
}
.preview-tag {
display: inline-block; padding: 2px 10px; border-radius: 999px;
background: rgba(255, 255, 255, 0.85); color: #c8553d; font-size: 12px;
}
.preview-title { margin-top: 10px; font-size: 17px; font-weight: 700; color: #2c2c34; }
.preview-subtitle { margin-top: 6px; font-size: 12px; line-height: 1.6; color: #6b6b78; }
.preview-button {
display: inline-block; margin-top: 14px; padding: 5px 16px; border-radius: 999px;
background: linear-gradient(135deg, #ff7d6e, #ff5c82); color: #fff; font-size: 12px; font-weight: 600;
}
</style>
@@ -3,18 +3,38 @@
<el-tabs v-model="activeTab">
<el-tab-pane label="内容分类" name="categories">
<el-card>
<template #header><div class="card-header"><span>内容分类</span><el-button type="primary" size="small" @click="openCategoryDialog">新建分类</el-button></div></template>
<el-table :data="categories" stripe>
<template #header>
<div class="card-header">
<span>内容分类</span>
<el-button type="primary" size="small" @click="openCategoryDialog">新建分类</el-button>
</div>
</template>
<el-alert
type="info"
:closable="false"
show-icon
title="分类不做删除:文章通过分类ID引用它,删除会让历史文章分类悬空。不再使用的分类请「停用」,停用后小程序端不再下发,但后台仍可见并可重新启用。"
style="margin-bottom: 12px"
/>
<el-table :data="categories" stripe v-loading="categoryLoading">
<el-table-column prop="name" label="分类名称" />
<el-table-column prop="code" label="编码" width="150" />
<el-table-column prop="sort_order" label="排序" width="80" />
<el-table-column prop="sortOrder" label="排序" width="80" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':''">{{ row.status }}</el-tag></template>
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ categoryStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="150">
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button size="small" @click="editCategory(row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteCategory(row)">删除</el-button>
<el-button
size="small"
:type="row.status === 'active' ? 'warning' : 'success'"
@click="toggleCategoryStatus(row)"
>
{{ row.status === 'active' ? '停用' : '启用' }}
</el-button>
</template>
</el-table-column>
</el-table>
@@ -22,21 +42,26 @@
</el-tab-pane>
<el-tab-pane label="文章内容" name="articles">
<el-card>
<template #header><div class="card-header"><span>文章内容</span><el-button type="primary" size="small" @click="openArticleDialog">新建文章</el-button></div></template>
<template #header>
<div class="card-header">
<span>文章内容</span>
<el-button type="primary" size="small" @click="openArticleDialog">新建文章</el-button>
</div>
</template>
<el-table :data="articles" stripe v-loading="loading">
<el-table-column prop="title" label="标题" />
<el-table-column label="分类" width="120">
<template #default="{ row }">{{ categoryLabel(row.category_id) }}</template>
<template #default="{ row }">{{ categoryLabel(row.categoryId) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="view_count" label="阅读量" width="80" />
<el-table-column prop="viewCount" label="阅读量" width="80" />
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button size="small" @click="editArticle(row)">编辑</el-button>
<el-button v-if="row.status==='draft'" size="small" type="success" @click="publishArticle(row)">发布</el-button>
<el-button v-if="row.status==='published'" size="small" type="warning" @click="offlineArticle(row)">下架</el-button>
<el-button v-if="row.status !== 'published'" size="small" type="success" @click="publishArticle(row)">发布</el-button>
<el-button v-if="row.status === 'published'" size="small" type="warning" @click="offlineArticle(row)">下架</el-button>
</template>
</el-table-column>
</el-table>
@@ -47,30 +72,35 @@
<!-- 分类Dialog -->
<el-dialog v-model="categoryDialogVisible" :title="editingCategoryId ? '编辑分类' : '新建分类'" width="500px">
<el-form :model="categoryForm" label-width="80px">
<el-form-item label="名称"><el-input v-model="categoryForm.name" /></el-form-item>
<el-form-item label="编码"><el-input v-model="categoryForm.code" /></el-form-item>
<el-form-item label="名称" required><el-input v-model="categoryForm.name" maxlength="128" show-word-limit /></el-form-item>
<el-form-item label="编码" required>
<el-input v-model="categoryForm.code" maxlength="64" show-word-limit placeholder="唯一标识,如 classroom" />
<div class="field-hint">编码全局唯一,重复会被拒绝</div>
</el-form-item>
<el-form-item label="排序"><el-input-number v-model="categoryForm.sortOrder" :min="0" /></el-form-item>
<el-form-item label="说明"><el-input v-model="categoryForm.description" type="textarea" :rows="2" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="categoryDialogVisible=false">取消</el-button>
<el-button @click="categoryDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveCategory">保存</el-button>
</template>
</el-dialog>
<!-- 文章Dialog -->
<el-dialog v-model="articleDialogVisible" :title="editingArticleId ? '编辑文章' : '新建文章'" width="700px">
<el-form :model="articleForm" label-width="80px">
<el-form-item label="标题"><el-input v-model="articleForm.title" /></el-form-item>
<el-form :model="articleForm" label-width="80px" v-loading="articleDetailLoading">
<el-form-item label="标题" required><el-input v-model="articleForm.title" maxlength="200" show-word-limit /></el-form-item>
<el-form-item label="分类">
<el-select v-model="articleForm.categoryId">
<el-select v-model="articleForm.categoryId" clearable placeholder="请选择分类">
<el-option v-for="c in categories" :key="c.id" :label="c.name" :value="c.id" />
</el-select>
</el-form-item>
<el-form-item label="摘要"><el-input v-model="articleForm.summary" type="textarea" :rows="2" /></el-form-item>
<el-form-item label="内容"><el-input v-model="articleForm.content" type="textarea" :rows="8" /></el-form-item>
<el-form-item label="摘要"><el-input v-model="articleForm.summary" type="textarea" :rows="2" maxlength="500" show-word-limit /></el-form-item>
<el-form-item label="正文"><el-input v-model="articleForm.content" type="textarea" :rows="10" /></el-form-item>
<el-form-item label="排序"><el-input-number v-model="articleForm.sortOrder" :min="0" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="articleDialogVisible=false">取消</el-button>
<el-button @click="articleDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveArticle">保存</el-button>
</template>
</el-dialog>
@@ -79,76 +109,149 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post, put, del } from '@/api/client'
import { get, post, put } from '@/api/client'
import { ElMessage } from 'element-plus'
const activeTab = ref('categories')
const loading = ref(false)
const categoryLoading = ref(false)
const articleDetailLoading = ref(false)
const categories = ref<any[]>([])
const articles = ref<any[]>([])
const categoryDialogVisible = ref(false)
const editingCategoryId = ref<string | null>(null)
const categoryForm = ref({ name: '', code: '', sortOrder: 0 })
const categoryForm = ref({ name: '', code: '', description: '', sortOrder: 0 })
const articleDialogVisible = ref(false)
const editingArticleId = ref<string | null>(null)
const articleForm = ref({ title: '', categoryId: '', summary: '', content: '' })
const articleForm = ref({ title: '', categoryId: '', summary: '', content: '', sortOrder: 0 })
const CATEGORY_STATUS_LABEL: Record<string, string> = { active: '启用中', inactive: '已停用' }
const STATUS_LABEL: Record<string, string> = { draft: '草稿', scheduled: '定时', published: '已发布', offline: '已下架' }
const STATUS_TAG: Record<string, string> = { draft: 'info', scheduled: 'warning', published: 'success', offline: 'danger' }
const STATUS_LABEL: Record<string, string> = { draft: '草稿', published: '已发布', offline: '已下架' }
const STATUS_TAG: Record<string, string> = { draft: 'info', published: 'success', offline: 'danger' }
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
function categoryStatusLabel(s: string) { return CATEGORY_STATUS_LABEL[s] || s }
function categoryLabel(id: string) { const c = categories.value.find(x => x.id === id); return c ? c.name : '-' }
// 错误提示统一由 api/client 的响应拦截器弹出,catch 里不再重复 toast
async function loadCategories() {
const res: any = await get('/content/categories')
categories.value = res.data || []
categoryLoading.value = true
try {
// operator 接口:含已停用分类,否则停用后无法在后台重新启用
const res: any = await get('/content/operator/categories')
categories.value = res.data || []
} catch (e) { console.error(e) } finally { categoryLoading.value = false }
}
async function loadArticles() {
loading.value = true
try {
const res: any = await get('/content/articles')
// operator 接口:含全部状态。公开接口只给 published,草稿不出现在列表里 → 无法点「发布」
const res: any = await get('/content/operator/articles')
articles.value = res.data || []
} finally { loading.value = false }
} catch (e) { console.error(e) } finally { loading.value = false }
}
function validateCategory(): boolean {
if (!categoryForm.value.name.trim()) { ElMessage.error('请填写分类名称'); return false }
if (!categoryForm.value.code.trim()) { ElMessage.error('请填写分类编码'); return false }
return true
}
async function saveCategory() {
if (!validateCategory()) return
try {
if (editingCategoryId.value) await put(`/content/operator/categories/${editingCategoryId.value}`, categoryForm.value)
else await post('/content/operator/categories', categoryForm.value)
ElMessage.success('保存成功')
categoryDialogVisible.value = false
loadCategories()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
} catch (e) { console.error(e) }
}
async function editCategory(row: any) { editingCategoryId.value = row.id; Object.assign(categoryForm.value, row); categoryDialogVisible.value = true }
async function deleteCategory(row: any) {
try { await del(`/content/operator/categories/${row.id}`); ElMessage.success('删除成功'); loadCategories() }
catch (e) { ElMessage.error('删除失败') }
function editCategory(row: any) {
editingCategoryId.value = row.id
categoryForm.value = {
name: row.name || '', code: row.code || '',
description: row.description || '', sortOrder: row.sortOrder ?? 0,
}
categoryDialogVisible.value = true
}
async function toggleCategoryStatus(row: any) {
const next = row.status === 'active' ? 'inactive' : 'active'
try {
await put(`/content/operator/categories/${row.id}/status`, { status: next })
ElMessage.success(next === 'active' ? '已启用' : '已停用')
loadCategories()
} catch (e) { console.error(e) }
}
function validateArticle(): boolean {
if (!articleForm.value.title.trim()) { ElMessage.error('请填写文章标题'); return false }
return true
}
async function saveArticle() {
if (!validateArticle()) return
try {
if (editingArticleId.value) await put(`/content/operator/articles/${editingArticleId.value}`, articleForm.value)
else await post('/content/operator/articles', articleForm.value)
ElMessage.success('保存成功')
articleDialogVisible.value = false
loadArticles()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
}
async function editArticle(row: any) { editingArticleId.value = row.id; Object.assign(articleForm.value, { title: row.title, categoryId: row.category_id, summary: row.summary, content: row.content }); articleDialogVisible.value = true }
async function publishArticle(row: any) {
try { await post(`/content/operator/articles/${row.id}/publish`, {}); ElMessage.success('发布成功'); loadArticles() }
catch (e) { ElMessage.error('发布失败') }
}
async function offlineArticle(row: any) {
try { await post(`/content/operator/articles/${row.id}/offline`, {}); ElMessage.success('下架成功'); loadArticles() }
catch (e) { ElMessage.error('下架失败') }
} catch (e) { console.error(e) }
}
function openCategoryDialog() { editingCategoryId.value = null; Object.assign(categoryForm.value, { name: '', code: '', sortOrder: 0 }); categoryDialogVisible.value = true }
function openArticleDialog() { editingArticleId.value = null; Object.assign(articleForm.value, { title: '', categoryId: '', summary: '', content: '' }); articleDialogVisible.value = true }
async function editArticle(row: any) {
editingArticleId.value = row.id
articleForm.value = {
title: row.title || '', categoryId: row.categoryId || '',
summary: row.summary || '', content: '', sortOrder: row.sortOrder ?? 0,
}
articleDialogVisible.value = true
// 列表 DTO 不含正文(有意保持轻量),必须拉详情才能回填正文
articleDetailLoading.value = true
try {
const res: any = await get(`/content/operator/articles/${row.id}`)
const detail = res.data || {}
articleForm.value = {
title: detail.title || '', categoryId: detail.categoryId || '',
summary: detail.summary || '', content: detail.content || '',
sortOrder: detail.sortOrder ?? 0,
}
} catch (e) { console.error(e) } finally { articleDetailLoading.value = false }
}
async function publishArticle(row: any) {
try { await post(`/content/operator/articles/${row.id}/publish`, {}); ElMessage.success('发布成功'); loadArticles() }
catch (e) { console.error(e) }
}
async function offlineArticle(row: any) {
try { await post(`/content/operator/articles/${row.id}/offline`, {}); ElMessage.success('下架成功'); loadArticles() }
catch (e) { console.error(e) }
}
function openCategoryDialog() {
editingCategoryId.value = null
categoryForm.value = { name: '', code: '', description: '', sortOrder: 0 }
categoryDialogVisible.value = true
}
function openArticleDialog() {
editingArticleId.value = null
articleForm.value = { title: '', categoryId: '', summary: '', content: '', sortOrder: 0 }
articleDialogVisible.value = true
}
onMounted(() => { loadCategories(); loadArticles() })
</script>
<style
scoped></style>
<style scoped>
.card-header { display: flex; justify-content: space-between; align-items: center; }
.field-hint { width: 100%; font-size: 12px; color: var(--el-text-color-secondary); line-height: 1.5; }
</style>
@@ -1,45 +1,83 @@
<template>
<div class="counseling-management">
<el-card>
<template #header><div class="card-header"><span>公益婚恋咨询管理</span></div></template>
<el-tabs v-model="activeTab">
<template #header>
<div class="card-header">
<span>公益婚恋咨询管理</span>
<el-button size="small" @click="reloadAll">刷新</el-button>
</div>
</template>
<!-- 权限不足时明确提示,而不是静默显示空表 -->
<el-alert
v-if="!canViewAny"
type="warning"
:closable="false"
show-icon
title="当前账号没有咨询服务权限"
description="如需查看,请联系管理员分配 counseling.service.view 权限。"
/>
<el-tabs v-else v-model="activeTab" @tab-change="onTabChange">
<!-- 待分配队列(仅 counseling.service.assign 可见) -->
<el-tab-pane v-if="canAssign" label="待分配队列" name="queue">
<el-table :data="pendingQueue" stripe v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-skeleton v-if="loadingQueue" :rows="4" animated />
<el-empty v-else-if="queueError" :description="queueError">
<el-button size="small" @click="loadQueue">重试</el-button>
</el-empty>
<el-empty v-else-if="!pendingQueue.length" description="暂无待分配的咨询申请" />
<el-table v-else :data="pendingQueue" stripe>
<el-table-column prop="serviceType" label="服务类型" width="150">
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
</el-table-column>
<el-table-column prop="requestSource" label="来源" width="140">
<template #default="{ row }"><el-tag size="small">{{ requestSourceLabel(row.requestSource) }}</el-tag></template>
<template #default="{ row }">
<el-tag size="small">{{ requestSourceLabel(row.requestSource) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="userRequestSummary" label="问题描述" show-overflow-tooltip />
<el-table-column prop="createdAt" label="申请时间" width="160">
<el-table-column prop="userRequestSummary" label="问题描述" show-overflow-tooltip>
<template #default="{ row }">{{ row.userRequestSummary || '(无)' }}</template>
</el-table-column>
<el-table-column prop="createdAt" label="申请时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="150">
<el-table-column label="操作" width="140">
<template #default="{ row }">
<el-button size="small" type="primary" @click="openAssign(row)">分配咨询师</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="我的咨询(Counselor)" name="assigned">
<el-table :data="assignedServices" stripe v-loading="loading">
<!-- 我的服务(Counselor) -->
<el-tab-pane label="我的咨询(咨询师)" name="assigned">
<el-skeleton v-if="loadingAssigned" :rows="4" animated />
<el-empty v-else-if="assignedError" :description="assignedError">
<el-button size="small" @click="loadAssigned">重试</el-button>
</el-empty>
<el-empty v-else-if="!assignedServices.length" description="暂无分配给您的咨询服务" />
<el-table v-else :data="assignedServices" stripe>
<el-table-column prop="serviceType" label="类型" width="140">
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
<template #default="{ row }">
<el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="scheduledStartAt" label="预约时间" width="160">
<el-table-column prop="scheduledStartAt" label="预约时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.scheduledStartAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="200">
<el-table-column label="操作" min-width="260">
<template #default="{ row }">
<el-button v-if="canProcess && row.status==='assigned'" size="small" @click="scheduleCounseling(row)">排期</el-button>
<el-button v-if="canProcess && row.status==='scheduled'" size="small" type="success" @click="startCounseling(row)">开始</el-button>
<el-button v-if="canCreateNote && row.status==='in_progress'" size="small" type="primary" @click="showFollowup(row)">跟进</el-button>
<el-button v-if="canProcess && row.status==='in_progress'" size="small" type="warning" @click="completeCounseling(row)">完成</el-button>
<el-button v-if="canProcess && row.status === 'assigned'" size="small"
@click="doSchedule(row)">排期</el-button>
<el-button v-if="canProcess && row.status === 'scheduled'" size="small" type="success"
@click="doStart(row)">开始</el-button>
<el-button v-if="canCreateNote && row.status === 'in_progress'" size="small" type="primary"
@click="showFollowup(row)">跟进</el-button>
<el-button v-if="canProcess && row.status === 'in_progress'" size="small" type="warning"
@click="doComplete(row)">完成</el-button>
</template>
</el-table-column>
</el-table>
@@ -49,29 +87,33 @@
<el-dialog v-model="assignDialogVisible" title="分配咨询师" width="400px">
<el-form label-width="80px">
<el-form-item label="咨询师"><el-select v-model="assignForm.counselorId" placeholder="选择咨询师">
<el-option label="张咨询师" value="counselor01" />
<el-option label="李咨询师" value="counselor02" />
</el-select></el-form-item>
<el-form-item label="咨询师">
<el-input v-model="assignForm.counselorId" placeholder="请输入咨询师工号 / ID" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="assignDialogVisible=false">取消</el-button>
<el-button type="primary" @click="doAssign">确认分配</el-button>
<el-button @click="assignDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="doAssign">确认分配</el-button>
</template>
</el-dialog>
<el-dialog v-model="followupDialogVisible" title="添加跟进记录" width="500px">
<el-form label-width="80px">
<el-form-item label="跟进类型"><el-select v-model="followupForm.type">
<el-option label="电话回访" value="phone_call" />
<el-option label="面谈" value="face_to_face" />
<el-option label="线上沟通" value="online_chat" />
</el-select></el-form-item>
<el-form-item label="跟进内容"><el-input v-model="followupForm.content" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="跟进类型">
<el-select v-model="followupForm.type">
<el-option label="电话回访" value="phone_call" />
<el-option label="面谈" value="face_to_face" />
<el-option label="线上沟通" value="online_chat" />
</el-select>
</el-form-item>
<el-form-item label="跟进内容">
<el-input v-model="followupForm.content" type="textarea" :rows="4"
placeholder="仅参与本次咨询的咨询师与申请人本人可见" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="followupDialogVisible=false">取消</el-button>
<el-button type="primary" @click="doFollowup">保存</el-button>
<el-button @click="followupDialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="doFollowup">保存</el-button>
</template>
</el-dialog>
</div>
@@ -80,15 +122,22 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const canViewAny = computed(() =>
authStore.permissions.some((p) => p.startsWith('counseling.')))
const canAssign = computed(() => authStore.permissions.includes('counseling.service.assign'))
const canProcess = computed(() => authStore.permissions.includes('counseling.service.process'))
const canCreateNote = computed(() => authStore.permissions.includes('counseling.note.create'))
const activeTab = ref(canAssign.value ? 'queue' : 'assigned')
const loading = ref(false)
const loadingQueue = ref(false)
const loadingAssigned = ref(false)
const submitting = ref(false)
const queueError = ref('')
const assignedError = ref('')
const pendingQueue = ref<any[]>([])
const assignedServices = ref<any[]>([])
const assignDialogVisible = ref(false)
@@ -102,11 +151,12 @@ const SERVICE_TYPE_LABEL: Record<string, string> = {
premarital_guidance: '婚前辅导', anti_fraud_guidance: '婚恋反诈指导'
}
const REQUEST_SOURCE_LABEL: Record<string, string> = {
user_self: '用户自申请', matchmaker_referral: '红娘转介', operator_assisted: '工作人员协助'
user_self: '用户自申请', matchmaker_referral: '红娘转介', operator_assisted: '工作人员协助',
activity_followup: '活动后续跟进', service_station: '服务站转介'
}
const STATUS_LABEL: Record<string, string> = {
pending: '待分配', assigned: '已分配', scheduled: '已排期',
in_progress: '咨询中', completed: '已完成', cancelled: '已取消', closed: '已关闭'
in_progress: '服务中', completed: '已完成', cancelled: '已取消', closed: '已关闭'
}
const STATUS_TAG: Record<string, string> = {
pending: 'warning', assigned: 'info', scheduled: 'primary',
@@ -119,42 +169,144 @@ function statusLabel(s: string) { return STATUS_LABEL[s] || s }
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
async function loadQueue() {
loading.value = true
try { const res: any = await get('/counseling/services/operator/queue'); pendingQueue.value = res?.data || [] } finally { loading.value = false }
}
async function loadAssigned() {
loading.value = true
try { const res: any = await get('/counseling/services/operator/assigned'); assignedServices.value = res?.data || [] } finally { loading.value = false }
}
async function openAssign(row: any) { currentId.value = row.id; assignForm.value.counselorId = ''; assignDialogVisible.value = true }
async function doAssign() {
try { await post(`/counseling/services/operator/${currentId.value}/assign`, { counselorId: assignForm.value.counselorId }); ElMessage.success('分配成功'); assignDialogVisible.value = false; loadQueue() }
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
async function scheduleCounseling(row: any) {
const now = new Date(); now.setHours(now.getHours() + 2)
const end = new Date(now); end.setHours(end.getHours() + 1)
try { await post(`/counseling/services/operator/${row.id}/schedule`, { startAt: now.toISOString(), endAt: end.toISOString() }); ElMessage.success('排期成功'); loadAssigned() }
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
async function startCounseling(row: any) {
try { await post(`/counseling/services/operator/${row.id}/start`, {}); ElMessage.success('已开始'); loadAssigned() }
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
function showFollowup(row: any) { currentId.value = row.id; followupForm.value = { type: 'phone_call', content: '' }; followupDialogVisible.value = true }
async function doFollowup() {
try { await post(`/counseling/services/operator/${currentId.value}/followup`, { followupType: followupForm.value.type, content: followupForm.value.content }); ElMessage.success('跟进记录已保存'); followupDialogVisible.value = false; loadAssigned() }
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
async function completeCounseling(row: any) {
try { await post(`/counseling/services/operator/${row.id}/complete`, {}); ElMessage.success('已完成'); loadAssigned() }
catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
function errText(e: any, fallback: string) {
return e?.response?.data?.message || e?.message || fallback
}
onMounted(() => { if (canAssign.value) loadQueue(); loadAssigned() })
async function loadQueue() {
loadingQueue.value = true
queueError.value = ''
try {
const res: any = await get('/counseling/services/operator/queue')
pendingQueue.value = res?.data || []
} catch (e: any) {
queueError.value = errText(e, '加载待分配队列失败')
} finally {
loadingQueue.value = false
}
}
async function loadAssigned() {
loadingAssigned.value = true
assignedError.value = ''
try {
const res: any = await get('/counseling/services/operator/assigned')
assignedServices.value = res?.data || []
} catch (e: any) {
assignedError.value = errText(e, '加载我的咨询失败')
} finally {
loadingAssigned.value = false
}
}
function reloadAll() {
if (canAssign.value) loadQueue()
loadAssigned()
}
function onTabChange(name: string | number) {
if (name === 'queue') loadQueue()
else if (name === 'assigned') loadAssigned()
}
function openAssign(row: any) {
currentId.value = row.id
assignForm.value.counselorId = ''
assignDialogVisible.value = true
}
async function doAssign() {
if (!assignForm.value.counselorId) {
ElMessage.warning('请填写咨询师 ID')
return
}
submitting.value = true
try {
await post(`/counseling/services/operator/${currentId.value}/assign`,
{ counselorId: assignForm.value.counselorId })
ElMessage.success('分配成功')
assignDialogVisible.value = false
loadQueue()
loadAssigned()
} catch (e: any) {
// 并发分配冲突(条件更新未命中)会带明确 message
ElMessage.error(errText(e, '分配失败'))
} finally {
submitting.value = false
}
}
async function doSchedule(row: any) {
const start = new Date()
start.setHours(start.getHours() + 2)
const end = new Date(start)
end.setHours(end.getHours() + 1)
try {
await ElMessageBox.confirm(
`将为该申请排期:${formatDateTime(start.toISOString())} ~ ${formatDateTime(end.toISOString())}`,
'确认排期', { type: 'info' })
} catch { return }
try {
await post(`/counseling/services/operator/${row.id}/schedule`,
{ startAt: start.toISOString(), endAt: end.toISOString() })
ElMessage.success('排期成功')
loadAssigned()
} catch (e: any) {
ElMessage.error(errText(e, '排期失败'))
}
}
async function doStart(row: any) {
try {
await post(`/counseling/services/operator/${row.id}/start`, {})
ElMessage.success('已开始服务')
loadAssigned()
} catch (e: any) {
ElMessage.error(errText(e, '操作失败'))
}
}
function showFollowup(row: any) {
currentId.value = row.id
followupForm.value = { type: 'phone_call', content: '' }
followupDialogVisible.value = true
}
async function doFollowup() {
if (!followupForm.value.content.trim()) {
ElMessage.warning('请填写跟进内容')
return
}
submitting.value = true
try {
await post(`/counseling/services/operator/${currentId.value}/followup`,
{ followupType: followupForm.value.type, content: followupForm.value.content })
ElMessage.success('跟进记录已保存')
followupDialogVisible.value = false
loadAssigned()
} catch (e: any) {
ElMessage.error(errText(e, '保存失败'))
} finally {
submitting.value = false
}
}
async function doComplete(row: any) {
try {
await ElMessageBox.confirm('确认将本次服务标记为已完成?完成后不可再修改。', '确认完成', { type: 'warning' })
} catch { return }
try {
await post(`/counseling/services/operator/${row.id}/complete`, {})
ElMessage.success('已完成')
loadAssigned()
} catch (e: any) {
ElMessage.error(errText(e, '操作失败'))
}
}
onMounted(() => { reloadAll() })
</script>
<style scoped>
.card-header { display: flex; align-items: center; justify-content: space-between; }
</style>
</style>
@@ -0,0 +1,160 @@
<template>
<div class="matchmaker-management">
<el-card>
<template #header><div class="card-header"><span>公益红娘管理</span></div></template>
<el-tabs v-model="activeTab">
<el-tab-pane label="服务请求队列" name="services">
<el-table :data="services" stripe v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="serviceType" label="服务类型" width="160">
<template #default="{ row }">{{ serviceTypeLabel(row.serviceType) }}</template>
</el-table-column>
<el-table-column prop="description" label="需求说明" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }"><el-tag :type="statusTag(row.status)">{{ statusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="createdAt" label="提交时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="{ row }">
<el-button v-if="row.status==='submitted'" size="small" type="primary" @click="openAssign(row)">分配</el-button>
<el-button v-if="row.status==='assigned' || row.status==='processing'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
</template>
</el-table-column>
</el-table>
</el-tab-pane>
<el-tab-pane label="推荐列表" name="recommendations">
<el-table :data="recommendations" stripe v-loading="loading">
<el-table-column prop="userId" label="用户" width="110" />
<el-table-column prop="targetUserId" label="推荐对象" width="110" />
<el-table-column prop="reason" label="推荐理由" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="130">
<template #default="{ row }"><el-tag :type="recStatusTag(row.status)">{{ recStatusLabel(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column prop="createdAt" label="创建时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 分配弹窗 -->
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="400px">
<el-form label-width="80px">
<el-form-item label="红娘"><el-select v-model="assignForm.operatorId" placeholder="选择红娘">
<el-option label="王红娘" value="operator_wang" />
<el-option label="陈红娘" value="operator_chen" />
</el-select></el-form-item>
</el-form>
<template #footer>
<el-button @click="assignDialogVisible=false">取消</el-button>
<el-button type="primary" @click="doAssign">确认分配</el-button>
</template>
</el-dialog>
<!-- 创建推荐弹窗 -->
<el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
<el-form label-width="100px">
<el-form-item label="推荐给用户"><el-input v-model="recForm.userId" disabled /></el-form-item>
<el-form-item label="推荐对象ID"><el-input v-model="recForm.targetUserId" placeholder="输入被推荐对象的用户ID" /></el-form-item>
<el-form-item label="推荐理由"><el-input v-model="recForm.reason" type="textarea" :rows="3" placeholder="说明为什么这个对象适合该用户" /></el-form-item>
</el-form>
<template #footer>
<el-button @click="recDialogVisible=false">取消</el-button>
<el-button type="primary" @click="doCreateRecommendation">发送推荐</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { ElMessage } from 'element-plus'
const activeTab = ref('services')
const loading = ref(false)
const services = ref<any[]>([])
const recommendations = ref<any[]>([])
const assignDialogVisible = ref(false)
const recDialogVisible = ref(false)
const currentServiceId = ref('')
const assignForm = ref({ operatorId: '' })
const recForm = ref({ userId: '', targetUserId: '', reason: '' })
const SERVICE_TYPE_LABEL: Record<string, string> = {
recommendation: '推荐对象', referral_counseling: '转介咨询',
referral_activity: '转介活动', referral_station: '转介服务站'
}
const STATUS_LABEL: Record<string, string> = {
submitted: '待分配', assigned: '已分配', processing: '处理中', completed: '已完成', cancelled: '已取消'
}
const STATUS_TAG: Record<string, string> = {
submitted: 'warning', assigned: 'primary', processing: '', completed: 'success', cancelled: 'danger'
}
const REC_STATUS_LABEL: Record<string, string> = {
pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢',
passed: '已跳过', confirmed_mutual: '双向确认', rejected: '已拒绝', expired: '已过期'
}
const REC_STATUS_TAG: Record<string, string> = {
pending_view: 'warning', viewed: 'info', liked: 'primary',
passed: 'info', confirmed_mutual: 'success', rejected: 'danger', expired: 'info'
}
function serviceTypeLabel(t: string) { return SERVICE_TYPE_LABEL[t] || t }
function statusLabel(s: string) { return STATUS_LABEL[s] || s }
function statusTag(s: string) { return STATUS_TAG[s] || 'info' }
function recStatusLabel(s: string) { return REC_STATUS_LABEL[s] || s }
function recStatusTag(s: string) { return REC_STATUS_TAG[s] || 'info' }
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
async function loadServices() {
loading.value = true
// Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。
// 后端实际为 GET /operator/matchmaker/pending(待分配队列)。
try { const res: any = await get('/operator/matchmaker/pending'); services.value = res?.data || [] } finally { loading.value = false }
}
async function loadRecommendations() {
loading.value = true
// Phase 13 契约修复:原 `/matchmaker/recommendations/operator` 不存在(404)。
// 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。
try { const res: any = await get('/operator/matchmaker/recommendations'); recommendations.value = res?.data || [] } finally { loading.value = false }
}
async function refresh() {
if (activeTab.value === 'services') await loadServices(); else await loadRecommendations()
}
async function openAssign(row: any) { currentServiceId.value = row.id; assignForm.value.operatorId = ''; assignDialogVisible.value = true }
async function doAssign() {
// Phase 13 契约修复:原 PUT /matchmaker/services/{id}/assign → 后端为 POST /operator/matchmaker/services/{id}/assign
try {
await post(`/operator/matchmaker/services/${currentServiceId.value}/assign`, { operatorId: assignForm.value.operatorId })
ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
function openRecommendation(row: any) {
currentServiceId.value = row.id
recForm.value = { userId: row.userId, targetUserId: '', reason: '' }
recDialogVisible.value = true
}
async function doCreateRecommendation() {
if (!recForm.value.targetUserId || !recForm.value.reason) { ElMessage.warning('请填写推荐对象和理由'); return }
// Phase 13 契约修复:原 POST /matchmaker/services/{id}/recommendation → 后端为 /operator/matchmaker/services/{id}/recommend
try {
await post(`/operator/matchmaker/services/${currentServiceId.value}/recommend`, {
targetUserId: recForm.value.targetUserId, reason: recForm.value.reason
})
ElMessage.success('推荐已发送给用户'); recDialogVisible.value = false; loadRecommendations()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
}
onMounted(refresh)
</script>
<style scoped>
.card-header { display: flex; align-items: center; justify-content: space-between; }
</style>
@@ -30,7 +30,9 @@ function typeLabel(t: string) { return t === 'service_station' ? '服务站' : t
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
async function load() {
loading.value = true
try { const res: any = await get('/referrals/my-history'); referrals.value = res.data || [] } finally { loading.value = false }
// Phase 13 契约修复:原 `/referrals/my-history` 是 C 端用户端点(UserPrincipal),
// 运营端无"我的用户身份"→ 取不到数据。改用运营端端点(按发起红娘过滤)。
try { const res: any = await get('/referrals/operator/history'); referrals.value = res.data || [] } finally { loading.value = false }
}
onMounted(load)
</script>
+232 -174
View File
@@ -1,230 +1,288 @@
<template>
<el-container class="review-page">
<el-header class="header">
<h2>资料审核</h2>
<el-tag type="warning" v-if="stats.pending > 0">待审核 {{ stats.pending }}</el-tag>
<el-tag type="info" v-if="stats.processing > 0">审核中 {{ stats.processing }}</el-tag>
</el-header>
<div class="page-container page-shell review-page">
<header class="page-heading">
<div>
<span class="page-kicker">用户与资料审核</span>
<h1>资料审核</h1>
<p>按提交顺序核验必要资料,审核操作保留清晰意见和审计记录。</p>
</div>
<div class="stat-tags">
<el-tag type="warning">待审核 {{ stats.pending }}</el-tag>
<el-tag type="info">审核中 {{ stats.processing }}</el-tag>
</div>
</header>
<el-main>
<el-alert v-if="errorMessage" :title="errorMessage" type="error" :closable="false" show-icon>
<template #default><el-button link type="primary" @click="loadPage">重新加载</el-button></template>
</el-alert>
<section v-else class="surface-card review-panel">
<el-tabs v-model="activeTab">
<el-tab-pane label="待审核队列" name="pending">
<el-table :data="pendingTasks" v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="reviewType" label="审核类型" width="120" />
<el-table-column prop="submittedAt" label="提交时间" width="180">
<el-tab-pane :label="`待审核队列(${pendingTasks.length})`" name="pending">
<el-table :data="pendingTasks" v-loading="loading" row-key="taskId">
<el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip />
<el-table-column prop="reviewType" label="审核类型" min-width="130">
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
</el-table-column>
<el-table-column prop="submittedAt" label="提交时间" min-width="180">
<template #default="{ row }">{{ formatTime(row.submittedAt) }}</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
{{ formatTime(row.submittedAt) }}
</template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button type="primary" size="small" @click="handleClaim(row)">领取</el-button>
<el-button v-if="canClaim(row)" type="primary" link @click="handleClaim(row)">领取审核</el-button>
<span v-else class="muted">只读</span>
</template>
</el-table-column>
<template #empty><el-empty description="暂无待审核任务" /></template>
</el-table>
<el-empty v-if="!loading && pendingTasks.length === 0" description="暂无待审核任务" />
</el-tab-pane>
<el-tab-pane label="我的审核" name="my">
<el-table :data="myTasks" v-loading="loading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag>
</template>
<el-tab-pane :label="`我的审核(${myTasks.length})`" name="my">
<el-table :data="myTasks" v-loading="loading" row-key="taskId">
<el-table-column prop="userId" label="用户编号" min-width="170" show-overflow-tooltip />
<el-table-column prop="reviewType" label="审核类型" min-width="130">
<template #default="{ row }">{{ reviewTypeText(row.reviewType) }}</template>
</el-table-column>
<el-table-column prop="completedAt" label="完成时间" width="180">
<template #default="{ row }">
{{ row.completedAt ? formatTime(row.completedAt) : '-' }}
</template>
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button size="small" @click="handleView(row)">查看</el-button>
</template>
<el-table-column prop="submittedAt" label="提交时间" min-width="180">
<template #default="{ row }">{{ formatTime(row.submittedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template #default="{ row }"><el-button link type="primary" @click="handleView(row)">查看</el-button></template>
</el-table-column>
<template #empty><el-empty description="暂无已领取的审核任务" /></template>
</el-table>
</el-tab-pane>
</el-tabs>
</el-main>
</section>
<!-- 审核详情抽屉 -->
<el-drawer v-model="detailVisible" title="审核详情" size="500px">
<template v-if="currentTask">
<el-drawer v-model="detailVisible" title="审核详情" size="min(560px, 92vw)">
<div v-if="detailLoading" v-loading="true" class="drawer-loading"></div>
<template v-else-if="currentTask">
<el-descriptions :column="1" border>
<el-descriptions-item label="用户ID">{{ currentTask.userId }}</el-descriptions-item>
<el-descriptions-item label="审核类型">{{ currentTask.reviewType }}</el-descriptions-item>
<el-descriptions-item label="用户编号">{{ currentTask.userId }}</el-descriptions-item>
<el-descriptions-item label="审核类型">{{ reviewTypeText(currentTask.reviewType) }}</el-descriptions-item>
<el-descriptions-item label="提交时间">{{ formatTime(currentTask.submittedAt) }}</el-descriptions-item>
<el-descriptions-item label="当前状态">
<el-tag :type="statusType(currentTask.status)">{{ statusText(currentTask.status) }}</el-tag>
</el-descriptions-item>
</el-descriptions>
<el-divider>用户资料</el-divider>
<el-descriptions :column="2" border v-if="currentTask.userProfile">
<el-descriptions-item label="姓名">{{ currentTask.userProfile.displayName }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ currentTask.userProfile.gender }}</el-descriptions-item>
<el-descriptions-item label="年龄">{{ currentTask.userProfile.age }}岁</el-descriptions-item>
<el-descriptions-item label="身高">{{ currentTask.userProfile.heightCm }}cm</el-descriptions-item>
<el-descriptions-item label="婚姻状况">{{ currentTask.userProfile.maritalStatus }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ currentTask.userProfile.educationCode }}</el-descriptions-item>
<el-descriptions-item label="自我介绍" :span="2">
{{ currentTask.userProfile.selfIntro || '暂未填写' }}
</el-descriptions-item>
<h3 class="drawer-heading">用户资料</h3>
<el-descriptions v-if="currentTask.userProfile" :column="2" border>
<el-descriptions-item label="显示名称">{{ currentTask.userProfile.displayName || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText(currentTask.userProfile.gender) }}</el-descriptions-item>
<el-descriptions-item label="年龄">{{ currentTask.userProfile.age || '-' }}</el-descriptions-item>
<el-descriptions-item label="身高">{{ currentTask.userProfile.heightCm ? `${currentTask.userProfile.heightCm} cm` : '-' }}</el-descriptions-item>
<el-descriptions-item label="区域">{{ currentTask.userProfile.regionName || '-' }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ currentTask.userProfile.educationCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="自我介绍" :span="2">{{ currentTask.userProfile.selfIntro || '未填写' }}</el-descriptions-item>
</el-descriptions>
<el-empty v-else description="暂无资料快照" />
<el-divider>审核操作</el-divider>
<el-form :model="reviewForm" label-width="80px">
<el-form-item label="审核意见">
<el-input v-model="reviewForm.comment" type="textarea" :rows="3" placeholder="请输入审核意见" />
</el-form-item>
<el-form-item label="审核原因">
<el-select v-model="reviewForm.reasonCodes" multiple placeholder="可选" style="width:100%">
<el-option
v-for="reason in reasonOptions"
:key="reason.code"
:label="reason.name"
:value="reason.code"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="success" @click="handleApprove" :loading="submitting">通过</el-button>
<el-button type="warning" @click="handleNeedEdit" :loading="submitting">需修改</el-button>
<el-button type="danger" @click="handleReject" :loading="submitting">拒绝</el-button>
</el-form-item>
</el-form>
<template v-if="currentTask.reviewType === 'local_eligibility'">
<h3 class="drawer-heading">本地资格佐证</h3>
<el-alert title="佐证属于敏感数据,仅在审核必要时查看;每次访问都会记录目的和结果。" type="warning" :closable="false" show-icon />
<el-table :data="currentTask.localEligibilityEvidences || []" class="evidence-table">
<el-table-column prop="evidenceType" label="佐证类型" min-width="140" />
<el-table-column prop="createdAt" label="上传时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="100">
<template #default="{ row }">
<el-button v-if="canViewEvidence" link type="primary" @click="viewEvidence(row)">查看</el-button>
<span v-else class="muted">无权限</span>
</template>
</el-table-column>
</el-table>
</template>
<template v-if="canProcessCurrent">
<h3 class="drawer-heading">审核操作</h3>
<el-form label-position="top">
<el-form-item label="审核意见">
<el-input v-model.trim="reviewForm.comment" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请写明判断依据或需要补充的内容" />
</el-form-item>
<el-form-item label="原因标签">
<el-select v-model="reviewForm.reasonCodes" multiple class="full-width" placeholder="按需选择">
<el-option v-for="reason in reasonOptions" :key="reason.code" :label="reason.name" :value="reason.code" />
</el-select>
</el-form-item>
<div class="review-actions">
<el-button type="success" :loading="submitting" @click="runDecision('approve', '审核通过')">通过</el-button>
<el-button type="warning" :loading="submitting" @click="runDecision('need-edit', currentTask.reviewType === 'local_eligibility' ? '要求补充佐证' : '退回修改')">{{ currentTask.reviewType === 'local_eligibility' ? '补充佐证' : '需修改' }}</el-button>
<el-button type="danger" :loading="submitting" @click="runDecision('reject', '拒绝资料')">拒绝</el-button>
</div>
</el-form>
</template>
</template>
</el-drawer>
</el-container>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { get, post } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
const activeTab = ref('pending')
const loading = ref(false)
const detailLoading = ref(false)
const submitting = ref(false)
const detailVisible = ref(false)
const errorMessage = ref('')
const stats = ref({ pending: 0, processing: 0 })
const pendingTasks = ref<any[]>([])
const myTasks = ref<any[]>([])
const tasks = ref<any[]>([])
const currentTask = ref<any>(null)
const reasonOptions = ref<any[]>([])
const reviewForm = ref({ comment: '', reasonCodes: [] as string[] })
const canProcess = computed(() => authStore.permissions.includes('review.process'))
const canViewEvidence = computed(() => authStore.permissions.includes('local_eligibility.evidence.view'))
const canProcessCurrent = computed(() => currentTask.value?.reviewType === 'local_eligibility'
? authStore.permissions.includes('local_eligibility.review')
: canProcess.value)
const reviewForm = ref({
comment: '',
reasonCodes: [] as string[]
})
function canClaim(task: any) {
return task.reviewType === 'local_eligibility'
? authStore.permissions.includes('local_eligibility.review')
: canProcess.value
}
const pendingTasks = computed(() => tasks.value.filter((task) => !task.assignedOperatorId))
const myTasks = computed(() => tasks.value.filter((task) => task.assignedOperatorId === authStore.operator?.operatorId))
async function loadStats() {
// TODO: Phase 05 实现真实API调用
stats.value = { pending: 12, processing: 3 }
async function loadPage() {
loading.value = true
errorMessage.value = ''
try {
const [statsResponse, tasksResponse, reasonsResponse]: any[] = await Promise.all([
get('/operator/reviews/stats'),
get('/operator/reviews/pending', { page: 1, pageSize: 20 }),
get('/operator/reviews/reasons'),
])
stats.value = statsResponse.data || { pending: 0, processing: 0 }
tasks.value = tasksResponse.data || []
reasonOptions.value = reasonsResponse.data || []
} catch (error: any) {
tasks.value = []
errorMessage.value = error.response?.status === 403
? '您没有查看资料审核的权限'
: '审核任务加载失败,请检查服务状态后重试'
} finally {
loading.value = false
}
}
function formatTime(ts?: string) {
if (!ts) return '-'
return new Date(ts).toLocaleString('zh-CN')
async function handleClaim(row: any) {
try {
const response: any = await post(`/operator/reviews/${row.taskId || row.id}/claim`)
if (!response.success) throw new Error(response.message || '领取失败')
ElMessage.success('审核任务领取成功')
await loadPage()
await openDetail(row.taskId || row.id)
} catch (error: any) {
ElMessage.error(error.message || '领取失败,任务可能已被其他审核员领取')
}
}
async function openDetail(taskId: string) {
detailVisible.value = true
detailLoading.value = true
currentTask.value = null
reviewForm.value = { comment: '', reasonCodes: [] }
try {
const response: any = await get(`/operator/reviews/${taskId}`)
currentTask.value = response.data
} finally {
detailLoading.value = false
}
}
async function handleView(row: any) {
await openDetail(row.taskId || row.id)
}
async function runDecision(action: 'approve' | 'need-edit' | 'reject', label: string) {
if (!currentTask.value || submitting.value) return
if (action !== 'approve' && !reviewForm.value.comment) {
ElMessage.warning('请填写审核意见,说明需要修改或拒绝的原因')
return
}
if (action === 'reject') {
await ElMessageBox.confirm('拒绝后用户需要重新处理资料,确认继续吗?', '确认拒绝资料', {
type: 'warning',
confirmButtonText: '确认拒绝',
cancelButtonText: '取消',
})
}
submitting.value = true
try {
const localActions = {
approve: 'approve-local-eligibility',
'need-edit': 'need-evidence',
reject: 'reject-local-eligibility',
}
const endpointAction = currentTask.value.reviewType === 'local_eligibility' ? localActions[action] : action
const response: any = await post(`/operator/reviews/${currentTask.value.taskId}/${endpointAction}`, {
comment: reviewForm.value.comment,
reasonCodes: reviewForm.value.reasonCodes,
})
if (!response.success) throw new Error(response.message || `${label}失败`)
ElMessage.success(label)
detailVisible.value = false
await loadPage()
} catch (error: any) {
ElMessage.error(error.message || `${label}失败,请重试`)
} finally {
submitting.value = false
}
}
async function viewEvidence(row: any) {
if (!currentTask.value) return
try {
const { value: purpose } = await ElMessageBox.prompt('请填写本次查看佐证的业务目的', '敏感数据访问确认', {
confirmButtonText: '记录并查看',
cancelButtonText: '取消',
inputPattern: /\S+/,
inputErrorMessage: '访问目的不能为空',
})
const response: any = await post(`/operator/reviews/${currentTask.value.taskId}/evidences/${row.evidenceId}/access`, { purpose })
window.open(response.data.url, '_blank', 'noopener,noreferrer')
} catch (error: any) {
if (error !== 'cancel' && error !== 'close') ElMessage.error(error.message || '佐证访问失败')
}
}
function reviewTypeText(value?: string) {
return value ? ({ identity: '实名认证', profile: '婚恋资料', local_eligibility: '本地资格' }[value] || value) : '-'
}
function genderText(value?: string) {
return value === 'male' ? '男' : value === 'female' ? '女' : '-'
}
function statusType(status: string) {
const map: Record<string, string> = {
pending: 'warning',
processing: 'info',
approved: 'success',
need_edit: 'warning',
rejected: 'danger'
}
return map[status] || ''
return ({ pending: 'warning', processing: 'info', approved: 'success', need_edit: 'warning', rejected: 'danger' } as Record<string, string>)[status] || 'info'
}
function statusText(status: string) {
const map: Record<string, string> = {
pending: '待审核',
processing: '审核中',
approved: '已通过',
need_edit: '需修改',
rejected: '已拒绝'
}
return map[status] || status
return ({ pending: '待审核', processing: '审核中', approved: '已通过', need_edit: '需修改', rejected: '已拒绝' } as Record<string, string>)[status] || status || '-'
}
function handleClaim(_row: any) {
// TODO: 调用 API 领取
ElMessage.success('领取成功')
function formatTime(time?: string) {
return time ? new Date(time).toLocaleString('zh-CN') : '-'
}
function handleView(row: any) {
currentTask.value = row
detailVisible.value = true
}
async function handleApprove() {
submitting.value = true
try {
// TODO: 调用 API
ElMessage.success('审核通过')
detailVisible.value = false
await loadStats()
} finally {
submitting.value = false
}
}
async function handleNeedEdit() {
submitting.value = true
try {
ElMessage.success('已标记需修改')
detailVisible.value = false
await loadStats()
} finally {
submitting.value = false
}
}
async function handleReject() {
submitting.value = true
try {
ElMessage.success('已拒绝')
detailVisible.value = false
await loadStats()
} finally {
submitting.value = false
}
}
onMounted(() => {
loadStats()
})
onMounted(loadPage)
</script>
<style scoped>
.review-page {
height: 100vh;
display: flex;
flex-direction: column;
}
.header {
background: #fff;
padding: 16px 24px;
display: flex;
align-items: center;
gap: 16px;
border-bottom: 1px solid #e4e7ed;
}
.header h2 {
margin: 0;
font-size: 18px;
color: #303133;
}
.el-main {
flex: 1;
padding: 20px;
overflow: auto;
}
.page-container{padding:28px}.page-kicker{color:var(--admin-primary);font-size:12px;font-weight:650;letter-spacing:1px}.stat-tags{display:flex;gap:8px}.review-panel{padding:20px 24px}.muted{color:var(--color-text-tertiary);font-size:12px}.drawer-loading{min-height:240px}.drawer-heading{margin:24px 0 12px;font-size:16px}.full-width{width:100%}.review-actions{display:flex;flex-wrap:wrap;gap:8px}@media(max-width:760px){.page-container{padding:18px}}
</style>
@@ -1,41 +1,96 @@
<template>
<div class="service-program">
<el-card>
<template #header><div class="card-header"><span>专项公益服务项目</span><el-button type="primary" size="small" disabled>新建项目(待开放)</el-button></div></template>
<template #header>
<div class="card-header">
<span>专项公益服务项目</span>
<el-button type="primary" size="small" @click="openCreate">新建项目</el-button>
</div>
</template>
<el-table :data="programs" stripe v-loading="loading">
<el-table-column prop="name" label="项目名称" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':row.status==='draft'?'info':'danger'">{{ row.status }}</el-tag></template>
<template #default="{ row }">
<el-tag :type="programStatusTag(row.status)">{{ programStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="时间范围" width="220">
<template #default="{ row }">{{ formatDateTime(row.start_at) }} ~ {{ formatDateTime(row.end_at) }}</template>
<template #default="{ row }">{{ formatDateTime(row.startAt) }} ~ {{ formatDateTime(row.endAt) }}</template>
</el-table-column>
<el-table-column label="成员数" width="80">
<template #default="{ row }">{{ row.memberCount || 0 }}</template>
<el-table-column label="已通过" width="80">
<template #default="{ row }">{{ row.memberCount ?? 0 }}</template>
</el-table-column>
<el-table-column label="操作" width="180">
<el-table-column label="待审批" width="85">
<template #default="{ row }">
<el-button v-if="row.status==='draft'" size="small" type="success" @click="activateProgram(row)">激活</el-button>
<el-button size="small" @click="viewMembers(row)">查看成员</el-button>
<el-tag v-if="row.pendingCount" type="warning" size="small">{{ row.pendingCount }}</el-tag>
<span v-else>0</span>
</template>
</el-table-column>
<el-table-column label="操作" width="230">
<template #default="{ row }">
<el-button v-if="row.status === 'draft'" size="small" type="success" @click="activateProgram(row)">激活</el-button>
<el-button size="small" @click="viewMembers(row)">成员审批</el-button>
<el-button size="small" @click="openEdit(row)">编辑</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 成员Dialog -->
<el-dialog v-model="memberDialogVisible" title="项目成员" width="700px">
<!-- 项目新建/编辑 Dialog -->
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑项目' : '新建项目'" width="620px">
<el-form :model="form" label-width="90px">
<el-form-item label="项目名称" required><el-input v-model="form.name" maxlength="128" show-word-limit /></el-form-item>
<el-form-item label="项目说明"><el-input v-model="form.description" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="开始时间"><el-date-picker v-model="form.startAt" type="datetime" placeholder="留空表示不限" /></el-form-item>
<el-form-item label="结束时间"><el-date-picker v-model="form.endAt" type="datetime" placeholder="留空表示不限" /></el-form-item>
<el-form-item v-if="editingId" label="状态">
<el-select v-model="form.status">
<el-option label="草稿" value="draft" />
<el-option label="进行中" value="active" />
<el-option label="已结束" value="ended" />
<el-option label="已取消" value="cancelled" />
</el-select>
</el-form-item>
<el-alert
v-else
type="info"
:closable="false"
title="新建项目为草稿状态,保存后需在列表中「激活」才会对用户展示。"
/>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveProgram">保存</el-button>
</template>
</el-dialog>
<!-- 成员审批 Dialog -->
<el-dialog v-model="memberDialogVisible" :title="`成员审批 · ${currentProgramName}`" width="820px">
<el-descriptions v-if="stats" :column="5" border size="small" style="margin-bottom: 14px">
<el-descriptions-item label="待审批">{{ stats.pending }}</el-descriptions-item>
<el-descriptions-item label="已通过">{{ stats.approved }}</el-descriptions-item>
<el-descriptions-item label="已拒绝">{{ stats.rejected }}</el-descriptions-item>
<el-descriptions-item label="已退出">{{ stats.withdrawn }}</el-descriptions-item>
<el-descriptions-item label="合计">{{ stats.total }}</el-descriptions-item>
</el-descriptions>
<el-table :data="members" stripe v-loading="memberLoading">
<el-table-column prop="userId" label="用户ID" width="120" />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="row.status==='approved'?'success':row.status==='pending'?'warning':'info'">{{ row.status }}</el-tag></template>
<el-table-column prop="userId" label="用户ID" width="200" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="110">
<template #default="{ row }">
<el-tag :type="memberStatusTag(row.status)">{{ memberStatusLabel(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="appliedAt" label="申请时间" width="160">
<el-table-column prop="appliedAt" label="申请时间" width="170">
<template #default="{ row }">{{ formatDateTime(row.appliedAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="150">
<el-table-column prop="rejectedReason" label="原因" show-overflow-tooltip>
<template #default="{ row }">{{ row.rejectedReason || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="180">
<template #default="{ row }">
<el-button v-if="row.status==='pending'" size="small" type="success" @click="approveMember(row.id)">通过</el-button>
<el-button v-if="row.status==='pending'" size="small" type="danger" @click="rejectMember(row.id)">拒绝</el-button>
<el-button v-if="row.status === 'pending'" size="small" type="success" @click="approveMember(row.id)">通过</el-button>
<el-button v-if="row.status === 'pending'" size="small" type="danger" @click="rejectMember(row.id)">拒绝</el-button>
<el-button v-if="row.status === 'approved'" size="small" type="warning" @click="removeMember(row.id)">移除</el-button>
</template>
</el-table-column>
</el-table>
@@ -45,48 +100,161 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { get, post } from '@/api/client'
import { ElMessage } from 'element-plus'
import { get, post, put } from '@/api/client'
import { ElMessage, ElMessageBox } from 'element-plus'
const loading = ref(false)
const programs = ref<any[]>([])
const dialogVisible = ref(false)
const editingId = ref<string | null>(null)
const form = ref({
name: '', description: '', startAt: '', endAt: '', status: 'draft',
})
const memberDialogVisible = ref(false)
const currentProgramId = ref('')
const currentProgramName = ref('')
const members = ref<any[]>([])
const memberLoading = ref(false)
const stats = ref<any>(null)
const PROGRAM_STATUS_LABEL: Record<string, string> = { draft: '草稿', active: '进行中', ended: '已结束', cancelled: '已取消' }
const PROGRAM_STATUS_TAG: Record<string, string> = { draft: 'info', active: 'success', ended: 'warning', cancelled: 'danger' }
const MEMBER_STATUS_LABEL: Record<string, string> = { pending: '待审批', approved: '已通过', rejected: '已拒绝', withdrawn: '已退出' }
const MEMBER_STATUS_TAG: Record<string, string> = { pending: 'warning', approved: 'success', rejected: 'danger', withdrawn: 'info' }
function programStatusLabel(s: string) { return PROGRAM_STATUS_LABEL[s] || s }
function programStatusTag(s: string) { return PROGRAM_STATUS_TAG[s] || 'info' }
function memberStatusLabel(s: string) { return MEMBER_STATUS_LABEL[s] || s }
function memberStatusTag(s: string) { return MEMBER_STATUS_TAG[s] || 'info' }
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
// 错误提示统一由 api/client 的响应拦截器弹出,catch 里不再重复 toast
async function loadPrograms() {
loading.value = true
try {
const res: any = await get('/service-programs')
// operator 接口:含草稿/已结束项目。用户端接口只返回 active,草稿在后台会看不见。
const res: any = await get('/service-programs/operator/list')
programs.value = res.data || []
} finally { loading.value = false }
} catch (e) { console.error(e) } finally { loading.value = false }
}
function openCreate() {
editingId.value = null
form.value = { name: '', description: '', startAt: '', endAt: '', status: 'draft' }
dialogVisible.value = true
}
function openEdit(row: any) {
editingId.value = row.id
form.value = {
name: row.name || '', description: row.description || '',
startAt: row.startAt || '', endAt: row.endAt || '',
status: row.status || 'draft',
}
dialogVisible.value = true
}
function validate(): boolean {
if (!form.value.name.trim()) { ElMessage.error('请填写项目名称'); return false }
if (form.value.startAt && form.value.endAt && new Date(form.value.startAt) > new Date(form.value.endAt)) {
ElMessage.error('开始时间不能晚于结束时间')
return false
}
return true
}
async function saveProgram() {
if (!validate()) return
const payload: any = {
name: form.value.name,
description: form.value.description,
// 后端按 ISO-8601 Instant 解析;空字符串需转 null,否则 400
startAt: form.value.startAt ? new Date(form.value.startAt).toISOString() : null,
endAt: form.value.endAt ? new Date(form.value.endAt).toISOString() : null,
}
if (editingId.value) payload.status = form.value.status
try {
if (editingId.value) await put(`/service-programs/operator/${editingId.value}`, payload)
else await post('/service-programs/operator', payload)
ElMessage.success('保存成功')
dialogVisible.value = false
loadPrograms()
} catch (e) { console.error(e) }
}
async function viewMembers(row: any) {
currentProgramId.value = row.id
currentProgramName.value = row.name
memberDialogVisible.value = true
await Promise.all([loadMembers(), loadStats()])
}
async function loadMembers() {
memberLoading.value = true
try {
const res: any = await get(`/service-programs/operator/${row.id}/members`)
// operator 接口返回全部状态(含 pending);此前复用只返回 approved 的接口,
// 导致待审批申请不显示、审批按钮永远不出现。
const res: any = await get(`/service-programs/operator/${currentProgramId.value}/members`)
members.value = res.data || []
} finally { memberLoading.value = false }
} catch (e) { console.error(e) } finally { memberLoading.value = false }
}
async function loadStats() {
try {
const res: any = await get(`/service-programs/operator/${currentProgramId.value}/stats`)
stats.value = res.data || null
} catch (e) { console.error(e) }
}
async function refreshAfterAction() {
await Promise.all([loadMembers(), loadStats()])
loadPrograms()
}
async function activateProgram(row: any) {
try { await post(`/service-programs/operator/${row.id}/activate`, {}); ElMessage.success('激活成功'); loadPrograms() }
catch (e) { ElMessage.error('操作失败') }
}
async function approveMember(memberId: string) {
try { await post(`/service-programs/operator/members/${memberId}/approve`, {}); ElMessage.success('已通过'); viewMembers({ id: currentProgramId.value }); }
catch (e) { ElMessage.error('操作失败') }
}
async function rejectMember(memberId: string) {
try { await post(`/service-programs/operator/members/${memberId}/reject`, { reason: '不符合条件' }); ElMessage.success('已拒绝'); viewMembers({ id: currentProgramId.value }); }
catch (e) { ElMessage.error('操作失败') }
try {
await post(`/service-programs/operator/${row.id}/activate`, {})
ElMessage.success('激活成功')
loadPrograms()
} catch (e) { console.error(e) }
}
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
async function approveMember(memberId: string) {
try {
await post(`/service-programs/operator/members/${memberId}/approve`, {})
ElMessage.success('已通过')
refreshAfterAction()
} catch (e) { console.error(e) }
}
async function rejectMember(memberId: string) {
try {
// 后端要求拒绝必须填写原因,这里不再硬编码"不符合条件"
const { value } = await ElMessageBox.prompt('请填写拒绝原因(用户可见)', '拒绝申请', {
confirmButtonText: '确认拒绝',
cancelButtonText: '取消',
inputPlaceholder: '例如:名额已满 / 不符合本期招募条件',
inputValidator: (text: string) => (text && text.trim() ? true : '必须填写拒绝原因'),
})
await post(`/service-programs/operator/members/${memberId}/reject`, { reason: value })
ElMessage.success('已拒绝')
refreshAfterAction()
} catch (e) { console.error(e) }
}
async function removeMember(memberId: string) {
try {
await ElMessageBox.confirm('移除后该成员状态变为「已退出」,记录保留可追溯。确认移除?', '移除成员', {
confirmButtonText: '确认移除',
cancelButtonText: '取消',
type: 'warning',
})
await post(`/service-programs/operator/members/${memberId}/remove`, { reason: '运营移除' })
ElMessage.success('已移除')
refreshAfterAction()
} catch (e) { console.error(e) }
}
onMounted(loadPrograms)
</script>
@@ -1,7 +1,12 @@
<template>
<div class="service-station">
<el-card>
<template #header><div class="card-header"><span>善缘服务站管理</span><el-button type="primary" size="small" @click="openCreate">新建服务站</el-button></div></template>
<template #header>
<div class="card-header">
<span>善缘服务站管理</span>
<el-button v-if="canManage" type="primary" size="small" @click="openCreate">新建服务站</el-button>
</div>
</template>
<el-table :data="stations" stripe v-loading="loading">
<el-table-column prop="name" label="名称" />
<el-table-column prop="shortName" label="简称" width="120" />
@@ -11,23 +16,35 @@
<template #default="{ row }">{{ row.openingHoursText || '-' }}</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }"><el-tag :type="row.status==='active'?'success':row.status==='closed'?'danger':'info'">{{ row.status }}</el-tag></template>
</el-table-column>
<el-table-column label="操作" width="150">
<template #default="{ row }">
<el-button size="small" @click="openEdit(row)">编辑</el-button>
<el-tag :type="row.status === 'active' ? 'success' : row.status === 'closed' ? 'danger' : 'info'">
{{ STATUS_LABELS[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="210">
<template #default="{ row }">
<el-button size="small" v-if="canManage" @click="openEdit(row)">编辑</el-button>
<el-button size="small" type="primary" plain @click="openAnnouncements(row)">公告管理</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!loading && !stations.length" description="暂无服务站" />
</el-card>
<el-dialog v-model="dialogVisible" title="编辑服务站" width="600px">
<!-- 服务站信息 -->
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑服务站' : '新建服务站'" width="600px">
<el-form :model="form" label-width="100px">
<el-form-item label="名称"><el-input v-model="form.name" /></el-form-item>
<el-form-item label="名称" required><el-input v-model="form.name" /></el-form-item>
<el-form-item label="简称"><el-input v-model="form.shortName" /></el-form-item>
<el-form-item label="地址"><el-input v-model="form.address" /></el-form-item>
<el-form-item label="电话显示"><el-input v-model="form.contactPhoneDisplay" /></el-form-item>
<el-form-item label="地址" required><el-input v-model="form.address" /></el-form-item>
<el-form-item label="电话显示">
<el-input v-model="form.contactPhoneDisplay" placeholder="展示给用户的脱敏号码,如 0511-****8888" />
</el-form-item>
<el-form-item label="营业时间"><el-input v-model="form.openingHoursText" type="textarea" :rows="3" /></el-form-item>
<el-form-item label="服务说明">
<el-input v-model="form.serviceDescription" type="textarea" :rows="2" placeholder="服务站提供的公益服务内容" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="营业中" value="active" />
@@ -37,44 +54,300 @@
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible=false">取消</el-button>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveStation">保存</el-button>
</template>
</el-dialog>
<!-- 临时公告管理 -->
<el-drawer v-model="drawerVisible" :title="`临时公告 · ${currentStation?.name || ''}`" size="70%">
<div class="drawer-toolbar">
<el-alert
type="info"
:closable="false"
show-icon
title="公告仅「已发布」且未过期时对小程序用户可见;过期后自动隐藏,无需手工下架。"
/>
<el-button v-if="canManage" type="primary" size="small" @click="openAnnouncementCreate">新建公告</el-button>
</div>
<el-table :data="announcements" stripe v-loading="announcementLoading">
<el-table-column prop="title" label="标题" show-overflow-tooltip />
<el-table-column prop="status" label="状态" width="100">
<template #default="{ row }">
<el-tag :type="ANNOUNCEMENT_STATUS_TYPE[row.status] || 'info'">
{{ ANNOUNCEMENT_STATUS_LABELS[row.status] || row.status }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="sortOrder" label="排序" width="80" />
<el-table-column label="发布时间" width="170">
<template #default="{ row }">{{ formatTime(row.publishedAt) }}</template>
</el-table-column>
<el-table-column label="过期时间" width="170">
<template #default="{ row }">{{ row.expiresAt ? formatTime(row.expiresAt) : '长期有效' }}</template>
</el-table-column>
<el-table-column label="操作" width="230">
<template #default="{ row }">
<el-button v-if="canManage" size="small" @click="openAnnouncementEdit(row)">编辑</el-button>
<el-button
v-if="canManage && row.status !== 'published'"
size="small"
type="success"
@click="publishAnnouncement(row)"
>发布</el-button>
<el-button
v-if="canManage && row.status === 'published'"
size="small"
type="warning"
@click="archiveAnnouncement(row)"
>下架</el-button>
</template>
</el-table-column>
</el-table>
<el-empty v-if="!announcementLoading && !announcements.length" description="暂无公告" />
</el-drawer>
<!-- 公告编辑 -->
<el-dialog
v-model="announcementDialogVisible"
:title="announcementForm.id ? '编辑公告' : '新建公告'"
width="600px"
append-to-body
>
<el-form :model="announcementForm" label-width="100px">
<el-form-item label="标题" required>
<el-input v-model="announcementForm.title" maxlength="200" show-word-limit />
</el-form-item>
<el-form-item label="正文">
<el-input v-model="announcementForm.content" type="textarea" :rows="4" />
</el-form-item>
<el-form-item label="过期时间">
<el-date-picker
v-model="announcementForm.expiresAt"
type="datetime"
placeholder="留空表示长期有效"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="排序">
<el-input-number v-model="announcementForm.sortOrder" :min="0" />
<span class="form-hint">数值越小越靠前</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="announcementDialogVisible = false">取消</el-button>
<el-button type="primary" @click="saveAnnouncement">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { get, post, put } from '@/api/client'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const STATUS_LABELS: Record<string, string> = { active: '营业中', inactive: '休息中', closed: '关闭' }
const ANNOUNCEMENT_STATUS_LABELS: Record<string, string> = {
draft: '草稿',
published: '已发布',
archived: '已下架',
}
const ANNOUNCEMENT_STATUS_TYPE: Record<string, 'info' | 'success' | 'warning'> = {
draft: 'info',
published: 'success',
archived: 'warning',
}
const auth = useAuthStore()
// 后端写端点由 service_location.manage 保护;此处按同一权限显隐,避免点了才 403。
const canManage = computed(() => auth.permissions.includes('service_location.manage'))
const loading = ref(false)
const stations = ref<any[]>([])
const dialogVisible = ref(false)
const form = ref<any>({})
const drawerVisible = ref(false)
const currentStation = ref<any>(null)
const announcements = ref<any[]>([])
const announcementLoading = ref(false)
const announcementDialogVisible = ref(false)
const announcementForm = ref<any>({})
function formatTime(value: string | null | undefined) {
if (!value) return '-'
return new Date(value).toLocaleString('zh-CN', { hour12: false })
}
async function loadStations() {
loading.value = true
try {
const res: any = await get('/operator/service-locations')
stations.value = res.data || []
} finally { loading.value = false }
} finally {
loading.value = false
}
}
function openEdit(row: any) { Object.assign(form.value, row); dialogVisible.value = true }
function openCreate() { Object.assign(form.value, { id: undefined, name: '', shortName: '', address: '', contactPhoneDisplay: '', openingHoursText: '', status: 'active' }); dialogVisible.value = true }
function openEdit(row: any) {
form.value = { ...row }
dialogVisible.value = true
}
function openCreate() {
form.value = {
id: undefined,
name: '',
shortName: '',
address: '',
contactPhoneDisplay: '',
openingHoursText: '',
serviceDescription: '',
status: 'active',
}
dialogVisible.value = true
}
async function saveStation() {
const payload = { ...form.value }
try {
if (form.value.id) await put(`/operator/service-locations/${form.value.id}`, form.value)
else await post('/operator/service-locations', form.value)
// 失败提示由响应拦截器统一处理(避免重复 toast)
if (payload.id) await put(`/operator/service-locations/${payload.id}`, payload)
else await post('/operator/service-locations', payload)
ElMessage.success('保存成功')
dialogVisible.value = false
loadStations()
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '保存失败') }
} catch {
/* 已由拦截器提示 */
}
}
// ==================== 公告 ====================
async function openAnnouncements(row: any) {
currentStation.value = row
drawerVisible.value = true
await loadAnnouncements()
}
async function loadAnnouncements() {
if (!currentStation.value) return
announcementLoading.value = true
try {
const res: any = await get(
`/operator/service-locations/${currentStation.value.id}/announcements`
)
announcements.value = res.data || []
} finally {
announcementLoading.value = false
}
}
function openAnnouncementCreate() {
announcementForm.value = { id: undefined, title: '', content: '', expiresAt: null, sortOrder: 0 }
announcementDialogVisible.value = true
}
function openAnnouncementEdit(row: any) {
announcementForm.value = {
id: row.id,
title: row.title,
content: row.content || '',
// el-date-picker 需要 Date 对象;后端为 ISO-8601 瞬时
expiresAt: row.expiresAt ? new Date(row.expiresAt) : null,
sortOrder: row.sortOrder ?? 0,
}
announcementDialogVisible.value = true
}
async function saveAnnouncement() {
const payload: any = {
title: announcementForm.value.title,
content: announcementForm.value.content,
sortOrder: announcementForm.value.sortOrder,
expiresAt: announcementForm.value.expiresAt
? new Date(announcementForm.value.expiresAt).toISOString()
: null,
}
try {
if (announcementForm.value.id) {
await put(`/operator/service-locations/announcements/${announcementForm.value.id}`, payload)
} else {
await post(
`/operator/service-locations/${currentStation.value.id}/announcements`,
payload
)
}
ElMessage.success('保存成功')
announcementDialogVisible.value = false
loadAnnouncements()
} catch {
/* 已由拦截器提示 */
}
}
async function publishAnnouncement(row: any) {
try {
await ElMessageBox.confirm(
`发布后小程序用户将立即可见「${row.title}」。确认发布?`,
'发布公告',
{ type: 'warning' }
)
} catch {
return
}
try {
await post(`/operator/service-locations/announcements/${row.id}/publish`)
ElMessage.success('已发布')
loadAnnouncements()
} catch {
/* 已由拦截器提示 */
}
}
async function archiveAnnouncement(row: any) {
try {
await ElMessageBox.confirm(
`下架后用户端立即不可见,记录保留可追溯。确认下架「${row.title}」?`,
'下架公告',
{ type: 'warning' }
)
} catch {
return
}
try {
await post(`/operator/service-locations/announcements/${row.id}/archive`)
ElMessage.success('已下架')
loadAnnouncements()
} catch {
/* 已由拦截器提示 */
}
}
onMounted(loadStations)
</script>
<style scoped>.card-header{display:flex;justify-content:space-between;align-items:center}</style>
<style scoped>
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.drawer-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.drawer-toolbar :deep(.el-alert) {
flex: 1;
}
.form-hint {
margin-left: 10px;
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>
+88 -131
View File
@@ -1,176 +1,133 @@
<template>
<div class="page-container" v-loading="loading">
<el-card shadow="never" v-if="user">
<template #header>
<div class="card-header">
<span>用户详情</span>
<el-tag :type="accountStatusType(user.accountStatus)">{{ accountStatusText(user.accountStatus) }}</el-tag>
<el-tag :type="riskLevelType(user.riskLevel)">{{ riskLevelText(user.riskLevel) }}</el-tag>
</div>
</template>
<el-tabs v-model="activeTab">
<!-- 基础信息 -->
<div class="page-container page-shell" v-loading="loading">
<header class="page-heading">
<div>
<el-button class="back-button" link @click="router.push('/users')">
<el-icon><ArrowLeft /></el-icon>返回用户列表
</el-button>
<h1>用户详情</h1>
<p>敏感信息遵循最小权限原则,查看与处置操作均需审计。</p>
</div>
<div v-if="user" class="heading-tags">
<el-tag :type="accountStatusType(user.accountStatus)">{{ accountStatusText(user.accountStatus) }}</el-tag>
<el-tag :type="riskLevelType(user.riskLevel)" effect="plain">{{ riskLevelText(user.riskLevel) }}</el-tag>
</div>
</header>
<el-alert v-if="errorMessage" :title="errorMessage" type="error" :closable="false" show-icon>
<template #default><el-button v-if="accountId" link type="primary" @click="loadDetail">重新加载</el-button></template>
</el-alert>
<section v-else-if="user" class="surface-card detail-card">
<div class="identity-summary">
<el-avatar :size="58">{{ (user.displayName || '用')[0] }}</el-avatar>
<div><h2>{{ user.displayName || '未设置显示名称' }}</h2><span>用户编号:{{ user.id }}</span></div>
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="#EE666B" />
</div>
<el-tabs v-model="activeTab" class="detail-tabs">
<el-tab-pane label="基础信息" name="basic">
<el-descriptions :column="2" border>
<el-descriptions-item label="用户ID">{{ user.id }}</el-descriptions-item>
<el-descriptions-item label="显示名称">{{ user.displayName }}</el-descriptions-item>
<el-descriptions-item label="显示名称">{{ user.displayName || '-' }}</el-descriptions-item>
<el-descriptions-item label="实名认证">{{ user.idVerified ? '已认证' : '未认证' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ genderText(user.gender) }}</el-descriptions-item>
<el-descriptions-item label="年龄">{{ user.age || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机">
<template v-if="user.sensitiveMasked">
<span class="masked">{{ user.phoneMasked }}</span>
<el-button size="small" link @click="requestSensitive">需要查看</el-button>
</template>
<template v-else>{{ user.phone }}</template>
<el-descriptions-item label="手机号">
<template v-if="user.sensitiveMasked"><span class="masked">{{ user.phoneMasked || '已脱敏' }}</span><el-button link type="primary" @click="requestSensitive">权限说明</el-button></template>
<template v-else>{{ user.phone || '-' }}</template>
</el-descriptions-item>
<el-descriptions-item label="区域">{{ user.regionName || '-' }}</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ formatTime(user.registeredAt) }}</el-descriptions-item>
<el-descriptions-item label="最近登录">{{ formatTime(user.lastLoginAt) }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<!-- 婚恋资料 -->
<el-tab-pane label="婚恋资料" name="profile">
<el-descriptions :column="2" border>
<el-descriptions-item label="婚姻状态">{{ maritalText(user.maritalStatus) }}</el-descriptions-item>
<el-descriptions-item label="子女情况">{{ childrenText(user.childrenStatus) }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ eduText(user.educationCode) }}</el-descriptions-item>
<el-descriptions-item label="职业">{{ occText(user.occupationCode) }}</el-descriptions-item>
<el-descriptions-item label="自我介绍" :span="2">{{ user.selfIntro || '未填写' }}</el-descriptions-item>
<el-descriptions-item label="资料完整度">{{ user.profileCompletion }}%</el-descriptions-item>
<el-descriptions-item label="学历">{{ user.educationCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="职业">{{ user.occupationCode || '-' }}</el-descriptions-item>
<el-descriptions-item label="资料状态">{{ profileStatusText(user.profileStatus) }}</el-descriptions-item>
<el-descriptions-item label="关系状态">{{ relationshipText(user.relationshipStatus) }}</el-descriptions-item>
<el-descriptions-item label="自我介绍" :span="2">{{ user.selfIntro || '未填写' }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
<!-- 风险记录 -->
<el-tab-pane label="风险记录" name="risk">
<el-descriptions :column="2" border>
<el-descriptions-item label="风险分数">{{ user.totalRiskScore ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="风险等级">
<el-tag :type="riskLevelType(user.riskLevel)">{{ riskLevelText(user.riskLevel) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="历史举报数">{{ user.totalReportsCount ?? 0 }}</el-descriptions-item>
<el-descriptions-item label="当前处置数">{{ user.activeActionsCount ?? 0 }}</el-descriptions-item>
</el-descriptions>
<div style="margin-top:16px">
<el-button type="primary" size="small" @click="showStatusDialog = true">修改账号状态</el-button>
<el-tab-pane label="安全与账号" name="risk">
<div class="risk-summary">
<article><span>风险分数</span><strong>{{ user.totalRiskScore ?? 0 }}</strong></article>
<article><span>历史举报</span><strong>{{ user.totalReportsCount ?? 0 }}</strong></article>
<article><span>当前处置</span><strong>{{ user.activeActionsCount ?? 0 }}</strong></article>
</div>
<div class="danger-zone">
<div><h3><el-icon><WarningFilled /></el-icon>账号状态处置</h3><p>状态变更会影响用户登录和平台功能,提交前请确认处置依据。</p></div>
<el-button v-if="canChangeStatus" type="danger" plain @click="openStatusDialog">修改账号状态</el-button>
<el-tag v-else type="info">无状态修改权限</el-tag>
</div>
</el-tab-pane>
<!-- 状态记录 -->
<el-tab-pane label="状态记录" name="status">
<el-descriptions :column="2" border>
<el-descriptions-item label="账号状态">
<el-tag :type="accountStatusType(user.accountStatus)">{{ accountStatusText(user.accountStatus) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="婚恋状态">{{ relationshipText(user.relationshipStatus) }}</el-descriptions-item>
</el-descriptions>
</el-tab-pane>
</el-tabs>
</el-card>
<!-- 修改状态对话框 -->
<el-dialog v-model="showStatusDialog" title="修改账号状态" width="400px">
<el-form label-width="100px">
</section>
<el-empty v-else-if="!loading" description="未找到用户信息" />
<el-dialog v-model="showStatusDialog" title="修改账号状态" width="420px">
<el-alert title="该操作将被写入审计日志,请谨慎处理。" type="warning" :closable="false" show-icon />
<el-form label-position="top" class="status-form">
<el-form-item label="新状态">
<el-select v-model="newStatus" style="width:100%">
<el-option label="正常" value="normal" />
<el-option label="风险审核中" value="reviewing_risk" />
<el-option label="功能受限" value="limited" />
<el-option label="临时暂停" value="suspended" />
<el-option label="封禁" value="banned" />
<el-option label="已注销" value="closed" />
<el-select v-model="newStatus" class="full-width">
<el-option label="正常" value="normal" /><el-option label="风险审核中" value="reviewing_risk" />
<el-option label="功能受限" value="limited" /><el-option label="临时暂停" value="suspended" />
<el-option label="封禁" value="banned" /><el-option label="已注销" value="closed" />
</el-select>
</el-form-item>
<el-form-item label="原因">
<el-input v-model="statusNote" type="textarea" :rows="3" placeholder="请输入修改原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="showStatusDialog = false">取消</el-button>
<el-button type="primary" :loading="updating" @click="updateStatus">确认</el-button>
</template>
<template #footer><el-button @click="showStatusDialog = false">取消</el-button><el-button type="danger" :loading="updating" @click="updateStatus">确认变更</el-button></template>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' // eslint-disable-next-line
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ArrowLeft, WarningFilled } from '@element-plus/icons-vue'
import { get, put } from '@/api/client'
const route = useRoute()
const loading = ref(true)
const user = ref<any>(null)
const activeTab = ref('basic')
const showStatusDialog = ref(false)
const newStatus = ref('normal')
const statusNote = ref('')
const updating = ref(false)
const accountId = route.query.id as string
import { useAuthStore } from '@/stores/auth'
const route = useRoute(); const router = useRouter(); const authStore = useAuthStore()
const loading = ref(true); const errorMessage = ref(''); const user = ref<any>(null); const activeTab = ref('basic')
const showStatusDialog = ref(false); const newStatus = ref('normal'); const updating = ref(false)
const accountId = String(route.query.id || '')
const canChangeStatus = computed(() => authStore.permissions.includes('user.status.change'))
const ACCOUNT_MAP: Record<string, { text: string; type: string }> = {
normal: { text: '正常', type: 'success' },
banned: { text: '封禁', type: 'danger' },
suspended: { text: '暂停', type: 'warning' },
reviewing_risk: { text: '风险审核', type: 'danger' },
limited: { text: '受限', type: 'info' }
normal: { text: '正常', type: 'success' }, banned: { text: '封禁', type: 'danger' },
suspended: { text: '暂停', type: 'warning' }, reviewing_risk: { text: '风险审核', type: 'danger' },
limited: { text: '受限', type: 'info' }, closed: { text: '已注销', type: 'info' },
}
const RISK_MAP: Record<string, { text: string; type: string }> = {
normal: { text: '正常', type: 'info' },
watch: { text: '关注', type: '' },
high: { text: '高风险', type: 'warning' },
critical: { text: '极高风险', type: 'danger' }
normal: { text: '正常', type: 'info' }, watch: { text: '关注', type: 'warning' },
high: { text: '高风险', type: 'warning' }, critical: { text: '极高风险', type: 'danger' },
}
async function loadDetail() {
if (!accountId) return
loading.value = true
try {
const token = localStorage.getItem('accessToken')
const res: any = await get(`/operator/users/${accountId}`, {}, { Authorization: `Bearer ${token}` })
user.value = res
} catch (e) { console.error(e) }
if (!accountId) { loading.value = false; errorMessage.value = '缺少用户编号,请从用户列表重新进入'; return }
loading.value = true; errorMessage.value = ''
try { const response: any = await get(`/operator/users/${accountId}`); user.value = response.data }
catch (error: any) { user.value = null; errorMessage.value = error.response?.status === 403 ? '您没有查看该用户详情的权限' : '用户详情加载失败,请检查服务状态后重试' }
finally { loading.value = false }
}
function openStatusDialog() { newStatus.value = user.value?.accountStatus || 'normal'; showStatusDialog.value = true }
async function updateStatus() {
if (!newStatus.value) return
if (!newStatus.value || updating.value) return
await ElMessageBox.confirm(`确认将该用户账号状态修改为“${accountStatusText(newStatus.value)}”吗?`, '确认账号状态变更', { type: 'warning', confirmButtonText: '确认变更', cancelButtonText: '取消' })
updating.value = true
try {
const token = localStorage.getItem('accessToken')
await put(`/operator/users/${accountId}/status`, { accountStatus: newStatus.value }, {
Authorization: `Bearer ${token}`
})
showStatusDialog.value = false
loadDetail()
} catch (e) { console.error(e) }
try { await put(`/operator/users/${accountId}/status`, { accountStatus: newStatus.value }); ElMessage.success('账号状态已更新'); showStatusDialog.value = false; await loadDetail() }
finally { updating.value = false }
}
function requestSensitive() { /* TODO: 需要敏感权限的申请流程 */ }
function genderText(g?: string) { return g === 'male' ? '男' : g === 'female' ? '女' : '-' }
function maritalText(m?: string) { const m2 = m as string | undefined; return m2 && { single: '未婚', divorced: '离异', widowed: '丧偶' }[m2] || '-' }
function childrenText(c?: string) { const c2 = c as string | undefined; return c2 && { none: '无子女', with_children: '有子女' }[c2] || '-' }
function eduText(e?: string) { return e || '-' }
function occText(o?: string) { return o || '-' }
function profileStatusText(s?: string) { const s2 = s as string | undefined; return s2 && { approved: '已审核', pending: '待审核', need_edit: '需修改' }[s2] || s }
function relationshipText(r?: string) { const r2 = r as string | undefined; return r2 && { searching: '寻找中', getting_to_know: '正在了解', matched_successfully: '已牵手', paused: '暂停' }[r2] || r || '-' }
function accountStatusType(s: string) { return ACCOUNT_MAP[s]?.type || 'info' }
function accountStatusText(s: string) { return ACCOUNT_MAP[s]?.text || s }
function riskLevelType(l: string) { return RISK_MAP[l]?.type || 'info' }
function riskLevelText(l: string) { return RISK_MAP[l]?.text || l }
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
function requestSensitive() { ElMessage.info('当前内容已脱敏;只有具备敏感数据权限的角色可以查看明文。') }
function genderText(value?: string) { return value === 'male' ? '男' : value === 'female' ? '女' : '-' }
function maritalText(value?: string) { return value ? ({ single: '未婚', divorced: '离异', widowed: '丧偶' }[value] || value) : '-' }
function childrenText(value?: string) { return value ? ({ none: '无子女', with_children: '有子女' }[value] || value) : '-' }
function profileStatusText(value?: string) { return value ? ({ approved: '已审核', pending: '待审核', need_edit: '需修改' }[value] || value) : '-' }
function relationshipText(value?: string) { return value ? ({ searching: '寻找中', getting_to_know: '正在了解', matched_successfully: '已牵手', paused: '暂停' }[value] || value) : '-' }
function accountStatusType(status: string) { return ACCOUNT_MAP[status]?.type || 'info' }
function accountStatusText(status: string) { return ACCOUNT_MAP[status]?.text || status || '-' }
function riskLevelType(level: string) { return RISK_MAP[level]?.type || 'info' }
function riskLevelText(level: string) { return RISK_MAP[level]?.text || level || '-' }
function formatTime(time?: string) { return time ? new Date(time).toLocaleString('zh-CN') : '-' }
onMounted(loadDetail)
</script>
<style scoped>
.page-container { padding: 24px; }
.card-header { display: flex; align-items: center; gap: 12px; }
.masked { color: #999; font-style: italic; }
.page-container{padding:28px}.back-button{margin-bottom:8px}.heading-tags{display:flex;gap:8px}.detail-card{padding:24px}.identity-summary{display:flex;align-items:center;gap:14px;padding-bottom:20px;border-bottom:1px solid var(--color-border-light)}.identity-summary .el-avatar{color:#fff;background:var(--admin-primary)}.identity-summary h2{font-size:20px}.identity-summary span{display:block;margin-top:6px;color:var(--color-text-tertiary);font-size:12px}.completion{margin-left:auto}.detail-tabs{margin-top:18px}.masked{margin-right:8px;color:var(--color-text-tertiary)}.risk-summary{display:grid;grid-template-columns:repeat(3,1fr);gap:14px}.risk-summary article{padding:18px;background:var(--admin-soft-pink);border-radius:12px}.risk-summary span{color:var(--color-text-secondary)}.risk-summary strong{display:block;margin-top:8px;font-size:26px}.danger-zone{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:20px;padding:18px;border:1px solid #F3CCCC;border-radius:12px}.danger-zone h3{display:flex;align-items:center;gap:7px;color:var(--color-danger)}.danger-zone p{margin-top:6px;color:var(--color-text-secondary)}.status-form{margin-top:18px}.full-width{width:100%}@media(max-width:760px){.page-container{padding:18px}.risk-summary{grid-template-columns:1fr}.danger-zone{align-items:flex-start;flex-direction:column}}
</style>
+193 -60
View File
@@ -1,103 +1,170 @@
<template>
<div class="page-container">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>用户管理</span>
<el-input v-model="keyword" placeholder="搜索用户ID" clearable style="width:200px" />
<el-select v-model="accountStatus" placeholder="账号状态" clearable style="width:140px">
<el-option label="正常" value="normal" />
<el-option label="封禁" value="banned" />
<el-option label="暂停" value="suspended" />
<el-option label="风险审核中" value="reviewing_risk" />
</el-select>
<el-button type="primary" @click="loadData" :loading="loading">查询</el-button>
</div>
</template>
<div class="page-container page-shell">
<header class="page-heading">
<div>
<span class="page-kicker">用户与资料审核</span>
<h1>用户管理</h1>
<p>仅按当前角色权限查询用户,手机号等敏感信息默认脱敏。</p>
</div>
<el-tag type="info" effect="plain">共 {{ total }} 人</el-tag>
</header>
<el-table :data="tableData" v-loading="loading" border stripe>
<el-table-column prop="id" label="用户ID" width="140" show-overflow-tooltip />
<el-table-column prop="displayName" label="姓名" width="120" />
<el-table-column prop="gender" label="性别" width="60">
<template #default="{row}">{{ row.gender === 'male' ? '男' : row.gender === 'female' ? '女' : '-' }}</template>
<section class="surface-card filter-panel" aria-label="用户筛选">
<el-input
v-model.trim="keyword"
class="keyword-input"
clearable
aria-label="用户编号"
placeholder="输入用户编号"
@keyup.enter="search"
>
<template #prefix><el-icon><Search /></el-icon></template>
</el-input>
<el-select v-model="accountStatus" clearable placeholder="全部账号状态" aria-label="账号状态">
<el-option label="正常" value="normal" />
<el-option label="封禁" value="banned" />
<el-option label="暂停" value="suspended" />
<el-option label="风险审核中" value="reviewing_risk" />
</el-select>
<el-button type="primary" :loading="loading" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</section>
<section class="surface-card table-panel">
<el-alert
v-if="errorMessage"
:title="errorMessage"
type="error"
:closable="false"
show-icon
>
<template #default><el-button link type="primary" @click="loadData">重新加载</el-button></template>
</el-alert>
<el-table v-else :data="tableData" v-loading="loading" row-key="id">
<el-table-column prop="id" label="用户编号" min-width="170" show-overflow-tooltip />
<el-table-column prop="displayName" label="显示名称" min-width="110">
<template #default="{ row }">{{ row.displayName || '未设置' }}</template>
</el-table-column>
<el-table-column prop="age" label="年龄" width="60" />
<el-table-column prop="phoneMasked" label="手机尾号" width="120" />
<el-table-column prop="regionName" label="区域" width="100" />
<el-table-column prop="accountStatus" label="账号状态" width="100">
<template #default="{row}">
<el-tag :type="accountStatusType(row.accountStatus)">{{ accountStatusText(row.accountStatus) }}</el-tag>
<el-table-column prop="gender" label="性别" width="70">
<template #default="{ row }">{{ genderText(row.gender) }}</template>
</el-table-column>
<el-table-column prop="age" label="年龄" width="70">
<template #default="{ row }">{{ row.age || '-' }}</template>
</el-table-column>
<el-table-column prop="phoneMasked" label="手机号" min-width="130">
<template #default="{ row }">{{ row.phoneMasked || '未绑定' }}</template>
</el-table-column>
<el-table-column prop="regionName" label="区域" min-width="100">
<template #default="{ row }">{{ row.regionName || '-' }}</template>
</el-table-column>
<el-table-column prop="accountStatus" label="账号状态" min-width="105">
<template #default="{ row }">
<el-tag :type="accountStatusType(row.accountStatus)" effect="light">
{{ accountStatusText(row.accountStatus) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="riskLevel" label="风险等级" width="100">
<template #default="{row}">
<el-tag :type="riskLevelType(row.riskLevel)">{{ riskLevelText(row.riskLevel) }}</el-tag>
<el-table-column prop="riskLevel" label="风险等级" min-width="105">
<template #default="{ row }">
<el-tag :type="riskLevelType(row.riskLevel)" effect="plain">
{{ riskLevelText(row.riskLevel) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="registeredAt" label="注册时间" width="180">
<template #default="{row}">{{ formatTime(row.registeredAt) }}</template>
<el-table-column prop="registeredAt" label="注册时间" min-width="170">
<template #default="{ row }">{{ formatTime(row.registeredAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="80">
<template #default="{row}">
<el-button size="small" @click="goDetail(row.id)">查看</el-button>
<el-table-column label="操作" width="86" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="goDetail(row.id)">查看详情</el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="没有找到符合条件的用户" />
</template>
</el-table>
<el-pagination v-if="total > 0" style="margin-top:16px;text-align:right"
layout="total, prev, pager, next" :total="total" :page-size="pageSize"
:current-page="page" @current-change="handlePageChange" />
</el-card>
<el-pagination
v-if="!errorMessage && total > 0"
class="pagination"
layout="total, prev, pager, next"
:total="total"
:page-size="pageSize"
:current-page="page + 1"
@current-change="handlePageChange"
/>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { Search } from '@element-plus/icons-vue'
import { get } from '@/api/client'
const router = useRouter()
const loading = ref(false)
const errorMessage = ref('')
const keyword = ref('')
const accountStatus = ref('')
const tableData = ref<any[]>([])
const total = ref(0)
const page = ref(0)
const pageSize = ref(20)
const pageSize = 20
const ACCOUNT_MAP: Record<string, { text: string; type: string }> = {
normal: { text: '正常', type: 'success' },
banned: { text: '封禁', type: 'danger' },
suspended: { text: '暂停', type: 'warning' },
reviewing_risk: { text: '风险审核', type: 'danger' },
limited: { text: '受限', type: 'info' }
limited: { text: '受限', type: 'info' },
}
const RISK_MAP: Record<string, { text: string; type: string }> = {
normal: { text: '正常', type: 'info' },
watch: { text: '关注', type: '' },
watch: { text: '关注', type: 'warning' },
high: { text: '高风险', type: 'warning' },
critical: { text: '极高风险', type: 'danger' }
critical: { text: '极高风险', type: 'danger' },
}
async function loadData() {
loading.value = true
errorMessage.value = ''
try {
const token = localStorage.getItem('accessToken')
const res: any = await get('/operator/users', {
const response: any = await get('/operator/users', {
keyword: keyword.value || undefined,
accountStatus: accountStatus.value || undefined,
page: page.value,
pageSize: pageSize.value
}, { Authorization: `Bearer ${token}` })
tableData.value = res.data?.list || []
total.value = res.data?.total || 0
} catch (e) { console.error(e) }
finally { loading.value = false }
pageSize,
})
tableData.value = response.data?.list || []
total.value = response.data?.total || 0
} catch (error: any) {
tableData.value = []
total.value = 0
errorMessage.value = error.response?.status === 403
? '您没有查看用户列表的权限'
: '用户数据加载失败,请检查服务状态后重试'
} finally {
loading.value = false
}
}
function handlePageChange(p: number) {
page.value = p - 1
function search() {
page.value = 0
loadData()
}
function resetFilters() {
keyword.value = ''
accountStatus.value = ''
search()
}
function handlePageChange(current: number) {
page.value = current - 1
loadData()
}
@@ -105,16 +172,82 @@ function goDetail(id: string) {
router.push({ path: '/users/detail', query: { id } })
}
function accountStatusType(s: string) { return ACCOUNT_MAP[s]?.type || 'info' }
function accountStatusText(s: string) { return ACCOUNT_MAP[s]?.text || s }
function riskLevelType(l: string) { return RISK_MAP[l]?.type || 'info' }
function riskLevelText(l: string) { return RISK_MAP[l]?.text || l }
function formatTime(t?: string) { return t ? new Date(t).toLocaleString('zh-CN') : '-' }
function genderText(gender?: string) {
return gender === 'male' ? '男' : gender === 'female' ? '女' : '-'
}
function accountStatusType(status: string) {
return ACCOUNT_MAP[status]?.type || 'info'
}
function accountStatusText(status: string) {
return ACCOUNT_MAP[status]?.text || status || '-'
}
function riskLevelType(level: string) {
return RISK_MAP[level]?.type || 'info'
}
function riskLevelText(level: string) {
return RISK_MAP[level]?.text || level || '-'
}
function formatTime(time?: string) {
return time ? new Date(time).toLocaleString('zh-CN') : '-'
}
onMounted(loadData)
</script>
<style scoped>
.page-container { padding: 24px; }
.card-header { display: flex; align-items: center; gap: 12px; }
.page-container {
padding: 28px;
}
.page-kicker {
color: var(--admin-primary);
font-size: 12px;
font-weight: 650;
letter-spacing: 1px;
}
.filter-panel {
display: flex;
flex-wrap: wrap;
gap: 12px;
padding: 18px;
}
.keyword-input {
width: 260px;
}
.filter-panel .el-select {
width: 180px;
}
.table-panel {
margin-top: 16px;
padding: 20px;
}
.table-panel .el-alert {
margin-bottom: 12px;
}
.pagination {
justify-content: flex-end;
margin-top: 18px;
}
@media (max-width: 760px) {
.page-container {
padding: 18px;
}
.keyword-input,
.filter-panel .el-select {
width: 100%;
}
}
</style>
+6 -1
View File
@@ -1,4 +1,4 @@
import { createRouter, createWebHistory } from 'vue-router'
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
@@ -18,6 +18,9 @@ const routes: RouteRecordRaw[] = [
children: [
{ path: 'dashboard', name: 'Dashboard', component: () => import('@/views/DashboardView.vue'), meta: { title: '工作台' } },
{ path: 'review', name: 'Review', component: () => import('@/modules/review/ReviewView.vue'), meta: { title: '资料审核', permissions: ['review.view'] } },
// V1.1 Phase 14:活动管理(类型化活动 + 动态表单 + 多组织 + 容量分组)
{ path: 'activities', name: 'ActivityManagement', component: () => import('@/modules/activity/ActivityManagementView.vue'), meta: { title: '活动管理', permissions: ['activity.view'] } },
{ path: 'activities/checkin', name: 'ActivityCheckin', component: () => import('@/modules/activity/ActivityCheckinView.vue'), meta: { title: '活动签到', permissions: ['activity.checkin'] } },
{ path: 'activities/interests', name: 'ActivityInterests', component: () => import('@/modules/activity/ActivityInterestView.vue'), meta: { title: '活动互选管理', permissions: ['activity.review_registration'] } },
{ path: 'risk/reports', name: 'ReportCenter', component: () => import('@/modules/risk/ReportCenterView.vue'), meta: { title: '举报中心', permissions: ['risk.report.view'] } },
{ path: 'appeals', name: 'AppealCenter', component: () => import('@/modules/appeal/AppealCenterView.vue'), meta: { title: '申诉管理', permissions: ['risk.report.process'] } },
@@ -35,6 +38,8 @@ const routes: RouteRecordRaw[] = [
{ path: 'service-program', name: 'ServiceProgram', component: () => import('@/modules/service-program/ServiceProgramView.vue'), meta: { title: '专项服务项目', permissions: ['service_program.manage'] } },
{ path: 'counseling', name: 'Counseling', component: () => import('@/modules/counseling/CounselingView.vue'), meta: { title: '婚恋咨询', permissions: ['counseling.service.view'] } },
{ path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['referral.manage'] } },
{ path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.service.view'] } },
],
},
]
+169
View File
@@ -0,0 +1,169 @@
import client from '@/api/client'
import type {
DashboardActivity,
DashboardEffectivenessItem,
DashboardMetric,
DashboardModel,
DashboardOverviewItem,
DashboardTodo,
} from '@/types/dashboard'
type JsonRecord = Record<string, unknown>
interface ApiEnvelope<T> {
success: boolean
data: T
message?: string
}
const REQUEST_TIMEOUT = 8000
const numberOf = (source: JsonRecord | undefined, key: string) => {
const value = source?.[key]
return typeof value === 'number' && Number.isFinite(value) ? value : 0
}
const recordOf = (source: JsonRecord | undefined, key: string): JsonRecord => {
const value = source?.[key]
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {}
}
const dataOf = <T>(response: unknown): T | null => {
const envelope = response as ApiEnvelope<T> | null
return envelope?.success && envelope.data != null ? envelope.data : null
}
const fulfilledData = <T>(result: PromiseSettledResult<unknown>): T | null =>
result.status === 'fulfilled' && result.value ? dataOf<T>(result.value) : null
const emptyMetric = (
key: string,
label: string,
tone: DashboardMetric['tone'],
route: string,
): DashboardMetric => ({ key, label, value: 0, trendText: '暂无授权数据', tone, available: false, route })
export function createEmptyDashboard(notice = '当前账号暂无可展示的工作台数据'): DashboardModel {
return {
metrics: [
emptyMetric('pending_reviews', '待审核资料', 'rose', '/review'),
emptyMetric('matchmaker_todos', '红娘服务待办', 'amber', '/referral'),
emptyMetric('counseling_todos', '咨询服务待办', 'blue', '/counseling'),
emptyMetric('station_appointments', '服务站预约', 'green', '/service-appointment'),
emptyMetric('effective_matches', '有效匹配', 'purple', '/analytics'),
emptyMetric('activity_registrations', '活动报名', 'red', '/activities/interests'),
],
todos: [],
activities: [],
serviceOverview: [],
effectiveness: [],
activitiesAvailable: false,
notice,
}
}
export async function loadDashboard(permissions: string[]): Promise<DashboardModel> {
const canViewAnalytics = permissions.includes('analytics.view')
const canViewActivities = permissions.includes('activity.view')
const [coreResult, serviceResult, activityResult] = await Promise.allSettled([
canViewAnalytics
? client.get('/operator/analytics/dashboard', { timeout: REQUEST_TIMEOUT })
: Promise.resolve(null),
canViewAnalytics
? client.get('/operator/analytics/v11-dashboard', { timeout: REQUEST_TIMEOUT })
: Promise.resolve(null),
canViewActivities
? client.get('/operator/activities', { timeout: REQUEST_TIMEOUT })
: Promise.resolve(null),
])
const core = fulfilledData<JsonRecord>(coreResult)
const service = fulfilledData<JsonRecord>(serviceResult)
const activityRows = fulfilledData<JsonRecord[]>(activityResult)
const analyticsAvailable = Boolean(core || service)
const eligibility = recordOf(service || undefined, 'localEligibility')
const counseling = recordOf(service || undefined, 'counseling')
const appointments = recordOf(service || undefined, 'serviceAppointments')
const effectiveConnections = recordOf(service || undefined, 'effectiveConnections')
const registrationSources = recordOf(service || undefined, 'registrationSources')
const uniqueUsers = recordOf(service || undefined, 'uniqueServiceUsers')
const metrics: DashboardMetric[] = [
{
key: 'pending_reviews', label: '待审核资料', value: numberOf(eligibility, 'pending'),
trendText: service ? '本地资格待核验' : '暂无授权数据', tone: 'rose', available: Boolean(service), route: '/review',
},
{
key: 'matchmaker_todos', label: '红娘服务待办', value: 0,
trendText: '当前接口未提供待办数', tone: 'amber', available: false, route: '/referral',
},
{
key: 'counseling_todos', label: '咨询服务待办',
value: numberOf(counseling, 'pending') + numberOf(counseling, 'assigned'),
trendText: service ? '待受理与已分配' : '暂无授权数据', tone: 'blue', available: Boolean(service), route: '/counseling',
},
{
key: 'station_appointments', label: '服务站预约',
value: numberOf(appointments, 'submitted') + numberOf(appointments, 'confirmed'),
trendText: service ? '待确认与已确认' : '暂无授权数据', tone: 'green', available: Boolean(service), route: '/service-appointment',
},
{
key: 'effective_matches', label: '有效匹配', value: numberOf(effectiveConnections, 'activeMatches'),
trendText: service ? '当前活跃匹配' : '暂无授权数据', tone: 'purple', available: Boolean(service), route: '/analytics',
},
{
key: 'activity_registrations', label: '活动报名', value: numberOf(registrationSources, 'totalRegistrations'),
trendText: service ? '累计有效报名' : '暂无授权数据', tone: 'red', available: Boolean(service), route: '/activities/interests',
},
]
const todos = ([
{ key: 'review', title: '资料审核', description: '本地资格材料等待核验', count: metrics[0].value, actionText: '立即审核', route: '/review', tone: 'rose', available: metrics[0].available },
{ key: 'counseling', title: '咨询预约', description: '咨询申请等待受理或跟进', count: metrics[2].value, actionText: '查看服务', route: '/counseling', tone: 'blue', available: metrics[2].available },
{ key: 'appointment', title: '到站预约', description: '线下服务预约等待确认', count: metrics[3].value, actionText: '管理预约', route: '/service-appointment', tone: 'green', available: metrics[3].available },
{ key: 'risk', title: '举报处理', description: '风险举报等待工作人员处理', count: numberOf(core || undefined, 'pendingReports'), actionText: '去处理', route: '/risk/reports', tone: 'amber', available: Boolean(core) },
] as DashboardTodo[]).filter((item) => item.available)
const activities: DashboardActivity[] = (activityRows || []).slice(0, 4).map((item) => ({
id: String(item.id || ''),
title: String(item.title || '未命名活动'),
coverUrl: String(item.coverUrl || ''),
startAt: String(item.startAt || ''),
locationName: String(item.locationName || '地点待确认'),
registered: typeof item.registered === 'number' ? item.registered : null,
capacity: typeof item.totalLimit === 'number' ? item.totalLimit : null,
statusText: String(item.status || '状态待确认'),
}))
const serviceOverview = ([
{ key: 'registered', label: '平台注册用户', value: numberOf(core || undefined, 'totalUsers'), compareText: '累计注册', tone: 'rose', available: Boolean(core) },
{ key: 'approved', label: '资料审核通过', value: numberOf(core || undefined, 'approvedProfiles'), compareText: '已通过资料', tone: 'blue', available: Boolean(core) },
{ key: 'served', label: '公益服务用户', value: numberOf(uniqueUsers, 'totalUniqueServiceUsers'), compareText: '去重服务人数', tone: 'green', available: Boolean(service) },
{ key: 'success', label: '成功牵手', value: numberOf(core || undefined, 'successPairs'), compareText: '双方确认成功', tone: 'amber', available: Boolean(core) },
] as DashboardOverviewItem[]).filter((item) => item.available)
const effectiveness = ([
{ key: 'profile_review', label: '资料审核通过', value: numberOf(core || undefined, 'approvedProfiles'), max: numberOf(core || undefined, 'totalUsers'), unit: '人', available: Boolean(core) },
{ key: 'successful_match', label: '成功匹配用户', value: numberOf(core || undefined, 'totalMatches'), max: numberOf(core || undefined, 'totalLikes'), unit: '对', available: Boolean(core) },
{ key: 'counseling_completed', label: '咨询服务完成', value: numberOf(counseling, 'completed'), max: numberOf(counseling, 'total'), unit: '人次', available: Boolean(service) },
{ key: 'activity_participation', label: '活动参与人数', value: numberOf(registrationSources, 'totalRegistrations'), max: 0, unit: '人次', available: Boolean(service) },
{ key: 'offline_success', label: '线下牵手成功', value: numberOf(effectiveConnections, 'successConnections'), max: numberOf(effectiveConnections, 'activeMatches'), unit: '对', available: Boolean(service) },
] as DashboardEffectivenessItem[]).filter((item) => item.available)
const rejectedCount = [coreResult, serviceResult, activityResult].filter((item) => item.status === 'rejected').length
const notice = !analyticsAvailable && !activityRows
? (rejectedCount ? '工作台数据加载失败,请稍后重试' : '当前账号暂无工作台统计权限')
: (rejectedCount ? '部分模块暂时不可用,其余数据已正常加载' : '')
return {
metrics,
todos,
activities,
serviceOverview,
effectiveness,
activitiesAvailable: Boolean(activityRows),
notice,
}
}
+31 -5
View File
@@ -1,6 +1,25 @@
:root {
--color-primary: #E96B6B;
--color-primary-hover: #D95F5F;
--admin-coral: #f45f68;
--admin-coral-hover: #df505a;
--admin-coral-soft: #fff0f1;
--admin-shell-bg: rgba(255, 255, 255, 0.96);
--admin-sidebar-bg: #202d38;
--admin-sidebar-surface: #2b3a46;
--admin-sidebar-text: #D9E1E6;
--admin-sidebar-muted: #8FA1AC;
--admin-page-bg: #f7f9fb;
--admin-primary: var(--admin-coral);
--admin-primary-hover: var(--admin-coral-hover);
--admin-card-radius: 14px;
--admin-shell-radius: 22px;
--admin-shadow: 0 18px 48px rgba(57, 40, 45, 0.14);
--admin-header-bg: #FFFFFF;
--admin-soft-pink: var(--admin-coral-soft);
--admin-soft-blue: #EEF5FF;
--admin-soft-green: #EDF8F1;
--admin-soft-purple: #F4EFFF;
--color-primary: var(--admin-primary);
--color-primary-hover: var(--admin-primary-hover);
--color-primary-light: #FFF3F2;
--color-primary-lighter: #FFF8F7;
--color-success: #2E9B68;
@@ -18,7 +37,7 @@
--sidebar-width: 220px;
--header-height: 64px;
--border-radius-sm: 8px;
--border-radius-md: 12px;
--border-radius-md: var(--admin-card-radius);
--border-radius-lg: 16px;
--shadow-card: 0 8px 24px rgba(75, 55, 46, 0.06);
--el-color-primary: var(--color-primary);
@@ -34,6 +53,13 @@ html, body, #app {
background-color: var(--color-bg-page);
}
body { min-width: 1100px; }
button:focus-visible,
a:focus-visible,
input:focus-visible,
[tabindex]:focus-visible {
outline: 3px solid rgba(238, 102, 107, 0.32);
outline-offset: 2px;
}
.el-button--primary {
--el-button-bg-color: var(--color-primary);
--el-button-border-color: var(--color-primary);
@@ -45,7 +71,7 @@ body { min-width: 1100px; }
border-color: var(--color-primary);
}
.el-card { --el-card-border-radius: var(--border-radius-md); --el-card-border-color: var(--color-border-light); }
.el-table { --el-table-header-bg-color: #FCFAF8; --el-table-border-color: var(--color-border-light); }
.el-table { --el-table-header-bg-color: #FBFAF8; --el-table-border-color: var(--color-border-light); }
.el-tag { border-radius: 999px; }
.page-container { padding: 24px; }
.page-shell { max-width: 1440px; margin: 0 auto; }
@@ -64,4 +90,4 @@ body { min-width: 1100px; }
.mb-lg { margin-bottom: 24px; }
.flex-center { display: flex; align-items: center; justify-content: center; }
@media (max-width: 1200px) { body { min-width: 0; } }
@media (max-width: 900px) { .page-container { padding: 16px; } .page-heading { align-items: flex-start; flex-direction: column; } }
@media (max-width: 900px) { .page-container { padding: 16px; } .page-heading { align-items: flex-start; flex-direction: column; } }
+61
View File
@@ -0,0 +1,61 @@
export type DashboardTone = 'rose' | 'amber' | 'blue' | 'green' | 'purple' | 'red'
export interface DashboardMetric {
key: string
label: string
value: number
trendText: string
tone: DashboardTone
available: boolean
route: string
}
export interface DashboardTodo {
key: string
title: string
description: string
count: number
actionText: string
route: string
tone: DashboardTone
available: boolean
}
export interface DashboardActivity {
id: string
title: string
coverUrl: string
startAt: string
locationName: string
registered: number | null
capacity: number | null
statusText: string
}
export interface DashboardOverviewItem {
key: string
label: string
value: number
compareText: string
tone: DashboardTone
available: boolean
}
export interface DashboardEffectivenessItem {
key: string
label: string
value: number
max: number
unit: string
available: boolean
}
export interface DashboardModel {
metrics: DashboardMetric[]
todos: DashboardTodo[]
activities: DashboardActivity[]
serviceOverview: DashboardOverviewItem[]
effectiveness: DashboardEffectivenessItem[]
activitiesAvailable: boolean
notice: string
}
+727 -27
View File
@@ -1,34 +1,734 @@
<template>
<div class="page-container page-shell dashboard" v-loading="loading">
<section class="welcome">
<div><div class="eyebrow">{{ profile.badge }}</div><h1>{{ greeting }},{{ operatorName }}</h1><p>{{ profile.subtitle }}</p></div>
<div class="date-card"><span>{{ today }}</span><strong>今日服务</strong></div>
<div class="dashboard-page">
<section class="dashboard-welcome">
<div>
<h1>你好,{{ operatorName }}</h1>
<p>用专业的服务,帮助更多人遇见对的人。今天也是传递幸福的一天!</p>
</div>
<div class="dashboard-date">
<span>{{ today }}</span>
<small>丹阳 · 公益服务</small>
</div>
</section>
<section class="metrics">
<article v-for="item in metrics" :key="item.label" class="metric-card surface-card"><div class="metric-top"><span>{{item.label}}</span><span class="metric-icon" :class="item.tone">{{item.icon}}</span></div><strong>{{item.value}}</strong><small>{{item.hint}}</small></article>
</section>
<section class="dashboard-grid">
<article class="surface-card focus-card"><header><div><span class="section-kicker">今日优先</span><h2>{{profile.focusTitle}}</h2></div><el-button type="primary" plain @click="go(profile.primaryPath)">{{profile.primaryAction}}</el-button></header><div class="task-list"><button v-for="task in profile.tasks" :key="task.title" type="button" class="task-row" @click="go(task.path)"><span class="task-dot" :class="task.tone"></span><span class="task-copy"><strong>{{task.title}}</strong><small>{{task.desc}}</small></span><span class="task-count">{{task.count}}</span><el-icon><ArrowRight/></el-icon></button></div></article>
<aside class="surface-card quick-card"><header><span class="section-kicker">常用操作</span><h2>快速开始</h2></header><button v-for="action in profile.actions" :key="action.label" type="button" class="quick-action" @click="go(action.path)"><span class="quick-icon">{{action.icon}}</span><span><strong>{{action.label}}</strong><small>{{action.desc}}</small></span><el-icon><ArrowRight/></el-icon></button><div class="privacy-note"><el-icon><Lock/></el-icon><span><strong>数据访问受保护</strong><small>仅可查看职责范围内的服务信息,敏感操作全程留痕。</small></span></div></aside>
</section>
<section class="surface-card service-overview"><header><div><span class="section-kicker">公益服务概况</span><h2>本周服务进展</h2></div><span class="preview-label" v-if="preview">界面预览数据</span></header><div class="progress-grid"><div v-for="item in progress" :key="item.label" class="progress-item"><div><span>{{item.label}}</span><strong>{{item.value}}</strong></div><el-progress :percentage="item.percent" :stroke-width="8" :show-text="false" :color="item.color"/><small>{{item.note}}</small></div></div></section>
<div v-if="loading" class="dashboard-loading" aria-label="工作台加载中">
<el-skeleton v-for="index in 6" :key="index" animated>
<template #template><el-skeleton-item variant="rect" class="metric-skeleton" /></template>
</el-skeleton>
</div>
<template v-else>
<div v-if="fatalError" class="dashboard-error" role="alert">
<el-icon><Warning /></el-icon>
<span><strong>工作台数据加载失败</strong><small>{{ fatalError }}</small></span>
<el-button type="primary" plain @click="retryLoad">重新加载</el-button>
</div>
<div v-else-if="isForbidden" class="dashboard-forbidden">
<el-icon><Lock /></el-icon>
<span><strong>当前账号暂无统计权限</strong><small>仅展示您职责范围内可访问的业务入口。</small></span>
</div>
<div v-else-if="dashboard.notice" class="dashboard-notice">
<el-icon><InfoFilled /></el-icon><span>{{ dashboard.notice }}</span>
</div>
<section class="dashboard-metrics" aria-label="工作台核心指标">
<button
v-for="metric in dashboard.metrics"
:key="metric.key"
type="button"
class="metric-card"
:class="[`tone-${metric.tone}`, { unavailable: !metric.available }]"
@click="goTo(metric.route)"
>
<span class="metric-icon"><el-icon><component :is="metricIcon(metric.key)" /></el-icon></span>
<span class="metric-copy">
<small>{{ metric.label }}</small>
<strong>{{ metric.available ? formatNumber(metric.value) : '—' }}</strong>
<em>{{ metric.trendText }}</em>
</span>
<el-icon class="metric-arrow"><ArrowRight /></el-icon>
</button>
</section>
<section class="dashboard-grid dashboard-grid-top">
<article class="dashboard-panel todo-panel">
<header class="panel-header">
<div><h2>今日待办事项 <span>{{ todoCount }}</span></h2></div>
<button type="button" @click="goTo('/review')">查看更多 <el-icon><ArrowRight /></el-icon></button>
</header>
<div v-if="dashboard.todos.length" class="todo-list">
<button
v-for="item in dashboard.todos"
:key="item.key"
type="button"
class="todo-row"
@click="goTo(item.route)"
>
<span class="todo-icon" :class="`tone-${item.tone}`"><el-icon><component :is="todoIcon(item.key)" /></el-icon></span>
<span class="todo-copy"><strong>{{ item.title }}</strong><small>{{ item.description }}</small></span>
<b>{{ item.count }}</b>
<em>{{ item.actionText }}</em>
</button>
</div>
<div v-else class="dashboard-empty"><el-icon><CircleCheck /></el-icon><span>当前没有授权范围内的待办事项</span></div>
</article>
<article class="dashboard-panel activity-panel">
<header class="panel-header">
<div><h2>近期公益活动</h2></div>
<button type="button" @click="goTo('/activities/interests')">查看更多 <el-icon><ArrowRight /></el-icon></button>
</header>
<div v-if="dashboard.activities.length" class="activity-list">
<button
v-for="activity in dashboard.activities"
:key="activity.id"
type="button"
class="activity-row"
@click="goTo('/activities/interests')"
>
<img
:src="activity.coverUrl || activityFallback"
:alt="activity.title"
@error="useActivityFallback"
/>
<span class="activity-copy">
<strong>{{ activity.title }}</strong>
<small>{{ formatDateTime(activity.startAt) }} · {{ activity.locationName }}</small>
</span>
<span class="activity-status">{{ statusLabel(activity.statusText) }}</span>
<em>{{ activityRegistration(activity) }}</em>
</button>
</div>
<div v-else class="dashboard-empty">
<el-icon><Calendar /></el-icon>
<span>{{ dashboard.activitiesAvailable ? '近期暂无公益活动' : '当前账号无活动列表权限' }}</span>
</div>
</article>
</section>
<section class="dashboard-grid dashboard-grid-bottom">
<article class="dashboard-panel service-overview-panel">
<header class="panel-header"><div><h2>服务数据概览</h2></div><span>当前累计</span></header>
<div v-if="dashboard.serviceOverview.length" class="overview-grid">
<div v-for="item in dashboard.serviceOverview" :key="item.key" :class="`tone-${item.tone}`">
<small>{{ item.label }}</small>
<strong>{{ formatNumber(item.value) }}</strong>
<em>{{ item.compareText }}</em>
</div>
</div>
<div v-else class="dashboard-empty"><el-icon><DataAnalysis /></el-icon><span>暂无可查看的服务统计</span></div>
</article>
<article class="dashboard-panel effectiveness-panel">
<header class="panel-header"><div><h2>项目服务成效</h2></div><span>实时数据</span></header>
<div v-if="dashboard.effectiveness.length" class="effectiveness-list">
<div v-for="item in dashboard.effectiveness" :key="item.key" class="effectiveness-row">
<span>{{ item.label }}</span>
<div><i :style="{ width: `${item.max > 0 ? Math.min(100, item.value / item.max * 100) : 0}%` }"></i></div>
<strong>{{ formatNumber(item.value) }} {{ item.unit }}</strong>
</div>
</div>
<div v-else class="dashboard-empty"><el-icon><TrendCharts /></el-icon><span>暂无可查看的成效数据</span></div>
</article>
</section>
</template>
</div>
</template>
<script setup lang="ts">
import {computed,onMounted,ref} from 'vue';import {useRouter} from 'vue-router';import {ArrowRight,Lock} from '@element-plus/icons-vue';import {get} from '@/api/client';import {useAuthStore} from '@/stores/auth'
type Task={title:string;desc:string;count:number;tone:string;path:string};type Action={label:string;desc:string;icon:string;path:string};type Profile={badge:string;subtitle:string;focusTitle:string;primaryAction:string;primaryPath:string;tasks:Task[];actions:Action[]}
const router=useRouter();const auth=useAuthStore();const loading=ref(false);const preview=ref(false);const stats=ref<Record<string,number>>({})
const operatorName=computed(()=>auth.operator?.name||'工作人员');const today=new Intl.DateTimeFormat('zh-CN',{month:'long',day:'numeric',weekday:'long'}).format(new Date());const greeting=computed(()=>new Date().getHours()<12?'上午好':new Date().getHours()<18?'下午好':'晚上好')
const p=computed(()=>auth.permissions||[]);const has=(code:string)=>p.value.includes(code)
const profile=computed<Profile>(()=>{if(has('counseling.service.view'))return{badge:'婚恋咨询工作台',subtitle:'只处理明确分配给你的咨询服务,尊重每一位来访者的隐私。',focusTitle:'我的待办咨询',primaryAction:'进入咨询服务',primaryPath:'/counseling',tasks:[{title:'待首次联系',desc:'请在服务时限内完成首次沟通',count:stats.value.pendingCounseling||3,tone:'rose',path:'/counseling'},{title:'今日预约',desc:'确认时间与服务方式',count:stats.value.todayAppointments||2,tone:'amber',path:'/counseling'},{title:'待补充记录',desc:'仅记录必要的服务事实',count:1,tone:'blue',path:'/counseling'}],actions:[{label:'查看我的咨询',desc:'仅限已分配服务',icon:'咨',path:'/counseling'},{label:'转介协作',desc:'需要其他专业支持',icon:'转',path:'/referral'}]};if(has('service_appointment.view'))return{badge:'服务站工作台',subtitle:'聚焦今天的现场预约与指定服务,不浏览全平台用户库。',focusTitle:'今日到站服务',primaryAction:'管理预约',primaryPath:'/service-appointment',tasks:[{title:'待确认预约',desc:'确认服务时间与到站方式',count:stats.value.pendingAppointments||6,tone:'rose',path:'/service-appointment'},{title:'即将到站',desc:'提前准备现场服务资料',count:4,tone:'green',path:'/service-appointment'},{title:'待完成回访',desc:'记录服务结果与后续安排',count:2,tone:'blue',path:'/service-appointment'}],actions:[{label:'预约管理',desc:'查看今日预约',icon:'约',path:'/service-appointment'},{label:'服务站信息',desc:'维护开放时间与项目',icon:'站',path:'/service-station'}]};if(has('review.view'))return{badge:'资料审核工作台',subtitle:'核验真实信息,帮助平台建立安全、可信的相遇环境。',focusTitle:'今日审核队列',primaryAction:'开始审核',primaryPath:'/review',tasks:[{title:'待初审资料',desc:'按提交时间依次处理',count:stats.value.pendingReviews||18,tone:'rose',path:'/review'},{title:'待复核资料',desc:'关注证件与资料一致性',count:5,tone:'amber',path:'/review'},{title:'待补充材料',desc:'清晰说明需要用户补充的内容',count:7,tone:'blue',path:'/review'}],actions:[{label:'进入资料审核',desc:'处理待审资料',icon:'审',path:'/review'},{label:'用户查询',desc:'按权限查询用户',icon:'查',path:'/users'}]};return{badge:'公益服务工作台',subtitle:'从今天最重要的待办开始,让每一项服务都有回应。',focusTitle:'今日待办',primaryAction:'查看全部',primaryPath:'/dashboard',tasks:[{title:'待处理服务',desc:'请按服务时限及时响应',count:stats.value.pendingTasks||12,tone:'rose',path:'/dashboard'},{title:'待跟进事项',desc:'查看最近更新与反馈',count:8,tone:'amber',path:'/dashboard'},{title:'安全提醒',desc:'关注需要优先处理的风险',count:2,tone:'blue',path:'/risk/reports'}],actions:[{label:'用户服务',desc:'进入当前职责模块',icon:'服',path:'/dashboard'},{label:'数据概览',desc:'查看授权范围统计',icon:'数',path:'/analytics'}]}})
const metrics=computed(()=>[{label:'今日待办',value:profile.value.tasks.reduce((n,t)=>n+t.count,0),hint:'按优先级及时处理',icon:'待',tone:'rose'},{label:'本周已完成',value:stats.value.weekCompleted||46,hint:'比上周多 8 项',icon:'成',tone:'green'},{label:'服务中',value:stats.value.activeServices||23,hint:'均在正常服务时限内',icon:'服',tone:'blue'},{label:'需关注',value:stats.value.attention||2,hint:'包含临期与风险事项',icon:'醒',tone:'amber'}])
const progress=[{label:'资料审核及时率',value:'96%',percent:96,note:'目标 95%',color:'#2E9B68'},{label:'预约按时完成率',value:'91%',percent:91,note:'本周 31 次服务',color:'#4A7CD4'},{label:'服务回访完成率',value:'84%',percent:84,note:'还有 5 项待回访',color:'#E59A32'}]
function go(path:string){if(path)router.push(path)}
async function load(){if(auth.token==='ui-preview-only'){preview.value=true;return}loading.value=true;try{stats.value=(await get('/operator/analytics/dashboard')) as unknown as Record<string,number>}catch{preview.value=true}finally{loading.value=false}}
onMounted(load)
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
ArrowRight,
Calendar,
ChatDotRound,
CircleCheck,
DataAnalysis,
DocumentChecked,
House,
InfoFilled,
Link,
Lock,
StarFilled,
TrendCharts,
UserFilled,
Warning,
} from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
import { createEmptyDashboard, loadDashboard } from '@/services/dashboard'
import type { DashboardActivity, DashboardModel } from '@/types/dashboard'
const router = useRouter()
const authStore = useAuthStore()
const activityFallback = '/brand/activity-default.jpg'
const dashboard = ref<DashboardModel>(createEmptyDashboard())
const loading = ref(true)
const fatalError = ref('')
const permissions = computed(() => authStore.permissions || [])
const operatorName = computed(() => authStore.operator?.name || '管理员')
const isPreviewData = computed(() => authStore.token === 'ui-preview-only')
const hasAnyData = computed(() => dashboard.value.metrics.some((item) => item.available) || dashboard.value.activitiesAvailable)
const isForbidden = computed(() => !hasAnyData.value && !fatalError.value && !isPreviewData.value)
const todoCount = computed(() => dashboard.value.todos.reduce((total, item) => total + item.count, 0))
const today = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long',
}).format(new Date())
const metricIcons: Record<string, unknown> = {
pending_reviews: DocumentChecked,
matchmaker_todos: StarFilled,
counseling_todos: ChatDotRound,
station_appointments: House,
effective_matches: Link,
activity_registrations: UserFilled,
}
const todoIcons: Record<string, unknown> = {
review: DocumentChecked,
counseling: ChatDotRound,
appointment: Calendar,
risk: Warning,
}
function metricIcon(key: string) {
return metricIcons[key] || DataAnalysis
}
function todoIcon(key: string) {
return todoIcons[key] || CircleCheck
}
function formatNumber(value: number) {
return new Intl.NumberFormat('zh-CN').format(value)
}
function formatDateTime(value: string) {
if (!value) return '时间待确认'
return new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
month: 'numeric',
day: 'numeric',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(value))
}
function statusLabel(status: string) {
const labels: Record<string, string> = {
draft: '草稿',
registration_open: '报名中',
published: '报名中',
ongoing: '进行中',
completed: '已结束',
cancelled: '已取消',
}
return labels[status] || status
}
function activityRegistration(activity: DashboardActivity) {
if (activity.registered == null) return activity.capacity == null ? '报名数据未提供' : `名额 ${activity.capacity}`
return activity.capacity == null ? `已报名 ${activity.registered}` : `已报名 ${activity.registered}/${activity.capacity}`
}
function useActivityFallback(event: Event) {
const image = event.currentTarget as HTMLImageElement
if (!image.src.endsWith(activityFallback)) image.src = activityFallback
}
function canAccess(path: string) {
const matched = router.resolve(path).matched
const required = matched.flatMap((route) => (route.meta.permissions as string[] | undefined) || [])
return required.every((permission) => permissions.value.includes(permission))
}
function goTo(path: string) {
if (!path) return
if (!canAccess(path)) {
ElMessage.info('当前账号无权访问该模块')
return
}
router.push(path)
}
async function retryLoad() {
loading.value = true
fatalError.value = ''
try {
if (authStore.token === 'ui-preview-only') {
dashboard.value = createEmptyDashboard('界面预览模式不展示虚构业务数字')
return
}
dashboard.value = await loadDashboard(permissions.value)
if (!hasAnyData.value && dashboard.value.notice.includes('加载失败')) {
fatalError.value = dashboard.value.notice
}
} catch (error) {
fatalError.value = error instanceof Error ? error.message : '服务暂时不可用,请稍后重试'
} finally {
loading.value = false
}
}
onMounted(retryLoad)
</script>
<style scoped>
.dashboard{padding-top:28px;padding-bottom:40px}.welcome{min-height:150px;padding:30px 34px;display:flex;align-items:center;justify-content:space-between;border-radius:18px;background:linear-gradient(120deg,#FFF1EE,#FFF9F2 56%,#F4F8F6);border:1px solid #F6E4DE;position:relative;overflow:hidden}.welcome:after{content:'缘';position:absolute;right:130px;top:-54px;font-size:210px;font-weight:700;color:rgba(233,107,107,.07)}.eyebrow,.section-kicker{font-size:12px;color:var(--color-primary);font-weight:650;letter-spacing:1px}.welcome h1{margin-top:9px;font-size:28px}.welcome p{margin-top:10px;color:var(--color-text-secondary)}.date-card{z-index:1;padding:14px 18px;border-radius:13px;background:rgba(255,255,255,.75);border:1px solid rgba(255,255,255,.9);display:flex;flex-direction:column;text-align:right}.date-card span{font-size:12px;color:var(--color-text-tertiary)}.date-card strong{margin-top:4px;font-size:16px}.metrics{display:grid;grid-template-columns:repeat(4,1fr);gap:16px;margin-top:18px}.metric-card{padding:20px}.metric-top{display:flex;justify-content:space-between;color:var(--color-text-secondary)}.metric-icon{width:34px;height:34px;display:grid;place-items:center;border-radius:11px;font-weight:650}.metric-icon.rose{background:var(--color-primary-light);color:var(--color-primary)}.metric-icon.green{background:#ECF8F2;color:var(--color-success)}.metric-icon.blue{background:#EEF4FF;color:var(--color-info)}.metric-icon.amber{background:#FFF7E9;color:var(--color-warning)}.metric-card>strong{display:block;margin-top:8px;font-size:30px}.metric-card>small{display:block;margin-top:6px;color:var(--color-text-tertiary)}.dashboard-grid{display:grid;grid-template-columns:minmax(0,1.7fr) minmax(300px,.8fr);gap:18px;margin-top:18px}.focus-card,.quick-card,.service-overview{padding:24px}.focus-card header,.quick-card header,.service-overview header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}.focus-card h2,.quick-card h2,.service-overview h2{margin-top:5px;font-size:18px}.task-row,.quick-action{width:100%;border:0;background:transparent;display:flex;align-items:center;text-align:left;cursor:pointer}.task-row{min-height:76px;border-top:1px solid var(--color-border-light);gap:14px}.task-row:hover,.quick-action:hover{background:var(--color-primary-lighter)}.task-dot{width:9px;height:9px;border-radius:50%;background:var(--color-primary)}.task-dot.green{background:var(--color-success)}.task-dot.blue{background:var(--color-info)}.task-dot.amber{background:var(--color-warning)}.task-copy{flex:1;display:flex;flex-direction:column}.task-copy small,.quick-action small{margin-top:5px;color:var(--color-text-tertiary)}.task-count{font-size:22px;font-weight:650;color:var(--color-primary)}.quick-action{padding:15px 8px;gap:12px;border-top:1px solid var(--color-border-light)}.quick-icon{width:38px;height:38px;display:grid;place-items:center;border-radius:12px;background:var(--color-primary-light);color:var(--color-primary);font-weight:650}.quick-action>span:nth-child(2){flex:1;display:flex;flex-direction:column}.privacy-note{margin-top:18px;padding:16px;display:flex;gap:10px;background:#F4F8F6;color:var(--color-success);border-radius:12px}.privacy-note span{display:flex;flex-direction:column}.privacy-note small{margin-top:4px;color:var(--color-text-secondary);line-height:1.5}.service-overview{margin-top:18px}.preview-label{font-size:11px;color:var(--color-warning);background:#FFF7E9;padding:5px 10px;border-radius:99px}.progress-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:30px}.progress-item>div{display:flex;justify-content:space-between;margin-bottom:12px}.progress-item strong{font-size:18px}.progress-item small{display:block;margin-top:8px;color:var(--color-text-tertiary)}
@media(max-width:1100px){.metrics{grid-template-columns:repeat(2,1fr)}.dashboard-grid{grid-template-columns:1fr}.progress-grid{grid-template-columns:1fr}.welcome:after{right:20px}}
@media(max-width:680px){.metrics{grid-template-columns:1fr}.welcome{padding:24px}.date-card{display:none}}
</style>
.dashboard-page {
min-width: 0;
max-width: 1500px;
margin: 0 auto;
padding: 20px 26px 28px;
}
.dashboard-welcome {
display: flex;
min-height: 66px;
align-items: center;
justify-content: space-between;
gap: 20px;
}
.dashboard-welcome h1 {
font-size: 24px;
line-height: 1.25;
}
.dashboard-welcome p {
margin-top: 6px;
color: #78828b;
font-size: 13px;
}
.dashboard-date {
display: flex;
align-items: center;
gap: 14px;
color: #7b858e;
font-size: 12px;
}
.dashboard-date small {
padding: 6px 10px;
color: #d75a63;
background: #fff0f1;
border-radius: 999px;
}
.dashboard-loading,
.dashboard-metrics {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 10px;
}
.dashboard-loading {
margin-top: 10px;
}
.metric-skeleton {
height: 105px;
border-radius: 12px;
}
.dashboard-error,
.dashboard-forbidden,
.dashboard-notice {
display: flex;
min-height: 48px;
margin: 8px 0 12px;
padding: 10px 14px;
align-items: center;
gap: 10px;
color: #6f5355;
background: #fff6f6;
border: 1px solid #f7dfe1;
border-radius: 11px;
}
.dashboard-error > span,
.dashboard-forbidden > span {
display: flex;
flex: 1;
flex-direction: column;
}
.dashboard-error small,
.dashboard-forbidden small {
margin-top: 3px;
color: #8c7d7e;
}
.dashboard-metrics {
margin-top: 10px;
}
.metric-card {
position: relative;
display: grid;
min-width: 0;
min-height: 108px;
padding: 14px 12px;
grid-template-columns: 38px 1fr;
align-items: center;
gap: 9px;
overflow: hidden;
text-align: left;
cursor: pointer;
background: #fff;
border: 1px solid rgba(235, 235, 238, 0.9);
border-radius: 12px;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.metric-card:hover {
box-shadow: 0 10px 24px rgba(68, 48, 55, 0.1);
transform: translateY(-2px);
}
.metric-card.unavailable {
cursor: default;
opacity: 0.72;
}
.metric-icon,
.todo-icon {
display: grid;
color: var(--tone-color);
background: var(--tone-soft);
border-radius: 50%;
place-items: center;
}
.metric-icon {
width: 38px;
height: 38px;
font-size: 19px;
}
.metric-copy {
display: flex;
min-width: 0;
flex-direction: column;
}
.metric-copy small {
overflow: hidden;
color: #55616a;
font-size: 11px;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-copy strong {
margin-top: 3px;
color: #171a1d;
font-size: 25px;
line-height: 1;
}
.metric-copy em {
margin-top: 6px;
overflow: hidden;
color: var(--tone-color);
font-size: 9px;
font-style: normal;
text-overflow: ellipsis;
white-space: nowrap;
}
.metric-arrow {
position: absolute;
right: 8px;
bottom: 8px;
color: var(--tone-color);
font-size: 11px;
}
.tone-rose { --tone-color: #f05e69; --tone-soft: #ffe9eb; }
.tone-amber { --tone-color: #e58b39; --tone-soft: #fff1df; }
.tone-blue { --tone-color: #568ce9; --tone-soft: #eaf2ff; }
.tone-green { --tone-color: #4aad76; --tone-soft: #e9f7ef; }
.tone-purple { --tone-color: #9c69df; --tone-soft: #f2eaff; }
.tone-red { --tone-color: #ed5b64; --tone-soft: #ffe9eb; }
.dashboard-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 14px;
margin-top: 14px;
}
.dashboard-panel {
min-width: 0;
padding: 14px 16px;
background: #fff;
border: 1px solid #e9edf0;
border-radius: var(--admin-card-radius);
box-shadow: 0 8px 20px rgba(48, 58, 67, 0.035);
}
.panel-header {
display: flex;
min-height: 30px;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid #f0f1f2;
}
.panel-header h2 {
padding-bottom: 10px;
font-size: 16px;
}
.panel-header h2 span {
display: inline-grid;
min-width: 20px;
height: 20px;
margin-left: 6px;
padding: 0 5px;
color: #fff;
font-size: 10px;
line-height: 20px;
background: var(--admin-coral);
border-radius: 999px;
place-items: center;
}
.panel-header button {
display: flex;
align-items: center;
gap: 4px;
color: #89929a;
font-size: 11px;
cursor: pointer;
background: transparent;
border: 0;
}
.panel-header > span {
color: #98a0a7;
font-size: 10px;
}
.todo-list,
.activity-list {
min-height: 190px;
}
.todo-row,
.activity-row {
display: flex;
width: 100%;
min-height: 47px;
padding: 6px 5px;
align-items: center;
gap: 9px;
text-align: left;
cursor: pointer;
background: transparent;
border: 0;
border-bottom: 1px solid #f2f3f4;
}
.todo-row:hover,
.activity-row:hover {
background: #fffafa;
}
.todo-icon {
width: 29px;
height: 29px;
flex: none;
}
.todo-copy,
.activity-copy {
display: flex;
min-width: 0;
flex: 1;
flex-direction: column;
}
.todo-copy strong,
.activity-copy strong {
overflow: hidden;
color: #30373d;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.todo-copy small,
.activity-copy small {
margin-top: 3px;
overflow: hidden;
color: #939ba2;
font-size: 9px;
text-overflow: ellipsis;
white-space: nowrap;
}
.todo-row > b {
min-width: 29px;
color: var(--admin-coral);
font-size: 13px;
text-align: center;
}
.todo-row > em {
color: var(--admin-coral);
font-size: 10px;
font-style: normal;
}
.activity-row img {
width: 72px;
height: 38px;
flex: none;
object-fit: cover;
border-radius: 7px;
}
.activity-status {
padding: 3px 6px;
color: #318b5a;
font-size: 9px;
background: #eaf7ef;
border-radius: 999px;
}
.activity-row > em {
min-width: 76px;
color: #7d878f;
font-size: 9px;
font-style: normal;
text-align: right;
}
.dashboard-empty {
display: flex;
min-height: 160px;
align-items: center;
justify-content: center;
flex-direction: column;
gap: 10px;
color: #a0a7ad;
font-size: 11px;
}
.dashboard-empty .el-icon {
color: #d7dce0;
font-size: 34px;
}
.overview-grid {
display: grid;
min-height: 118px;
padding-top: 12px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.overview-grid > div {
display: flex;
padding: 12px 10px;
align-items: center;
justify-content: center;
flex-direction: column;
background: var(--tone-soft);
border-radius: 8px;
}
.overview-grid small {
color: var(--tone-color);
font-size: 10px;
}
.overview-grid strong {
margin-top: 7px;
font-size: 24px;
}
.overview-grid em {
margin-top: 7px;
color: #8a9298;
font-size: 9px;
font-style: normal;
}
.effectiveness-list {
min-height: 118px;
padding-top: 9px;
}
.effectiveness-row {
display: grid;
min-height: 23px;
grid-template-columns: 92px 1fr 76px;
align-items: center;
gap: 9px;
font-size: 10px;
}
.effectiveness-row > span {
color: #747e87;
}
.effectiveness-row > div {
height: 8px;
overflow: hidden;
background: #fdebee;
border-radius: 999px;
}
.effectiveness-row i {
display: block;
height: 100%;
background: linear-gradient(90deg, #f56b75, #ef5a65);
border-radius: inherit;
}
.effectiveness-row strong {
color: #75808a;
font-size: 10px;
font-weight: 500;
text-align: right;
}
@media (max-width: 1450px), (max-height: 820px) {
.dashboard-page { padding: 12px 18px 20px; }
.dashboard-welcome { min-height: 54px; }
.dashboard-welcome h1 { font-size: 20px; }
.dashboard-welcome p { margin-top: 3px; font-size: 11px; }
.dashboard-metrics { margin-top: 6px; gap: 7px; }
.metric-card { min-height: 90px; padding: 10px 8px; grid-template-columns: 32px 1fr; gap: 6px; }
.metric-icon { width: 32px; height: 32px; font-size: 16px; }
.metric-copy strong { font-size: 21px; }
.dashboard-grid { margin-top: 9px; gap: 9px; }
.dashboard-panel { padding: 10px 12px; }
.todo-list, .activity-list { min-height: 152px; }
.todo-row, .activity-row { min-height: 38px; padding: 4px; }
.activity-row img { width: 58px; height: 31px; }
.dashboard-grid-bottom .dashboard-panel { min-height: 150px; }
.overview-grid, .effectiveness-list { min-height: 98px; }
.overview-grid > div { padding: 8px 6px; }
.overview-grid strong { font-size: 20px; }
}
@media (max-width: 1180px) {
.dashboard-loading,
.dashboard-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 880px) {
.dashboard-grid { grid-template-columns: 1fr; }
.dashboard-date { display: none; }
}
</style>
+243 -46
View File
@@ -1,42 +1,77 @@
<template>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<h1>丹阳公益婚恋服务平台</h1>
<p>工作人员登录</p>
<main class="login-shell">
<section class="brand-panel" aria-label="善缘丹阳公益婚恋服务平台">
<img class="brand-scene" src="/brand/login-illustration.jpg" alt="" />
<div class="brand-overlay">
<div class="brand-title">
<img src="/brand/logo-mark.png" alt="" />
<div>
<strong>善缘丹阳</strong>
<span>公益婚恋服务平台</span>
</div>
</div>
<div class="brand-message">
<p>公益 · 真实 · 安全 · 温暖</p>
<h1>让每一份真诚,都在丹阳得到回应</h1>
<span>用专业服务守护真实相识,以公益初心陪伴幸福成长。</span>
</div>
</div>
<el-form ref="formRef" :model="form" :rules="rules" class="login-form" @submit.prevent="handleLogin">
<el-form-item prop="username">
<el-input v-model="form.username" placeholder="用户名" prefix-icon="User" size="large" />
</el-form-item>
<el-form-item prop="password">
<el-input v-model="form.password" type="password" placeholder="密码" prefix-icon="Lock"
size="large" show-password @keyup.enter="handleLogin" />
</el-form-item>
<el-form-item>
<el-button type="primary" size="large" :loading="loading" style="width:100%;background:#E96B6B;border-color:#E96B6B"
@click="handleLogin">登录</el-button>
</el-form-item>
</el-form>
<div class="login-footer">
<p>丹阳公益婚恋服务平台 · 运营管理后台</p>
</section>
<section class="login-panel">
<div class="login-card">
<div class="login-header">
<span class="eyebrow">管理端 · 运营后台</span>
<h2>欢迎回来</h2>
<p>请使用工作人员账号登录</p>
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent="handleLogin">
<el-form-item label="用户名" prop="username">
<el-input v-model.trim="form.username" autocomplete="username" placeholder="请输入用户名" size="large">
<template #prefix><el-icon><User /></el-icon></template>
</el-input>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
autocomplete="current-password"
type="password"
placeholder="请输入密码"
size="large"
show-password
@keyup.enter="handleLogin"
>
<template #prefix><el-icon><Lock /></el-icon></template>
</el-input>
</el-form-item>
<el-button class="submit-button" type="primary" size="large" :loading="loading" @click="handleLogin">
登录管理后台
</el-button>
</el-form>
<div class="safety-note">
<el-icon><CircleCheck /></el-icon>
<span>敏感数据按权限访问,所有关键操作均记录审计日志。</span>
</div>
</div>
</div>
</div>
<footer>善缘丹阳 · 公益婚恋服务平台</footer>
</section>
</main>
</template>
<script setup lang="ts">
import { ref, reactive } from 'vue'
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import type { FormInstance } from 'element-plus'
import { CircleCheck, Lock, User } from '@element-plus/icons-vue'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const formRef = ref<FormInstance>()
const loading = ref(false)
const authStore = useAuthStore()
const form = reactive({ username: '', password: '' })
const rules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
@@ -44,30 +79,192 @@ const rules = {
}
async function handleLogin() {
if (!formRef.value) return
await formRef.value.validate(async (valid) => {
if (!valid) return
loading.value = true
try {
await authStore.login(form.username, form.password)
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (err: any) {
ElMessage.error(err.message || '登录失败,请检查用户名和密码')
} finally {
loading.value = false
}
})
if (!formRef.value || loading.value) return
const valid = await formRef.value.validate().catch(() => false)
if (!valid) return
loading.value = true
try {
await authStore.login(form.username, form.password)
ElMessage.success('登录成功')
router.push('/dashboard')
} catch (error: any) {
ElMessage.error(error.message || '登录失败,请检查用户名和密码')
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-container { width: 100%; height: 100vh; display: flex; align-items: center; justify-content: center;
background: linear-gradient(135deg, #FFF3F2 0%, #FFF8F7 100%); }
.login-card { width: 400px; padding: 40px; background: #fff; border-radius: 12px; box-shadow: 0 4px 24px rgba(233,107,107,0.1); }
.login-header { text-align: center; margin-bottom: 32px; }
.login-header h1 { font-size: 24px; color: #E96B6B; margin-bottom: 8px; }
.login-header p { font-size: 14px; color: #999; }
.login-form { margin-top: 24px; }
.login-footer { text-align: center; margin-top: 24px; font-size: 12px; color: #bbb; }
.login-shell {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(420px, 0.75fr);
min-height: 100vh;
background: #FFFFFF;
}
.brand-panel {
position: relative;
min-height: 100vh;
overflow: hidden;
background: var(--admin-soft-pink);
}
.brand-scene {
width: 100%;
height: 100%;
object-fit: cover;
}
.brand-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 48px 56px 70px;
}
.brand-title {
display: flex;
align-items: center;
gap: 14px;
}
.brand-title img {
width: 54px;
height: 54px;
}
.brand-title div {
display: flex;
flex-direction: column;
}
.brand-title strong {
color: var(--admin-primary);
font-size: 25px;
letter-spacing: 2px;
}
.brand-title span {
margin-top: 3px;
color: var(--color-text-secondary);
font-size: 12px;
}
.brand-message {
max-width: 590px;
padding: 26px 30px;
background: rgba(255, 255, 255, 0.86);
border: 1px solid rgba(255, 255, 255, 0.94);
border-radius: 18px;
backdrop-filter: blur(10px);
}
.brand-message p {
color: var(--admin-primary);
font-weight: 650;
letter-spacing: 3px;
}
.brand-message h1 {
margin-top: 12px;
font-size: clamp(28px, 3vw, 42px);
line-height: 1.25;
}
.brand-message span {
display: block;
margin-top: 14px;
color: var(--color-text-secondary);
line-height: 1.7;
}
.login-panel {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px;
background: #FFFFFF;
}
.login-card {
width: min(100%, 420px);
}
.login-header {
margin-bottom: 32px;
}
.eyebrow {
color: var(--admin-primary);
font-size: 12px;
font-weight: 650;
letter-spacing: 1px;
}
.login-header h2 {
margin-top: 10px;
font-size: 30px;
}
.login-header p {
margin-top: 8px;
color: var(--color-text-tertiary);
}
.submit-button {
width: 100%;
margin-top: 8px;
}
.safety-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 24px;
padding: 14px 16px;
color: var(--color-text-secondary);
font-size: 12px;
line-height: 1.6;
background: var(--admin-soft-green);
border-radius: 10px;
}
.safety-note .el-icon {
flex: none;
margin-top: 2px;
color: var(--color-success);
}
footer {
margin-top: 40px;
color: var(--color-text-tertiary);
font-size: 12px;
}
@media (max-width: 900px) {
.login-shell {
grid-template-columns: 1fr;
}
.brand-panel {
min-height: 280px;
}
.brand-overlay {
padding: 28px;
}
.brand-message {
display: none;
}
.login-panel {
min-height: calc(100vh - 280px);
padding: 36px 24px;
}
}
</style>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 931 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

@@ -1,3 +0,0 @@
<!doctype html><html lang="zh-CN"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>善缘丹阳小程序首页预览</title><style>
*{box-sizing:border-box}body{margin:0;background:#ece9e6;font:14px -apple-system,BlinkMacSystemFont,"PingFang SC","Microsoft YaHei",sans-serif;color:#222}.phone{width:min(390px,100vw);min-height:844px;margin:28px auto;background:#FAFAF8;box-shadow:0 20px 60px #4b372f33;overflow:hidden}.top{height:76px;padding:14px 16px 10px;background:#fff;display:flex;align-items:center;border-bottom:1px solid #f0ede9;position:relative}.brand{flex:1}.brand strong{display:block;font-size:19px;color:#E96B6B}.brand small{color:#888}.pill{font-size:11px;color:#2E9B68;background:#ECF8F2;padding:5px 8px;border-radius:99px}.bell{margin-left:12px;font-size:12px}.preview{text-align:center;padding:4px;background:#FFF7E9;color:#B77B19;font-size:10px}.status{margin:8px 16px 0;padding:10px 12px;background:#fff;border:1px solid #e8e5e2;border-radius:12px;display:flex;justify-content:space-between;font-size:12px}.ok{color:#2E9B68}.banner{height:146px;margin:8px 16px 0;border-radius:16px;background:linear-gradient(135deg,#FFF3F2,#FBE8DE);padding:22px 16px;position:relative;overflow:hidden}.banner:after{content:'♡';position:absolute;right:8px;top:-36px;font-size:150px;color:#e96b6b26}.banner h1{font-size:20px;margin:0;color:#6F3A35;width:70%;line-height:1.35}.banner p{font-size:12px;color:#8B5E56}.banner a{display:inline-block;color:#fff;background:#E96B6B;border-radius:99px;padding:7px 12px;font-size:12px}.entries{margin:10px 16px 0;padding:12px 5px;background:#fff;border-radius:16px;display:grid;grid-template-columns:repeat(4,1fr);row-gap:12px;box-shadow:0 4px 16px #4b372f0d}.entry{text-align:center;font-size:12px}.icon{width:38px;height:38px;margin:0 auto 5px;border-radius:12px;display:grid;place-items:center;font-weight:700}.entry:nth-child(1) .icon{background:#FFF0EF;color:#D85D62}.entry:nth-child(2) .icon{background:#FFF7E6;color:#B77B19}.entry:nth-child(3) .icon{background:#ECF8F2;color:#2E8A62}.entry:nth-child(4) .icon{background:#EEF5FF;color:#4A72B8}.entry:nth-child(5) .icon{background:#F5EFFB;color:#8865A7}.entry:nth-child(6) .icon{background:#FFF2E8;color:#B9723B}.entry:nth-child(7) .icon{background:#EEF0FB;color:#5F6FAE}.entry:nth-child(8) .icon{background:#EAF7F5;color:#31867C}.section{margin:24px 16px 0}.heading{display:flex;align-items:end;justify-content:space-between;margin-bottom:10px}.heading h2{font-size:17px;margin:0}.heading small{display:block;color:#888;font-weight:400;margin-top:3px}.more{font-size:12px;color:#E96B6B}.recommend{background:#fff;padding:13px;border-radius:16px;box-shadow:0 4px 16px #4b372f0d;display:flex;gap:12px}.avatar{width:68px;height:84px;border-radius:10px;background:linear-gradient(145deg,#f3d8cf,#e8b7aa);display:grid;place-items:center;color:#fff;font-size:22px}.person{flex:1}.name{font-size:16px;font-weight:650}.meta{font-size:12px;color:#777;margin-top:5px}.reason{margin-top:8px;color:#555;font-size:12px}.tag{display:inline-block;background:#ECF8F2;color:#2E9B68;padding:3px 7px;border-radius:99px;font-size:10px}.actions{margin-top:9px;display:flex;gap:8px}.actions span{flex:1;text-align:center;padding:7px;border-radius:99px;background:#FFF3F2;color:#E96B6B}.actions .primary{background:#E96B6B;color:#fff}.service{padding:14px;background:linear-gradient(135deg,#FFF8F7,#FFF9F0);border:1px solid #f8e3dd;border-radius:16px;display:flex;align-items:center}.service strong{display:block}.service p{font-size:11px;color:#666;margin:5px 0 0}.service a{margin-left:auto;color:#E96B6B;font-size:11px}.tabbar{position:sticky;bottom:0;margin-top:24px;height:62px;background:#fff;border-top:1px solid #eee;display:flex;justify-content:space-around;align-items:center;font-size:11px}.tabbar span:first-child{color:#E96B6B;font-weight:650}
</style></head><body><main class="phone"><header class="top"><div class="brand"><strong>善缘丹阳</strong><small>真实相识 · 公益牵线</small></div><span class="pill">公益服务</span><span class="bell">消息 2</span></header><div class="preview">界面预览数据</div><div class="status"><span class="ok">✓ 已实名</span><span class="ok">✓ 本地资格</span><span class="ok">✓ 资料已审核</span></div><section class="banner"><h1>让真诚的相遇,在丹阳发生</h1><p>实名认证 · 人工审核 · 公益服务</p><a>了解平台 ›</a></section><section class="entries"><div class="entry"><div class="icon">缘</div>找对象</div><div class="entry"><div class="icon">牵</div>公益红娘</div><div class="entry"><div class="icon">聚</div>公益活动</div><div class="entry"><div class="icon">谈</div>婚恋咨询</div><div class="entry"><div class="icon">学</div>婚恋课堂</div><div class="entry"><div class="icon">伴</div>婚前辅导</div><div class="entry"><div class="icon">安</div>婚恋反诈</div><div class="entry"><div class="icon">站</div>善缘服务站</div></section><section class="section"><div class="heading"><h2>今日推荐<small>根据双方条件,为你认真筛选</small></h2><span class="more">查看更多</span></div><div class="recommend"><div class="avatar">林</div><div class="person"><div><span class="name">林女士 · 29岁</span> <span class="tag">已认证</span></div><div class="meta">165cm · 丹阳 · 教育行业</div><div class="reason">✓ 双方生活规划较为契合<br>✓ 都期待认真稳定的关系</div><div class="actions"><span>不合适</span><span class="primary">喜欢</span></div></div></div></section><section class="section"><div class="heading"><h2>善缘公益服务<small>需要时,我们在这里陪伴你</small></h2></div><div class="service"><div><strong>需要更有温度的帮助?</strong><p>公益红娘尊重双方意愿并提供人工推荐</p></div><a>申请 ›</a></div></section><nav class="tabbar"><span>首页</span><span>找对象</span><span>消息</span><span>活动</span><span>我的</span></nav></main></body></html>
+5
View File
@@ -0,0 +1,5 @@
target
.m2*
local-*
build-temp
*.log
+4
View File
@@ -14,3 +14,7 @@
# MINIO_ENDPOINT=http://localhost:9000
# MINIO_ACCESS_KEY=replace-with-local-access-key
# MINIO_SECRET_KEY=replace-with-local-secret-key
# 本地资格佐证保留期(天)
LOCAL_EVIDENCE_RETENTION_DAYS=365
LOCAL_EVIDENCE_ALLOWED_TYPES=jpg,png
MATCH_LOCAL_ELIGIBILITY_REQUIRED=false
+1 -1
View File
@@ -10,4 +10,4 @@ FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "/app/app.jar", "--spring.profiles.active=${SPRING_PROFILES_ACTIVE:docker}"]
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
-77
View File
@@ -1,77 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.danyang.match</groupId>
<artifactId>temp-dependencies</artifactId>
<version>1.0.0</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
<version>9.22.3</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.30</version>
</dependency>
<dependency>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct</artifactId>
<version>1.5.5.Final</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.3.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<repositories>
<repository>
<id>central</id>
<url>https://repo1.maven.org/maven2</url>
</repository>
</repositories>
</project>
BIN
View File
Binary file not shown.
@@ -8,7 +8,6 @@ import com.danyang.match.platform.account.mapper.ConsentRecordMapper;
import com.danyang.match.platform.activity.entity.ActivityRegistration;
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
import com.danyang.match.platform.notification.service.NotificationService;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.risk.mapper.ReportMapper;
@@ -18,12 +17,19 @@ import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
import com.danyang.match.platform.counseling.entity.CounselingService;
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
import com.danyang.match.platform.chat.service.ChatService;
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.match.entity.Match;
import com.danyang.match.platform.match.mapper.MatchMapper;
import com.danyang.match.platform.verification.entity.UserIdentity;
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -48,11 +54,22 @@ public class AccountLifecycleService {
private static final Logger log = LoggerFactory.getLogger(AccountLifecycleService.class);
/**
* 本地资格「审核进行中」的状态集合。
*
* <p>取值域严格来自生产 {@code V017 chk_elig_verify_status}:
* {@code declared / needs_evidence / pending_review / verified / rejected}。
* 这里刻意**不复用** {@code Constants.VERIFICATION_STATUS_*} ——
* 那组常量描述的是 {@code user_identities}(V002 的 CHECK 才含 failed),
* 两个表的状态域不同,跨域复用正是本类两处缺陷的根因。
*/
private static final Set<String> LOCAL_ELIGIBILITY_IN_REVIEW =
Set.of("needs_evidence", "pending_review");
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final ActivityRegistrationMapper registrationMapper;
private final RecommendationRecordMapper recommendationRecordMapper;
private final ConversationMapper conversationMapper;
private final ReportMapper reportMapper;
private final RiskActionMapper riskActionMapper;
private final AuditLogMapper auditLogMapper;
@@ -62,6 +79,10 @@ public class AccountLifecycleService {
private final CounselingServiceMapper counselingServiceMapper;
private final ServiceProgramMemberMapper serviceProgramMemberMapper;
private final LocalEligibilityMapper localEligibilityMapper;
private final MatchMapper matchMapper;
private final ChatService chatService;
private final UserIdentityMapper userIdentityMapper;
private final CounselingFollowupMapper counselingFollowupMapper;
// ==================== 退出婚恋服务(暂停推荐) ====================
@@ -173,9 +194,9 @@ public class AccountLifecycleService {
// V1.1: 检查是否有进行中的咨询
long activeCounselings = counselingServiceMapper.selectCount(
new LambdaQueryWrapper<CounselingService>()
.eq(CounselingService::getUserId, userId)
.in(CounselingService::getStatus,
new LambdaQueryWrapper<CounselingServiceRecord>()
.eq(CounselingServiceRecord::getUserId, userId)
.in(CounselingServiceRecord::getStatus,
Constants.COUNSELING_STATUS_PENDING,
Constants.COUNSELING_STATUS_ASSIGNED,
Constants.COUNSELING_STATUS_SCHEDULED,
@@ -194,10 +215,14 @@ public class AccountLifecycleService {
}
// V1.1: 检查本地资格核验状态
// 注意状态域:本地资格表(V017 chk_elig_verify_status)只有
// declared / needs_evidence / pending_review / verified / rejected
// 此前这里比较的是 "submitted" / "pending" —— 这两个值在该表里**永不可能出现**,
// 因此"本地资格审核进行中"这道拦截从未生效过(死代码),
// 审核中的用户可以直接注销。正确的"审核进行中"是 pending_review。
Optional<LocalEligibility> localElig = localEligibilityMapper.findByUserId(userId);
if (localElig.isPresent() &&
(Constants.VERIFICATION_STATUS_SUBMITTED.equals(localElig.get().getVerificationStatus()) ||
Constants.VERIFICATION_STATUS_PENDING.equals(localElig.get().getVerificationStatus()))) {
LOCAL_ELIGIBILITY_IN_REVIEW.contains(localElig.get().getVerificationStatus())) {
blockers.put("localEligibility", "您的本地资格审核正在进行中,请耐心等待");
}
@@ -221,10 +246,16 @@ public class AccountLifecycleService {
throw new RuntimeException("用户不存在");
}
// 1. 检查是否可以注销
// 1. 注销前检查(信息性,不阻断):让 GET /can-withdraw 能把待处理业务展示给用户/运营。
// 依据 AGENTS「注销必须在线便捷、不设置障碍」与计划 520「注销事务关闭未来服务」,
// 这里**不再抛异常阻断**——closeAccount 会自行取消/退出/失效这些业务。
// 早期实现在此抛异常,导致下方 cancelXxx / invalidateLocalEligibility 的清理逻辑
// 永远不可达(死代码),且给用户注销制造了「需先手动取消所有业务」的障碍。
Map<String, Object> checkResult = checkCanWithdraw(userId);
if (!(boolean) checkResult.get("canWithdraw")) {
throw new RuntimeException("存在进行中的业务,无法注销:" + checkResult.get("blockers"));
@SuppressWarnings("unchecked")
Map<String, Object> blockers = (Map<String, Object>) checkResult.get("blockers");
if (blockers != null && !blockers.isEmpty()) {
log.info("注销时存在进行中业务(将自动关闭): userId={}, blockers={}", userId, blockers.keySet());
}
// 2. 停止所有进行中的业务
@@ -257,6 +288,9 @@ public class AccountLifecycleService {
// 3. 敏感数据脱敏(保留审计所需的最小信息)
sanitizeUserData(user.getId());
// 3b. 服务历史(含咨询正文)脱敏 —— 此前 P21-T05 的实现从未被调用(死代码),
// 导致注销后咨询摘要/正文仍以明文留存,违反 Gate「不反向暴露个人和咨询正文」。
anonymizeServiceHistory(user.getId());
// 4. 记录退出时间
Instant now = Instant.now();
@@ -333,11 +367,28 @@ public class AccountLifecycleService {
// ==================== 私有辅助方法 ====================
/**
* 通知所有活跃 Match 对方账号已关闭
* 注销时关闭用户所有活跃 Match 并通知对方。
*
* <p>这是 Gate「注销后不能聊天」的核心机制:仅仅把 {@code accountStatus} 置为
* {@code closed} 并不足以阻止聊天 —— 既有的 active Match 仍会让对方看到可聊入口。
* 因此这里把每条 active/getting_to_know 的 Match 置为 {@code unmatched}(与 Unmatch 同态),
* 并通过 {@link ChatService#closeConversationForMatch} 触发 {@code conversation.closed}
* 事件,让对方实时灰显、发送时被 {@code assertMatchAlive} 拦截。
*
* <p>此前该方法是 TODO 桩(只写日志),注销后 Match 仍 active、对方完全不知情 →
* 用户已注销却还能被匹配对象继续聊天,Gate 形同虚设。
*/
private void notifyActiveMatches(String userId, String reason) {
// TODO: 查询该用户的所有 active match,通知对方
log.info("通知 Match 对方账号关闭: userId={}, reason={}", userId, reason);
List<Match> matches = matchMapper.findActiveMatchesByUser(userId);
int closed = 0;
for (Match m : matches) {
m.setStatus(Constants.MATCH_STATUS_UNMATCHED);
m.setUnmatchedAt(Instant.now());
matchMapper.updateById(m);
chatService.closeConversationForMatch(m.getId(), Constants.CONVERSATION_STATUS_UNMATCHED);
closed++;
}
log.info("注销关闭活跃 Match 并通知对方: userId={}, closed={}, reason={}", userId, closed, reason);
}
/**
@@ -349,16 +400,36 @@ public class AccountLifecycleService {
}
/**
* 取消所有未开始的活动报名
* 注销时取消用户所有进行中的活动报名(pending/approved)。
*
* <p>计划 520 要求注销事务「关闭未来服务」——活动报名属于未来服务,若不取消,
* 已注销用户仍停留在活动名单、可能收到签到提醒或现场被识别。
* 此前该方法是 TODO 桩(只写日志),报名完全不被取消。
*
* <p>注意:{@code ActivityRegistration} 无 {@code deleted_at} 列(D12 软删过滤不适用),
* 且既有 {@code findByUserId} 带 {@code LIMIT 20} 不足以覆盖全部报名,故用
* {@code selectList} 全量查询后逐条置 {@code cancelled}。
*/
private void cancelPendingActivityRegistrations(String userId) {
// TODO: 查询 activity_registrations 中 pending/approved 且活动未开始的记录,将其状态设为 cancelled
log.info("取消用户活动报名: userId={}", userId);
List<ActivityRegistration> regs = registrationMapper.selectList(
new LambdaQueryWrapper<ActivityRegistration>()
.eq(ActivityRegistration::getUserId, userId)
.in(ActivityRegistration::getStatus,
Constants.REGISTRATION_STATUS_PENDING,
Constants.REGISTRATION_STATUS_APPROVED));
int cancelled = 0;
for (ActivityRegistration reg : regs) {
reg.setStatus(Constants.REGISTRATION_STATUS_CANCELLED);
reg.setUpdatedAt(Instant.now());
registrationMapper.updateById(reg);
cancelled++;
}
log.info("取消用户活动报名: userId={}, cancelled={}", userId, cancelled);
}
/**
* 敏感数据脱敏
* 核心:身份证/手机号脱敏,保留审计所需的最小信息
* 核心:身份证/手机号/实名姓名脱敏,保留审计所需的最小信息
*/
private void sanitizeUserData(String userId) {
// 脱敏手机号
@@ -373,7 +444,14 @@ public class AccountLifecycleService {
}
userMapper.updateById(user);
}
// TODO: 同步更新 user_identities 中的身份证号码
// 脱敏身份证号与姓名(user_identities):两列均为 TEXT NOT NULL,
// 不能以 null 删除,只能覆盖为不可还原的占位符(原文为密文,部分打码无意义)。
// 此前此处是 TODO,导致注销后身份证明文(密文)永久留存。
userIdentityMapper.findByUserId(userId).ifPresent(identity -> {
identity.setIdNumberEnc("***ACCOUNT_CLOSED***");
identity.setRealNameEnc("***ACCOUNT_CLOSED***");
userIdentityMapper.updateById(identity);
});
log.info("敏感数据脱敏: userId={}", userId);
}
@@ -399,9 +477,9 @@ public class AccountLifecycleService {
}
private void cancelCounselingServices(String userId) {
List<CounselingService> services = counselingServiceMapper.findByUserId(userId);
List<CounselingServiceRecord> services = counselingServiceMapper.findByUserId(userId);
int cancelled = 0;
for (CounselingService cs : services) {
for (CounselingServiceRecord cs : services) {
if (Constants.COUNSELING_STATUS_PENDING.equals(cs.getStatus()) ||
Constants.COUNSELING_STATUS_ASSIGNED.equals(cs.getStatus()) ||
Constants.COUNSELING_STATUS_SCHEDULED.equals(cs.getStatus()) ||
@@ -433,17 +511,36 @@ public class AccountLifecycleService {
Optional<LocalEligibility> elig = localEligibilityMapper.findByUserId(userId);
if (elig.isPresent()) {
LocalEligibility le = elig.get();
le.setVerificationStatus(Constants.VERIFICATION_STATUS_FAILED);
// 生产 V017 的 chk_elig_verify_status 只允许
// declared / needs_evidence / pending_review / verified / rejected
// —— **没有 failed**。VERIFICATION_STATUS_FAILED 属于 user_identities 的状态域
// (V002 的 chk 里才有 failed),把它写进本地资格表会在真实 PostgreSQL 上
// 直接触发 CHECK 违约 → closeAccount 整个事务回滚 → 注销 100% 不可用。
// 测试库此前没有该 CHECK,所以这个必崩缺陷在测试期完全不可见。
le.setVerificationStatus(Constants.VERIFICATION_STATUS_REJECTED);
localEligibilityMapper.updateById(le);
log.info("失效用户本地资格: userId={}", userId);
}
}
private void anonymizeServiceHistory(String userId) {
List<CounselingService> services = counselingServiceMapper.findByUserId(userId);
for (CounselingService cs : services) {
if (cs.getUserRequestSummary() != null && cs.getUserRequestSummary().length() > 10) {
List<CounselingServiceRecord> services = counselingServiceMapper.findByUserId(userId);
for (CounselingServiceRecord cs : services) {
boolean changed = false;
if (cs.getUserRequestSummary() != null && !cs.getUserRequestSummary().isEmpty()) {
cs.setUserRequestSummary("***已脱敏***");
changed = true;
}
// 咨询正文在独立的 counseling_followups 表(INV-013 保护的敏感正文),
// 仅脱敏主表摘要会漏掉逐条跟进记录 → 注销后咨询详情仍明文留存。
List<CounselingFollowup> followups = counselingFollowupMapper.findByServiceId(cs.getId());
for (CounselingFollowup f : followups) {
if (f.getContent() != null && !f.getContent().isEmpty()) {
f.setContent("***已脱敏***");
counselingFollowupMapper.updateById(f);
}
}
if (changed) {
counselingServiceMapper.updateById(cs);
}
}
@@ -1,11 +1,10 @@
package com.danyang.match.platform.activity.controller;
import com.danyang.match.platform.shared.idempotency.Idempotent;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import com.danyang.match.platform.activity.service.ActivityService;
import com.danyang.match.platform.shared.dto.ApiResponse;
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
@@ -48,6 +47,7 @@ public class ActivityController {
return ApiResponse.ok(detail);
}
@Idempotent
@PostMapping("/{id}/register")
public ApiResponse<Map<String, Object>> register(
@AuthenticationPrincipal UserPrincipal principal,
@@ -99,33 +99,13 @@ public class ActivityController {
return ApiResponse.ok(activityService.getActivityCapacityGroups(id));
}
@PostMapping("/operator")
@PreAuthorize("hasAuthority('activity.create')")
public ApiResponse<?> createActivityOperator(
@AuthenticationPrincipal OperatorPrincipal principal,
@RequestBody Map<String, Object> body) {
String operatorId = principal.id();
return ApiResponse.ok(activityService.createActivityFromAdmin(body, operatorId));
}
@PostMapping("/operator/{id}/organizations")
@PreAuthorize("hasAuthority('activity.create')")
public ApiResponse<?> addActivityOrganization(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
String operatorId = principal.id();
return ApiResponse.ok(activityService.addActivityOrganization(id, body.get("organizationId"), body.get("role"), operatorId));
}
@PostMapping("/operator/{id}/capacity-groups")
@PreAuthorize("hasAuthority('activity.create')")
public ApiResponse<?> addCapacityGroup(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, Object> body) {
String operatorId = principal.id();
return ApiResponse.ok(activityService.addCapacityGroup(id, body, operatorId));
}
// ------------------------------------------------------------------
// Phase 14 复验:以下三个运营端写入端点原先挂在**用户端** /activities 下
// POST /activities/operator
// POST /activities/operator/{id}/organizations
// POST /activities/operator/{id}/capacity-groups
// 属双实现 + 权限边界错配(运营写操作混在用户端控制器里),
// 且后台实际调用的是 /operator/activities,这三个端点零调用方。
// 已统一迁至 ActivityOperatorController(类级 @PreAuthorize('activity.view'))。
// ------------------------------------------------------------------
}
@@ -7,6 +7,7 @@ import com.danyang.match.platform.activity.mapper.ActivityMapper;
import com.danyang.match.platform.activity.entity.Activity;
import com.danyang.match.platform.activity.entity.ActivityInterest;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.shared.idempotency.Idempotent;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import com.danyang.match.platform.relationship.service.RelationshipService;
import com.danyang.match.platform.shared.dto.ApiResponse;
@@ -54,6 +55,7 @@ public class ActivityInterestController {
* 提交活动反馈 + 表达意向
* POST /api/v1/activities/{id}/feedback
*/
@Idempotent
@PostMapping("/{id}/feedback")
public ApiResponse<Map<String, Object>> submitFeedback(
@AuthenticationPrincipal UserPrincipal principal,
@@ -105,6 +107,7 @@ public class ActivityInterestController {
* POST /api/v1/activities/{id}/interests/confirm
* 双方确认后创建 Match Episode,不自动创建 Conversation
*/
@Idempotent
@PostMapping("/{id}/interests/confirm")
public ApiResponse<Map<String, Object>> confirmInterest(
@AuthenticationPrincipal UserPrincipal principal,
@@ -14,8 +14,6 @@ import java.util.List;
import java.util.stream.Collectors;
import com.danyang.match.platform.activity.entity.Activity;
import java.util.Map;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.shared.constant.Constants;
/**
* 后台活动管理控制器
@@ -30,35 +28,69 @@ public class ActivityOperatorController {
private final ActivityMapper activityMapper;
/**
* 创建活动
* 创建活动(V1.1 类型化)
* POST /api/v1/operator/activities
*
* <p><b>Phase 14 修复</b>:原实现把请求体的 activityType / registrationPolicy /
* 多组织 / 容量分组**全部丢弃**,且把 {@code organizer_org_id} 错设为操作者 userId。
* 后台动态表单因此无法创建带类型的活动 —— Gate「分类 Tabs 有数据」根本不成立。
* 现统一委托 {@link ActivityService#createActivityFromAdmin},只保留一条实现。
*/
@PostMapping
@PreAuthorize("hasAuthority('activity.create')")
@Transactional
public ApiResponse<Activity> createActivity(
public ApiResponse<?> createActivity(
@AuthenticationPrincipal OperatorPrincipal principal,
@RequestBody Map<String, Object> body) {
String operatorId = principal.id();
Activity activity = new Activity();
activity.setTitle((String) body.get("title"));
activity.setDescription((String) body.get("description"));
activity.setLocationName((String) body.get("locationName"));
activity.setLocationAddress((String) body.get("locationAddress"));
activity.setOrganizerOrgId(operatorId);
activity.setTotalLimit((Integer) body.getOrDefault("totalLimit", 50));
activity.setMaleLimit((Integer) body.getOrDefault("maleLimit", 25));
activity.setFemaleLimit((Integer) body.getOrDefault("femaleLimit", 25));
activity.setMinAge(((Number) body.getOrDefault("minAge", 18)).shortValue());
activity.setMaxAge(((Number) body.getOrDefault("maxAge", 60)).shortValue());
activity.setStatus(Constants.ACTIVITY_STATUS_DRAFT);
activity.setCreatedBy(operatorId);
// 默认时间:明天开始,持续3小时
java.time.LocalDateTime now = java.time.LocalDateTime.now();
activity.setStartAt(now.plusDays(1).withHour(14).withMinute(0).withSecond(0).toInstant(java.time.ZoneOffset.ofHours(8)));
activity.setEndAt(now.plusDays(1).withHour(17).withMinute(0).withSecond(0).toInstant(java.time.ZoneOffset.ofHours(8)));
activityMapper.insert(activity);
return ApiResponse.ok(activity);
return ApiResponse.ok(activityService.createActivityFromAdmin(body, operatorId));
}
/**
* 活动主办/协作组织列表
* GET /api/v1/operator/activities/{id}/organizations
*/
@GetMapping("/{id}/organizations")
public ApiResponse<List<Map<String, Object>>> getActivityOrganizations(@PathVariable String id) {
return ApiResponse.ok(activityService.getActivityOrganizations(id));
}
/**
* 添加活动组织关系
* POST /api/v1/operator/activities/{id}/organizations
* Body: { "organizationId": "...", "role": "host|organizer|co_organizer|partner|venue_partner" }
*/
@PostMapping("/{id}/organizations")
@PreAuthorize("hasAuthority('activity.create')")
public ApiResponse<?> addActivityOrganization(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, String> body) {
return ApiResponse.ok(activityService.addActivityOrganization(
id, body.get("organizationId"), body.get("role"), principal.id()));
}
/**
* 活动容量分组列表
* GET /api/v1/operator/activities/{id}/capacity-groups
*/
@GetMapping("/{id}/capacity-groups")
public ApiResponse<List<Map<String, Object>>> getActivityCapacityGroups(@PathVariable String id) {
return ApiResponse.ok(activityService.getActivityCapacityGroups(id));
}
/**
* 新增容量分组
* POST /api/v1/operator/activities/{id}/capacity-groups
* Body: { "groupCode": "...", "capacity": 20, "ruleDefinition": "{...}" }
*/
@PostMapping("/{id}/capacity-groups")
@PreAuthorize("hasAuthority('activity.create')")
public ApiResponse<?> addCapacityGroup(
@AuthenticationPrincipal OperatorPrincipal principal,
@PathVariable String id,
@RequestBody Map<String, Object> body) {
return ApiResponse.ok(activityService.addCapacityGroup(id, body, principal.id()));
}
/**
@@ -104,6 +136,8 @@ public class ActivityOperatorController {
m.put("id", a.getId());
m.put("title", a.getTitle());
m.put("status", a.getStatus());
// Phase 14:后台列表必须能看到活动类型,否则运营无法核对分类是否建对
m.put("activityType", a.getActivityType());
m.put("startAt", a.getStartAt());
m.put("endAt", a.getEndAt());
m.put("locationName", a.getLocationName());
@@ -1,5 +1,6 @@
package com.danyang.match.platform.activity.controller;
import com.danyang.match.platform.shared.idempotency.Idempotent;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import com.danyang.match.platform.activity.service.CheckinService;
import com.danyang.match.platform.shared.dto.ApiResponse;
@@ -38,6 +39,7 @@ public class CheckinController {
* POST /api/v1/operator/activities/checkin/scan
* Body: { "token": "..." }
*/
@Idempotent
@PostMapping("/operator/checkin/scan")
@PreAuthorize("hasAuthority('activity.checkin')")
public ApiResponse<Map<String, Object>> scanCheckin(
@@ -55,7 +57,14 @@ public class CheckinController {
* 手工签到
* POST /api/v1/operator/activities/{id}/checkin/manual
* Body: { "userId": "..." }
*
* <p>规范第八章要求「签到」支持 `Idempotency-Key`,第 146 章的验收场景
* 「扫码两次」正是这类操作:签到工作台在弱网下重发、或操作员双击,
* 都会重复提交同一个签到动作。此前该端点漏标(同控制器的
* {@code /operator/checkin/scan} 已标注),补上一处声明即可由
* {@code IdempotencyInterceptor} 统一兜住,业务代码无需改动。
*/
@Idempotent
@PostMapping("/operator/{id}/checkin/manual")
@PreAuthorize("hasAuthority('activity.checkin')")
public ApiResponse<Map<String, Object>> manualCheckin(
@@ -12,6 +12,26 @@ public class ActivityListItemDTO {
private String activityId;
private String title;
private String coverUrl;
/**
* V1.1 Phase 14:封面文件 ID。
*
* <p><b>修复</b>:{@code coverUrl} 此前**从未被赋值**(全库零写入点),
* 活动卡片封面恒回退到默认图。小程序侧按 {@code uploadBase + '/files/' + coverFileId}
* 拼接真实地址(见 {@code components/activity-card/index.js})。
*/
private String coverFileId;
/** V1.1 Phase 14:活动简介(卡片摘要)。此前 DTO 无此字段,卡片摘要恒不渲染。 */
private String description;
/**
* V1.1 Phase 14:活动状态。
*
* <p><b>修复</b>:卡片按钮按 {@code activity.status === 'registration_open'} 显示
* "立即报名",而 DTO 根本没有 status —— 按钮永远显示"查看详情"。
*/
private String status;
private Instant startAt;
private Instant endAt;
private String locationName;
@@ -22,4 +42,22 @@ public class ActivityListItemDTO {
private int remainingSeats;
private boolean registered;
private String registrationStatus;
/**
* V1.1 Phase 14:活动类型。
*
* <p><b>修复</b>:小程序 {@code pages/activity/activity.ts} 的分类 Tabs 读取
* {@code item.activityType},但后端列表 DTO 此前**根本没有该字段** ——
* 分类筛选恒无数据,Gate「matchmaking 与课堂/反诈类活动显示不同」直接失败。
*/
private String activityType;
/**
* V1.1 Phase 14:已报名人数。
*
* <p><b>修复</b>:小程序 {@code ui-activity-card} 读 {@code activity.registeredCount}
* (回退 {@code currentMaleCount + currentFemaleCount}),而后端从未提供这三个字段 ——
* 卡片上的"报名人数 x/y"恒为 {@code 0/y}。此处由真实性别统计汇总。
*/
private int registeredCount;
}
@@ -0,0 +1,30 @@
package com.danyang.match.platform.activity.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.time.Instant;
/**
* 活动工作人员指派
*
* <p>V1.1 Phase 15(V040):服务站操作员的活动维度数据范围。
* <b>主办/协办组织关系不授予活动操作权限(INV-020)</b> —— 能否为某活动执行签到,
* 只取决于本表的显式指派,与 operator 所属组织无关。
*
* <p><b>注意</b>:本表主键为 {@code (activity_id, operator_id)} 复合主键,
* 因此**不继承** {@code BaseEntity} —— BaseEntity 会引入 id / updated_at / deleted_at,
* 而 MyBatis-Plus 对非 null 字段一律写入列清单,那会让每次 insert 都在生产
* 撞 {@code column does not exist}(与本项目历次"只在 PG 上炸"同族)。
*/
@Data
@TableName("activity_staff_assignments")
public class ActivityStaffAssignment {
private String activityId;
private String operatorId;
/** checkin_staff / activity_manager */
private String assignmentRole;
private String createdBy;
private Instant createdAt;
}
@@ -3,7 +3,35 @@ package com.danyang.match.platform.activity.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.activity.entity.ActivityCapacityGroup;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
@Mapper
public interface ActivityCapacityGroupMapper extends BaseMapper<ActivityCapacityGroup> {
@Select("SELECT * FROM activity_capacity_groups WHERE activity_id = #{activityId} AND deleted_at IS NULL "
+ "ORDER BY group_code")
List<ActivityCapacityGroup> findByActivityId(@Param("activityId") String activityId);
/**
* **原子**占用分组名额:仅当 {@code approved_count < capacity} 时才 +1。
*
* <p>Phase 15:容量分组此前只"落库回读",从未参与名额扣减 —— 运营配置了分组容量也形同虚设。
* 单条 UPDATE 由数据库保证原子性,避免 {@code SELECT → UPDATE} 在并发下超卖。
*
* @return 影响行数;0 表示该分组已无名额(或分组不存在)
*/
@Update("UPDATE activity_capacity_groups SET approved_count = approved_count + 1, updated_at = NOW() "
+ "WHERE activity_id = #{activityId} AND group_code = #{groupCode} "
+ "AND deleted_at IS NULL AND approved_count < capacity")
int incrementIfAvailable(@Param("activityId") String activityId, @Param("groupCode") String groupCode);
/** 取消/拒绝 approved 报名时回退分组名额(不会退到负数) */
@Update("UPDATE activity_capacity_groups SET approved_count = GREATEST(0, approved_count - 1), updated_at = NOW() "
+ "WHERE activity_id = #{activityId} AND group_code = #{groupCode} "
+ "AND deleted_at IS NULL AND approved_count > 0")
int decrementIfPositive(@Param("activityId") String activityId, @Param("groupCode") String groupCode);
}
@@ -10,17 +10,32 @@ import java.util.List;
@Mapper
public interface ActivityMapper extends BaseMapper<Activity> {
int countRemainingCapacity(String activityId);
@Select("SELECT * FROM activities WHERE status = 'registration_open' AND deleted_at IS NULL ORDER BY start_at ASC")
List<Activity> findOpenActivities();
/** V1.1 Phase 14: 按类型下推查询(原为全表捞取后内存 filter) */
@Select("SELECT * FROM activities WHERE status = 'registration_open' AND deleted_at IS NULL "
+ "AND activity_type = #{activityType} ORDER BY start_at ASC")
List<Activity> findOpenActivitiesByType(@Param("activityType") String activityType);
@Select("SELECT * FROM activities WHERE start_at > NOW() AND deleted_at IS NULL ORDER BY start_at ASC LIMIT 10")
List<Activity> findUpcomingActivities();
@Select("SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
int countApprovedRegistrations(@Param("activityId") String activityId);
@Select("SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
int countApprovedMaleRegistrations(@Param("activityId") String activityId, @Param("gender") String gender);
/**
* 统计某活动已通过报名的**真实性别构成**。
*
* <p><b>Phase 14 修复</b>:原 {@code countApprovedMaleRegistrations} 的 SQL
* 完全忽略了 {@code gender} 入参(永远返回全部通过人数),且零调用方——
* 导致 {@code ActivityService#toListItemDTO} 只能用 {@code approvedCount / 2}
* **凭空猜测**男女人数。此处改为按 {@code user_profiles.gender} 真实聚合。
*/
@Select("SELECT COUNT(*) FROM activity_registrations r "
+ "JOIN user_profiles p ON p.user_id = r.user_id AND p.deleted_at IS NULL "
+ "WHERE r.activity_id = #{activityId} AND r.status = 'approved' "
+ "AND r.deleted_at IS NULL AND p.gender = #{gender}")
int countApprovedByGender(@Param("activityId") String activityId, @Param("gender") String gender);
}
@@ -2,9 +2,11 @@ package com.danyang.match.platform.activity.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.activity.entity.ActivityRegistration;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Optional;
@@ -19,13 +21,185 @@ public interface ActivityRegistrationMapper extends BaseMapper<ActivityRegistrat
@Select("SELECT * FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL")
List<ActivityRegistration> findApprovedByActivity(String activityId);
/**
* 某活动的**全部**报名记录(含 pending)。
*
* <p><b>Phase 15 修复</b>:后台报名列表此前只查 {@code status='approved'},
* 审核页面因此永远看不到待审记录 —— 审核功能实际不可用。
*/
@Select("SELECT * FROM activity_registrations WHERE activity_id = #{activityId} AND deleted_at IS NULL "
+ "ORDER BY registered_at ASC")
List<ActivityRegistration> findByActivity(@Param("activityId") String activityId);
@Select("SELECT * FROM activity_registrations WHERE user_id = #{userId} ORDER BY registered_at DESC LIMIT 20")
List<ActivityRegistration> findByUserId(String userId);
/**
* 原子检查名额:返回剩余名额数
* 原子检查名额:返回剩余名额数。
*
* <p><b>Phase 14 说明</b>:本方法是全模块**唯一**有 SQL 实现的容量查询。
* 此前 {@code ActivityMapper} 上有一个同名方法却**既无 {@code @Select} 也无 XML**,
* 而 {@code ActivityService} 调用的正是那个空壳 ——
* 活动详情 / 报名 / 审核三条路径在生产必然抛
* {@code Invalid bound statement (not found)}。已删除空壳并统一走本方法。
*/
@Select("SELECT COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 9999) - " +
"(SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} AND status = 'approved' AND deleted_at IS NULL) AS remaining")
int countRemainingCapacity(@Param("activityId") String activityId);
/**
* **原子**占位插入:仅当「总名额未满」**且**「该性别名额未满」时才写入 approved 报名。
*
* <p>把「查余量」与「写记录」合并为一条 SQL,由数据库保证原子性,
* 避免 {@code SELECT COUNT → INSERT} 的先读后写在并发下超卖。
*
* <p>性别名额按**配置**生效:{@code male_limit}/{@code female_limit} 为 NULL 时
* (如婚恋课堂 / 反诈类活动)该条件下退化为 999999,即只受总名额约束 ——
* 这正是 Gate「matchmaking 与课堂/反诈类容量策略不同」的实现方式。
*
* @return 影响行数;0 表示名额已满(或活动不存在),调用方必须据此报错
*/
@Insert("INSERT INTO activity_registrations (id, activity_id, user_id, status, registered_at, "
+ "checkin_status, emergency_contact_name, emergency_contact_phone_enc, created_at, updated_at) "
+ "SELECT #{id}, #{activityId}, #{userId}, #{status}, NOW(), 'not_checked_in', "
+ "#{contactName}, #{contactPhone}, NOW(), NOW() "
+ "WHERE (SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} "
+ " AND status = 'approved' AND deleted_at IS NULL) "
+ " < COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 999999) "
+ " AND (SELECT COUNT(*) FROM activity_registrations r "
+ " JOIN user_profiles p ON p.user_id = r.user_id AND p.deleted_at IS NULL "
+ " WHERE r.activity_id = #{activityId} AND r.status = 'approved' "
+ " AND r.deleted_at IS NULL AND p.gender = #{gender}) "
+ " < COALESCE((SELECT CASE #{gender} WHEN 'male' THEN a.male_limit "
+ " WHEN 'female' THEN a.female_limit "
+ " ELSE a.total_limit END "
+ " FROM activities a WHERE a.id = #{activityId} AND a.deleted_at IS NULL), 999999)")
int insertIfCapacityAvailable(@Param("id") String id,
@Param("activityId") String activityId,
@Param("userId") String userId,
@Param("status") String status,
@Param("contactName") String contactName,
@Param("contactPhone") String contactPhone,
@Param("gender") String gender);
/**
* **原子**审核通过:仅当记录仍为 pending **且**总名额与性别名额均未满时才置为 approved。
*
* @return 影响行数;0 表示名额已满或状态已被他人变更
*/
@Update("UPDATE activity_registrations SET status = 'approved', reviewed_by = #{operatorId}, "
+ "reviewed_at = NOW(), updated_at = NOW() "
+ "WHERE id = #{id} AND status = 'pending' AND deleted_at IS NULL "
+ " AND (SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} "
+ " AND status = 'approved' AND deleted_at IS NULL) "
+ " < COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 999999) "
+ " AND (SELECT COUNT(*) FROM activity_registrations r "
+ " JOIN user_profiles p ON p.user_id = r.user_id AND p.deleted_at IS NULL "
+ " WHERE r.activity_id = #{activityId} AND r.status = 'approved' "
+ " AND r.deleted_at IS NULL AND p.gender = #{gender}) "
+ " < COALESCE((SELECT CASE #{gender} WHEN 'male' THEN a.male_limit "
+ " WHEN 'female' THEN a.female_limit "
+ " ELSE a.total_limit END "
+ " FROM activities a WHERE a.id = #{activityId} AND a.deleted_at IS NULL), 999999)")
int approveIfCapacityAvailable(@Param("id") String id,
@Param("activityId") String activityId,
@Param("operatorId") String operatorId,
@Param("gender") String gender);
// ==================== Phase 15 新增 ====================
/**
* **原子**复用历史报名记录(cancelled / rejected → pending|approved)。
*
* <p><b>Phase 15 D7(必崩)</b>:{@code UNIQUE(activity_id, user_id)} 在生产库一直存在,
* 而"取消后重新报名"走的是 {@code INSERT} —— 用户一旦取消就永远无法再报该活动,
* 直接撞唯一键 500。测试库此前缺该 UNIQUE,所以这条必崩路径零覆盖。
* 正确做法:复用原记录做条件 UPDATE,并**同样**带上容量条件。
*
* @return 影响行数;0 表示名额已满,或记录状态已被他人变更
*/
@Update("UPDATE activity_registrations SET status = #{status}, registered_at = NOW(), "
+ "reviewed_by = NULL, reviewed_at = NULL, reject_reason = NULL, "
+ "checkin_status = 'not_checked_in', checkin_at = NULL, "
+ "emergency_contact_name = #{contactName}, emergency_contact_phone_enc = #{contactPhone}, "
+ "updated_at = NOW() "
+ "WHERE id = #{id} AND status IN ('cancelled', 'rejected') AND deleted_at IS NULL "
+ " AND (SELECT COUNT(*) FROM activity_registrations WHERE activity_id = #{activityId} "
+ " AND status = 'approved' AND deleted_at IS NULL) "
+ " < COALESCE((SELECT total_limit FROM activities WHERE id = #{activityId} AND deleted_at IS NULL), 999999) "
+ " AND (SELECT COUNT(*) FROM activity_registrations r "
+ " JOIN user_profiles p ON p.user_id = r.user_id AND p.deleted_at IS NULL "
+ " WHERE r.activity_id = #{activityId} AND r.status = 'approved' "
+ " AND r.deleted_at IS NULL AND p.gender = #{gender}) "
+ " < COALESCE((SELECT CASE #{gender} WHEN 'male' THEN a.male_limit "
+ " WHEN 'female' THEN a.female_limit "
+ " ELSE a.total_limit END "
+ " FROM activities a WHERE a.id = #{activityId} AND a.deleted_at IS NULL), 999999)")
int reuseIfCapacityAvailable(@Param("id") String id,
@Param("activityId") String activityId,
@Param("status") String status,
@Param("contactName") String contactName,
@Param("contactPhone") String contactPhone,
@Param("gender") String gender);
/**
* **原子**签到:仅当当前为 {@code not_checked_in} 时才置为已签到。
*
* <p><b>Phase 15 D2</b>:原实现为「读 → 判断 → updateById 无条件覆盖」,
* 并发扫码会重复写入 checkin_at 并重复记审计。此处合并为一条条件 UPDATE,
* 由数据库保证"重复签到幂等"。
*
* @return 影响行数;0 表示已签到(幂等命中)或记录已被取消
*/
@Update("UPDATE activity_registrations SET checkin_status = 'checked_in', checkin_at = NOW(), updated_at = NOW() "
+ "WHERE id = #{id} AND checkin_status = 'not_checked_in' AND deleted_at IS NULL")
int checkinIfNotCheckedIn(@Param("id") String id);
/**
* **原子**取消:仅当状态仍为 pending / approved 时才置为 cancelled。
*
* @return 影响行数;0 表示状态已被他人变更(如已被审核拒绝),调用方不得重复回补名额
*/
@Update("UPDATE activity_registrations SET status = 'cancelled', updated_at = NOW() "
+ "WHERE id = #{id} AND status IN ('pending', 'approved') AND deleted_at IS NULL")
int cancelIfActive(@Param("id") String id);
/**
* **原子**拒绝:仅当仍为 pending 时才置为 rejected。
*
* <p>与 {@link #approveIfCapacityAvailable} 对称 —— 审核并发时两人同时操作,
* 只有一个能生效,避免"拒绝后又被通过"。
*/
@Update("UPDATE activity_registrations SET status = 'rejected', reject_reason = #{reason}, "
+ "reviewed_by = #{operatorId}, reviewed_at = NOW(), updated_at = NOW() "
+ "WHERE id = #{id} AND status = 'pending' AND deleted_at IS NULL")
int rejectIfPending(@Param("id") String id,
@Param("operatorId") String operatorId,
@Param("reason") String reason);
// ==================== Phase 20:活动漏斗口径统计 ====================
// 此前 AnalyticsService.getActivityFunnel()/activityFunnelStats() 把全部指标
// 硬编码为 0(并且留着一行 `userMapper.selectCount(...)` 当作"活动数"的死代码),
// 后台"活动漏斗"永远显示 0 —— 不是没数据,是根本没查。
/** 报名总记录数(去重键 = id;含 pending/rejected/cancelled)。 */
@Select("SELECT COUNT(*) FROM activity_registrations WHERE deleted_at IS NULL")
long countAllRegistrations();
/** 按状态统计报名数(pending / approved / rejected / cancelled)。 */
@Select("SELECT COUNT(*) FROM activity_registrations WHERE status = #{status} AND deleted_at IS NULL")
long countRegistrationsByStatus(@Param("status") String status);
/** 已签到人数(去重键 = id)。 */
@Select("SELECT COUNT(*) FROM activity_registrations "
+ "WHERE checkin_status = 'checked_in' AND deleted_at IS NULL")
long countCheckedIn();
/** 按报名来源统计(chk_reg_source: user_self / matchmaker_referral / activity_followup / service_station / operator_assisted)。 */
@Select("SELECT COUNT(*) FROM activity_registrations WHERE source = #{source} AND deleted_at IS NULL")
long countRegistrationsBySource(@Param("source") String source);
/** 去重报名人数(同一用户报多个活动只算一人)。 */
@Select("SELECT COUNT(DISTINCT user_id) FROM activity_registrations WHERE deleted_at IS NULL")
long countDistinctRegisteredUsers();
}
@@ -0,0 +1,24 @@
package com.danyang.match.platform.activity.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.danyang.match.platform.activity.entity.ActivityStaffAssignment;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* 活动工作人员指派(Phase 15 签到数据范围)
*
* <p>与 {@code service_location_operators} 的范式一致:
* 有指派记录的工作人员只能操作被指派的资源;没有任何指派记录的是平台级人员。
*/
@Mapper
public interface ActivityStaffAssignmentMapper extends BaseMapper<ActivityStaffAssignment> {
@Select("SELECT COUNT(*) FROM activity_staff_assignments WHERE operator_id = #{operatorId}")
int countAssignmentsForOperator(@Param("operatorId") String operatorId);
@Select("SELECT COUNT(*) FROM activity_staff_assignments "
+ "WHERE activity_id = #{activityId} AND operator_id = #{operatorId}")
int canOperateActivity(@Param("activityId") String activityId, @Param("operatorId") String operatorId);
}
@@ -16,8 +16,11 @@ import com.danyang.match.platform.activity.entity.ActivityOrganization;
import com.danyang.match.platform.activity.entity.ActivityCapacityGroup;
import com.danyang.match.platform.activity.mapper.ActivityOrganizationMapper;
import com.danyang.match.platform.activity.mapper.ActivityCapacityGroupMapper;
import com.danyang.match.platform.activity.mapper.ActivityStaffAssignmentMapper;
import com.danyang.match.platform.organization.entity.Organization;
import com.danyang.match.platform.organization.mapper.OrganizationMapper;
import com.danyang.match.platform.verification.service.IdentityVerificationService;
import com.danyang.match.platform.local_eligibility.service.LocalEligibilityService;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -34,6 +37,46 @@ public class ActivityService {
private static final Logger log = LoggerFactory.getLogger(ActivityService.class);
// ==================== Phase 14:多类型活动常量(必须与生产 V023 CHECK 白名单一致)====================
// 守卫 ActivityConstraintGuardTest 会直接解析 db/migration/V*.sql 校验这些集合,
// 任何与生产白名单的偏离都会让测试变红 —— 不要在此处"顺手"加值。
/** 合法活动类型 ⊆ 生产 chk_activity_type */
public static final Set<String> VALID_ACTIVITY_TYPES = Set.of(
Constants.ACTIVITY_TYPE_MATCHMAKING,
Constants.ACTIVITY_TYPE_INTEREST_SOCIAL,
Constants.ACTIVITY_TYPE_RELATIONSHIP_EDUCATION,
Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE,
Constants.ACTIVITY_TYPE_MARRIAGE_CUSTOM_CULTURE,
Constants.ACTIVITY_TYPE_ANTI_FRAUD);
/** 合法活动组织角色 ⊆ 生产 chk_org_role */
public static final Set<String> VALID_ORG_ROLES = Set.of(
Constants.ORG_ROLE_HOST,
Constants.ORG_ROLE_ORGANIZER,
Constants.ORG_ROLE_CO_ORGANIZER,
Constants.ORG_ROLE_PARTNER,
Constants.ORG_ROLE_VENUE_PARTNER);
/**
* 需要**实名认证**才能报名的类型。
*
* <p>Gate 要求「matchmaking 与课堂/反诈类活动资格策略不同」:
* 相亲类接触陌生人、必须实名;婚恋课堂 / 反诈宣传是公益普适科普,不设实名门槛。
*/
public static final Set<String> TYPES_REQUIRING_IDENTITY = Set.of(
Constants.ACTIVITY_TYPE_MATCHMAKING,
Constants.ACTIVITY_TYPE_PREMARITAL_GUIDANCE);
/** 需要**本地资格**才能报名的类型(丹阳户籍/工作/居住) */
public static final Set<String> TYPES_REQUIRING_LOCAL_ELIGIBILITY = Set.of(
Constants.ACTIVITY_TYPE_MATCHMAKING);
/** 需要按**性别名额**分别扣减的类型(课堂/反诈类只按总名额) */
public static final Set<String> TYPES_WITH_GENDER_QUOTA = Set.of(
Constants.ACTIVITY_TYPE_MATCHMAKING,
Constants.ACTIVITY_TYPE_INTEREST_SOCIAL);
private final ActivityMapper activityMapper;
private final ActivityRegistrationMapper registrationMapper;
private final UserProfileMapper userProfileMapper;
@@ -42,6 +85,10 @@ public class ActivityService {
private final ActivityOrganizationMapper activityOrganizationMapper;
private final ActivityCapacityGroupMapper capacityGroupMapper;
private final OrganizationMapper organizationMapper;
private final ActivityStaffAssignmentMapper staffAssignmentMapper;
private final com.danyang.match.platform.shared.security.DataScopeGuard dataScopeGuard;
private final IdentityVerificationService identityVerificationService;
private final LocalEligibilityService localEligibilityService;
private final com.danyang.match.platform.risk.service.RiskReferralService riskReferralService;
/**
@@ -144,13 +191,17 @@ public class ActivityService {
detail.put("organizerOrgId", activity.getOrganizerOrgId());
detail.put("coorganizerText", activity.getCoorganizerText());
// 报名统计
// 报名统计(Phase 14: 改走 ActivityRegistrationMapper —— ActivityMapper 上的同名方法无 SQL 实现)
int approvedCount = activityMapper.countApprovedRegistrations(activityId);
int remaining = activityMapper.countRemainingCapacity(activityId);
int remaining = registrationMapper.countRemainingCapacity(activityId);
detail.put("approvedCount", approvedCount);
detail.put("remainingSeats", Math.max(0, remaining));
detail.put("isFull", remaining <= 0);
// V1.1 Phase 14: 类型与资格策略(小程序据此渲染差异化的资格提示)
detail.put("activityType", activity.getActivityType());
detail.put("eligibility", resolveEligibility(activity));
// 用户报名状态
registrationMapper.findByActivityAndUser(activityId, userId).ifPresent(reg -> {
detail.put("registered", true);
@@ -173,37 +224,90 @@ public class ActivityService {
if (!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
throw new RuntimeException("活动未开放报名或已结束");
}
// Phase 15 修复:报名窗口此前完全不校验 —— registration_start_at / registration_end_at
// 形同虚设,活动一旦发布就永远可报名(运营设的截止时间无效)。
assertWithinRegistrationWindow(activity);
// 检查是否已报名
registrationMapper.findByActivityAndUser(activityId, userId).ifPresent(existing -> {
if (!Constants.REGISTRATION_STATUS_REJECTED.equals(existing.getStatus()) &&
!Constants.REGISTRATION_STATUS_CANCELLED.equals(existing.getStatus())) {
throw new RuntimeException("已经报名过该活动");
// 性别:容量分组与性别名额都依赖它
String gender = genderOf(userId);
// 已报名记录的处置:pending/approved 直接拒绝;cancelled/rejected 必须**复用**原记录
java.util.Optional<ActivityRegistration> existing =
registrationMapper.findByActivityAndUser(activityId, userId);
if (existing.isPresent()
&& !Constants.REGISTRATION_STATUS_REJECTED.equals(existing.get().getStatus())
&& !Constants.REGISTRATION_STATUS_CANCELLED.equals(existing.get().getStatus())) {
throw new RuntimeException("已经报名过该活动");
}
// V1.1 Phase 14: 按活动类型执行差异化资格策略(Gate 要求 matchmaking 与课堂/反诈类不同)
assertEligible(userId, activity);
// V1.1 Phase 14: 年龄范围校验(原实现为 TODO 空壳,minAge/maxAge 完全不生效)
assertAgeInRange(userId, activity);
// 创建报名记录(needReview=false 时直接 approved —— 必须原子占位,否则并发超卖)
boolean autoApprove = !Boolean.TRUE.equals(activity.getNeedReview());
String status = autoApprove
? Constants.REGISTRATION_STATUS_APPROVED
: Constants.REGISTRATION_STATUS_PENDING;
ActivityRegistration registration;
if (existing.isPresent()) {
// Phase 15 修复(必崩):生产 UNIQUE(activity_id, user_id) 一直存在,
// 而"取消/拒绝后重新报名"走的却是 INSERT —— 用户取消一次即永远无法再报,直接撞唯一键 500。
// 测试库此前缺该 UNIQUE,所以这条必崩路径零覆盖。此处改为复用原记录。
ActivityRegistration old = existing.get();
if (autoApprove) {
String groupCode = occupyCapacityGroup(activityId, gender);
try {
if (registrationMapper.reuseIfCapacityAvailable(
old.getId(), activityId, status,
emergencyContactName, emergencyContactPhoneEnc, gender) == 0) {
throw new RuntimeException("活动名额已满");
}
} catch (RuntimeException e) {
if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
throw e;
}
} else {
// pending 不占名额
registrationMapper.reuseIfCapacityAvailable(
old.getId(), activityId, status,
emergencyContactName, emergencyContactPhoneEnc, gender);
}
});
registration = registrationMapper.selectById(old.getId());
} else {
ActivityRegistration fresh = new ActivityRegistration();
fresh.setActivityId(activityId);
fresh.setUserId(userId);
fresh.setStatus(status);
fresh.setRegisteredAt(Instant.now());
fresh.setEmergencyContactName(emergencyContactName);
fresh.setEmergencyContactPhoneEnc(emergencyContactPhoneEnc);
// 检查名额(事务内原子检查)
int remaining = activityMapper.countRemainingCapacity(activityId);
if (remaining <= 0) {
throw new RuntimeException("活动名额已满");
if (autoApprove) {
String groupCode = occupyCapacityGroup(activityId, gender);
String newId = UUID.randomUUID().toString();
try {
if (registrationMapper.insertIfCapacityAvailable(
newId, activityId, userId, status,
emergencyContactName, emergencyContactPhoneEnc, gender) == 0) {
throw new RuntimeException("活动名额已满");
}
} catch (RuntimeException e) {
if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
throw e;
}
fresh.setId(newId);
} else {
// pending 不占用名额,插入即可(UNIQUE(activity_id,user_id) 防重复)
registrationMapper.insert(fresh);
}
registration = fresh;
}
// 检查年龄范围
if (activity.getMinAge() != null) {
// TODO: 获取用户年龄并校验
}
// 创建报名记录
ActivityRegistration registration = new ActivityRegistration();
registration.setActivityId(activityId);
registration.setUserId(userId);
registration.setStatus(activity.getNeedReview() ?
Constants.REGISTRATION_STATUS_PENDING : Constants.REGISTRATION_STATUS_APPROVED);
registration.setRegisteredAt(Instant.now());
registration.setEmergencyContactName(emergencyContactName);
registration.setEmergencyContactPhoneEnc(emergencyContactPhoneEnc);
registrationMapper.insert(registration);
// P17-T03: 线下活动风险检测
try {
String riskLevel = riskReferralService.detectActivityRisk(userId, emergencyContactName, null);
@@ -235,16 +339,26 @@ public class ActivityService {
throw new RuntimeException("当前状态不允许审核通过");
}
// 再次检查名额(防止并发超卖)
int remaining = activityMapper.countRemainingCapacity(registration.getActivityId());
if (remaining <= 0) {
throw new RuntimeException("活动名额已满,无法通过审核");
}
// Phase 15(计划项③):服务站操作员只能审核被指派给自己的活动
assertActivityScope(registration.getActivityId(), operatorId, "只能审核被指派给自己的活动");
registration.setStatus(Constants.REGISTRATION_STATUS_APPROVED);
registration.setReviewedBy(operatorId);
registration.setReviewedAt(Instant.now());
registrationMapper.updateById(registration);
// 原子审核:名额已满或状态已被他人变更时 affectedRows=0
String gender = genderOf(registration.getUserId());
String groupCode = occupyCapacityGroup(registration.getActivityId(), gender);
int affected;
try {
affected = registrationMapper.approveIfCapacityAvailable(
registrationId, registration.getActivityId(), operatorId, gender);
} catch (RuntimeException e) {
if (groupCode != null) capacityGroupMapper.decrementIfPositive(registration.getActivityId(), groupCode);
throw e;
}
if (affected == 0) {
if (groupCode != null) capacityGroupMapper.decrementIfPositive(registration.getActivityId(), groupCode);
throw new RuntimeException("活动名额已满或状态已变更,无法通过审核");
}
// 回读事务内最新状态(条件更新不回填实体)
registration = registrationMapper.selectById(registrationId);
// 通知用户
notificationService.sendAsync(registration.getUserId(), Constants.NOTIFICATION_TYPE_ACTIVITY,
@@ -266,12 +380,14 @@ public class ActivityService {
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不允许审核");
}
assertActivityScope(registration.getActivityId(), operatorId, "只能审核被指派给自己的活动");
registration.setStatus(Constants.REGISTRATION_STATUS_REJECTED);
registration.setRejectReason(reason);
registration.setReviewedBy(operatorId);
registration.setReviewedAt(Instant.now());
registrationMapper.updateById(registration);
// Phase 15 修复:原实现为「读 pending → 全量 updateById」,两人同时审核时
// 一个通过、一个拒绝会互相覆盖。改为条件更新,后到者 affectedRows=0。
if (registrationMapper.rejectIfPending(registrationId, operatorId, reason) == 0) {
throw new RuntimeException("该报名状态已被他人变更,无法拒绝");
}
registration = registrationMapper.selectById(registrationId);
// 通知用户
notificationService.sendAsync(registration.getUserId(), Constants.NOTIFICATION_TYPE_ACTIVITY,
@@ -294,9 +410,20 @@ public class ActivityService {
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("当前状态不能取消");
}
registration.setStatus(Constants.REGISTRATION_STATUS_CANCELLED);
registrationMapper.updateById(registration);
// 原子取消:状态已被他人变更(如已被拒绝)时 affectedRows=0,不得重复回补名额
if (registrationMapper.cancelIfActive(registration.getId()) == 0) {
throw new RuntimeException("该报名状态已被他人变更,无法取消");
}
// 已通过的名额释放(总名额/性别名额由 SQL 实时统计自动释放;分组计数需显式回退)
if (Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
String gender = genderOf(userId);
String groupCode = resolveCapacityGroupCode(activityId, gender);
if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
}
registration = registrationMapper.selectById(registration.getId());
log.info("用户取消报名: userId={}, activityId={}", userId, activityId);
// Phase 15 修复:取消是唯一没有审计日志的写操作,无法追溯"谁取消了报名"
saveAuditLog(userId, "CANCEL_REGISTRATION", "activity_registration", registration.getId(), null, null);
return registration;
}
@@ -307,8 +434,11 @@ public class ActivityService {
return registrationMapper.findByUserId(userId).stream()
.map(reg -> {
Activity activity = activityMapper.selectById(reg.getActivityId());
// Phase 14 修复:活动被软删后 selectById 返回 null,原实现直接 NPE
if (activity == null) return null;
Map<String, Object> map = new HashMap<>();
map.put("activityId", activity.getId());
map.put("activityType", activity.getActivityType());
map.put("title", activity.getTitle());
map.put("startAt", activity.getStartAt());
map.put("locationName", activity.getLocationName());
@@ -318,6 +448,7 @@ public class ActivityService {
map.put("reviewedAt", reg.getReviewedAt());
return map;
})
.filter(java.util.Objects::nonNull)
.collect(Collectors.toList());
}
@@ -325,7 +456,9 @@ public class ActivityService {
* 获取活动报名列表(后台)
*/
public List<Map<String, Object>> getRegistrations(String activityId) {
return registrationMapper.findApprovedByActivity(activityId).stream()
// Phase 15 修复(必崩):原实现只查 status='approved',后台审核列表因此
// **永远看不到 pending 记录** —— 审核功能实际不可用(无待审项可点)。
return registrationMapper.findByActivity(activityId).stream()
.map(reg -> {
Map<String, Object> map = new HashMap<>();
map.put("registrationId", reg.getId());
@@ -349,19 +482,37 @@ public class ActivityService {
ActivityListItemDTO dto = new ActivityListItemDTO();
dto.setActivityId(activity.getId());
dto.setTitle(activity.getTitle());
dto.setCoverFileId(activity.getCoverFileId());
dto.setDescription(activity.getDescription());
dto.setStatus(activity.getStatus());
dto.setStartAt(activity.getStartAt());
dto.setEndAt(activity.getEndAt());
dto.setLocationName(activity.getLocationName());
dto.setTotalLimit(activity.getTotalLimit());
long approvedCount = registrationMapper.selectCount(
new LambdaQueryWrapper<ActivityRegistration>()
.eq(ActivityRegistration::getActivityId, activity.getId())
.eq(ActivityRegistration::getStatus, "approved"));
// 注:性别在 user_profiles 中,此处简化处理(男女各半)
dto.setMaleCount((int)(approvedCount / 2));
dto.setFemaleCount((int)(approvedCount - approvedCount / 2));
dto.setRemainingSeats(Math.max(0, activity.getTotalLimit() != null ?
activity.getTotalLimit() - dto.getMaleCount() - dto.getFemaleCount() : 0));
dto.setActivityType(activity.getActivityType() == null
? Constants.ACTIVITY_TYPE_MATCHMAKING : activity.getActivityType());
// Phase 14 修复:男女人数改为按 user_profiles.gender 真实统计。
// 原实现为 approvedCount/2 与 approvedCount-approvedCount/2 —— 凭空编造的性别构成。
int approvedCount = activityMapper.countApprovedRegistrations(activity.getId());
int male = activityMapper.countApprovedByGender(activity.getId(), "male");
int female = activityMapper.countApprovedByGender(activity.getId(), "female");
dto.setMaleCount(male);
dto.setFemaleCount(female);
dto.setRegisteredCount(male + female);
// 剩余名额:总名额优先;性别名额启用时取"两个性别各自剩余"与总剩余的较小值
int remainingByTotal = activity.getTotalLimit() == null ? approvedCount : activity.getTotalLimit() - approvedCount;
int remaining = remainingByTotal;
if (TYPES_WITH_GENDER_QUOTA.contains(dto.getActivityType())) {
if (activity.getMaleLimit() != null) {
remaining = Math.min(remaining, activity.getMaleLimit() - male);
}
if (activity.getFemaleLimit() != null) {
remaining = Math.min(remaining, activity.getFemaleLimit() - female);
}
}
dto.setRemainingSeats(Math.max(0, remaining));
dto.setRegistered(false);
return dto;
}
@@ -369,9 +520,14 @@ public class ActivityService {
/** V1.1 Phase 14: 按活动类型过滤 */
@SuppressWarnings("unchecked")
public List<Map<String, Object>> getActivitiesByType(String userId, String activityType) {
List<Activity> activities = activityMapper.findOpenActivities();
// Phase 14 修复:类型必须落在生产 chk_activity_type 白名单内,
// 否则下推到 SQL 后只会静默返回空列表,掩盖前端传错值的问题。
if (activityType == null || !VALID_ACTIVITY_TYPES.contains(activityType)) {
throw new IllegalArgumentException("非法的活动类型: " + activityType);
}
// 原实现为「全表捞取 + 内存 filter」;改为下推到 SQL。
List<Activity> activities = activityMapper.findOpenActivitiesByType(activityType);
return activities.stream()
.filter(a -> activityType != null && activityType.equals(a.getActivityType()))
.map(a -> (Map<String, Object>) convertToMap(toListItemDTO(userId, a)))
.toList();
}
@@ -385,8 +541,10 @@ public class ActivityService {
m.put("totalLimit", dto.getTotalLimit());
m.put("maleCount", dto.getMaleCount());
m.put("femaleCount", dto.getFemaleCount());
m.put("registeredCount", dto.getRegisteredCount());
m.put("remainingSeats", dto.getRemainingSeats());
m.put("registered", dto.isRegistered());
m.put("activityType", dto.getActivityType());
return m;
}
@@ -426,33 +584,42 @@ public class ActivityService {
activity.setTitle((String) body.get("title"));
activity.setDescription((String) body.get("description"));
activity.setCoverFileId((String) body.get("coverFileId"));
activity.setStartAt(Instant.parse((String) body.get("startAt")));
activity.setEndAt(Instant.parse((String) body.get("endAt")));
// Phase 14 修复:运营端表单允许缺省时间/名额/年龄,原实现直接 Instant.parse(null)
// 与 ((Number) null).intValue() —— 后台一漏填就 NPE 500。此处按旧运营端端点兜底。
activity.setStartAt(parseInstant(body.get("startAt"), Instant.now().plusSeconds(3600)));
activity.setEndAt(parseInstant(body.get("endAt"), Instant.now().plusSeconds(7200)));
activity.setLocationName((String) body.get("locationName"));
activity.setLocationAddress((String) body.get("locationAddress"));
activity.setTotalLimit(((Number) body.get("totalLimit")).intValue());
activity.setMinAge(((Number) body.get("minAge")).shortValue());
activity.setMaxAge(((Number) body.get("maxAge")).shortValue());
activity.setTotalLimit(toInt(body.get("totalLimit"), 50));
// minAge/maxAge 为空表示不设年龄门槛(null 参与比较会 NPE,故用包装类型)
activity.setMinAge(toShort(body.get("minAge")));
activity.setMaxAge(toShort(body.get("maxAge")));
Object regStart = body.get("registrationStartAt");
Object regEnd = body.get("registrationEndAt");
activity.setRegistrationStartAt(regStart != null ? Instant.parse((String) regStart) : null);
activity.setRegistrationEndAt(regEnd != null ? Instant.parse((String) regEnd) : null);
activity.setNeedReview(Boolean.parseBoolean(body.getOrDefault("needReview", "true").toString()));
activity.setActivityType((String) body.getOrDefault("activityType", "matchmaking"));
// Phase 14 修复:活动类型必须落在生产 chk_activity_type 白名单内。
// 原实现直接把请求体原样写库 —— 非法类型在 H2 测试库里静默通过,
// 上到 PostgreSQL 才撞 CHECK 约束返回 500(典型的"只在 PG 上炸")。
activity.setActivityType(requireActivityType((String) body.getOrDefault("activityType", "matchmaking")));
activity.setRegistrationPolicy((String) body.get("registrationPolicy"));
activity.setTargetGroup((String) body.get("targetGroup"));
activity.setIndustryScope((String) body.get("industryScope"));
activity.setPostEventInterestEnabled(Boolean.parseBoolean(body.getOrDefault("postEventInterestEnabled", "false").toString()));
activity.setStatus(Constants.ACTIVITY_STATUS_DRAFT);
activity.setCreatedBy(operatorId);
// Phase 14 修复:主办组织此前只写进 activity_organizations,activities.organizer_org_id 留空,
// 后台列表与详情里的"主办方"恒为空。
String hostOrgId = (String) body.get("organizerOrgId");
activity.setOrganizerOrgId(hostOrgId);
activityMapper.insert(activity);
// 保存主组织
String orgId = (String) body.get("organizerOrgId");
if (orgId != null) {
if (hostOrgId != null) {
ActivityOrganization hostOrg = new ActivityOrganization();
hostOrg.setActivityId(activity.getId());
hostOrg.setOrganizationId(orgId);
hostOrg.setOrganizationId(hostOrgId);
hostOrg.setRole(Constants.ORG_ROLE_HOST);
activityOrganizationMapper.insert(hostOrg);
}
@@ -486,7 +653,8 @@ public class ActivityService {
ActivityOrganization org = new ActivityOrganization();
org.setActivityId(activityId);
org.setOrganizationId(organizationId);
org.setRole(role != null ? role : Constants.ORG_ROLE_PARTNER);
// Phase 14 修复:角色必须落在生产 chk_org_role 白名单内(同 D3 根因)
org.setRole(requireOrgRole(role != null ? role : Constants.ORG_ROLE_PARTNER));
activityOrganizationMapper.insert(org);
log.info("添加活动组织: activityId={}, orgId={}, role={}", activityId, organizationId, role);
Map<String, Object> result = new java.util.HashMap<>();
@@ -514,6 +682,198 @@ public class ActivityService {
return result;
}
// ==================== Phase 14:类型化资格策略 ====================
/**
* 解析某活动的资格要求。
*
* <p><b>Gate 落点</b>:「matchmaking 与课堂/反诈类活动显示和资格策略不同」——
* 相亲类要求实名 + 本地资格 + 性别名额;婚恋课堂 / 反诈宣传是公益科普,
* 不设实名与本地资格门槛,容量也只按总名额计算。
*/
public Map<String, Object> resolveEligibility(Activity activity) {
String type = activity.getActivityType() == null
? Constants.ACTIVITY_TYPE_MATCHMAKING : activity.getActivityType();
Map<String, Object> m = new LinkedHashMap<>();
m.put("activityType", type);
m.put("requiresIdentityVerified", TYPES_REQUIRING_IDENTITY.contains(type));
m.put("requiresLocalEligibility", TYPES_REQUIRING_LOCAL_ELIGIBILITY.contains(type));
m.put("usesGenderQuota", TYPES_WITH_GENDER_QUOTA.contains(type));
return m;
}
/**
* 报名前按活动类型校验资格。
*
* <p>此前 {@code registration_policy} 字段被写入却**从未被读取**,
* 资格策略实际是空的(Gate 未落地);此处改为按类型强制执行。
*/
private void assertEligible(String userId, Activity activity) {
String type = activity.getActivityType() == null
? Constants.ACTIVITY_TYPE_MATCHMAKING : activity.getActivityType();
if (TYPES_REQUIRING_IDENTITY.contains(type)) {
Boolean verified = Boolean.FALSE;
try {
Object v = identityVerificationService.getIdentityStatus(userId).get("verified");
verified = Boolean.TRUE.equals(v);
} catch (Exception e) {
log.warn("实名状态查询失败,按未实名处理: userId={}, err={}", userId, e.getMessage());
}
if (!verified) {
throw new RuntimeException("该活动需完成实名认证后方可报名");
}
}
if (TYPES_REQUIRING_LOCAL_ELIGIBILITY.contains(type)) {
String st = null;
try {
st = localEligibilityService.getStatus(userId).getVerificationStatus();
} catch (Exception e) {
log.warn("本地资格状态查询失败,按未核验处理: userId={}, err={}", userId, e.getMessage());
}
if (!Constants.VERIFICATION_STATUS_VERIFIED.equals(st)) {
throw new RuntimeException("该活动需通过本地资格核验后方可报名");
}
}
}
/** 年龄范围校验(原实现为 TODO 空壳,minAge/maxAge 此前完全不生效) */
private void assertAgeInRange(String userId, Activity activity) {
if (activity.getMinAge() == null && activity.getMaxAge() == null) return;
java.time.LocalDate birth = userProfileMapper.findByUserId(userId)
.map(p -> p.getBirthDate()).orElse(null);
if (birth == null) return; // 资料不全时不阻断报名
int age = java.time.Period.between(birth, java.time.LocalDate.now()).getYears();
if (activity.getMinAge() != null && age < activity.getMinAge()) {
throw new RuntimeException("报名年龄未满 " + activity.getMinAge() + " 周岁");
}
if (activity.getMaxAge() != null && age > activity.getMaxAge()) {
throw new RuntimeException("报名年龄超出 " + activity.getMaxAge() + " 周岁上限");
}
}
/** 校验并归一化活动类型;非法值直接拒绝,避免撞生产 CHECK 约束 */
private String requireActivityType(String type) {
if (type == null || !VALID_ACTIVITY_TYPES.contains(type)) {
throw new IllegalArgumentException("非法的活动类型: " + type
+ ",允许值:" + new TreeSet<>(VALID_ACTIVITY_TYPES));
}
return type;
}
/** 校验并归一化活动组织角色;非法值直接拒绝,避免撞生产 CHECK 约束 */
private String requireOrgRole(String role) {
if (role == null || !VALID_ORG_ROLES.contains(role)) {
throw new IllegalArgumentException("非法的活动组织角色: " + role
+ ",允许值:" + new TreeSet<>(VALID_ORG_ROLES));
}
return role;
}
/** 读取用户性别(用于性别名额扣减);资料缺失时返回 null,退化为按总名额约束 */
private String genderOf(String userId) {
return userProfileMapper.findByUserId(userId).map(p -> p.getGender()).orElse(null);
}
// ==================== Phase 15:报名窗口、数据范围与容量分组 ====================
/** 报名窗口校验:registration_start_at / registration_end_at 此前完全不生效 */
private void assertWithinRegistrationWindow(Activity activity) {
Instant now = Instant.now();
if (activity.getRegistrationStartAt() != null && now.isBefore(activity.getRegistrationStartAt())) {
throw new RuntimeException("活动报名尚未开始");
}
if (activity.getRegistrationEndAt() != null && now.isAfter(activity.getRegistrationEndAt())) {
throw new RuntimeException("活动报名已截止");
}
}
/**
* 活动维度的数据范围校验(计划项③)。
*
* <p>服务站操作员只能操作被显式指派的活动;主办/协办组织关系不授予任何操作权限(INV-020)。
* 没有任何指派记录的工作人员视为平台级人员,不受此限制。
*/
private void assertActivityScope(String activityId, String operatorId, String message) {
if (operatorId == null) {
dataScopeGuard.deny(operatorId, "activity", activityId, message);
}
if (staffAssignmentMapper.countAssignmentsForOperator(operatorId) > 0
&& staffAssignmentMapper.canOperateActivity(activityId, operatorId) == 0) {
dataScopeGuard.deny(operatorId, "activity", activityId, message);
}
}
/**
* 解析该用户应落入的容量分组;活动未配置分组时返回 null(表示不启用分组约束)。
*
* <p>分组即配置:只要活动存在 {@code activity_capacity_groups} 记录就启用分组容量,
* 无需额外的开关列(生产 V023 没有 {@code capacity_group_enabled})。
*/
private String resolveCapacityGroupCode(String activityId, String gender) {
java.util.List<ActivityCapacityGroup> groups = capacityGroupMapper.findByActivityId(activityId);
if (groups.isEmpty()) return null;
for (ActivityCapacityGroup g : groups) {
if (g.getGroupCode() != null && g.getGroupCode().equals(gender)) return g.getGroupCode();
}
for (ActivityCapacityGroup g : groups) {
if ("general".equals(g.getGroupCode())) return "general";
}
return null; // 有分组但无匹配 → 不施加分组约束(避免误拒)
}
/**
* 原子占用容量分组名额。
*
* <p>Phase 15 修复:容量分组此前只"落库回读",{@code approved_count} 永远是 0 ——
* 运营配了分组容量也完全不生效。此处让分组真正参与扣减。
*
* @return 被占用的 groupCode;null 表示该活动未启用分组约束
*/
private String occupyCapacityGroup(String activityId, String gender) {
String groupCode = resolveCapacityGroupCode(activityId, gender);
if (groupCode == null) return null;
if (capacityGroupMapper.incrementIfAvailable(activityId, groupCode) == 0) {
throw new RuntimeException("该分组名额已满");
}
return groupCode;
}
/**
* 容忍 null / 时间戳 / 本地无时区文本 / ISO 文本的时间字段。
*
* <p>后台 el-date-picker 默认输出 {@code 2026-09-20T14:00:00}(无时区偏移),
* 直接 {@code Instant.parse} 会抛异常;若简单兜底成"现在",运营填的时间会被**静默丢弃** ——
* 比报错更危险。故无偏移时按服务器时区解释。
*/
private static Instant parseInstant(Object raw, Instant fallback) {
if (raw == null) return fallback;
try {
if (raw instanceof Number n) return Instant.ofEpochMilli(n.longValue());
String s = raw.toString().trim();
if (s.isEmpty()) return fallback;
try {
return Instant.parse(s);
} catch (RuntimeException ignored) {
return java.time.LocalDateTime.parse(s)
.atZone(java.time.ZoneId.systemDefault()).toInstant();
}
} catch (RuntimeException e) {
log.warn("非法时间字段,回落默认值: raw={}, err={}", raw, e.getMessage());
return fallback;
}
}
private static int toInt(Object raw, int fallback) {
return raw instanceof Number n ? n.intValue() : fallback;
}
/** null 表示不设年龄门槛 —— 不能用 0 兜底,否则 age >= minAge 恒成立反而放行 */
private static Short toShort(Object raw) {
return raw instanceof Number n ? n.shortValue() : null;
}
private void saveAuditLog(String operatorId, String action, String resourceType,
String resourceId, String beforeData, String afterData) {
AuditLog auditLog = new AuditLog();
@@ -4,10 +4,14 @@ import com.danyang.match.platform.activity.entity.Activity;
import com.danyang.match.platform.activity.entity.ActivityRegistration;
import com.danyang.match.platform.activity.mapper.ActivityMapper;
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.activity.mapper.ActivityStaffAssignmentMapper;
import com.danyang.match.platform.audit.entity.AuditLog;
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.shared.security.DataScopeGuard;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@@ -16,6 +20,7 @@ import org.springframework.transaction.annotation.Transactional;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.Base64;
import java.util.HashMap;
@@ -30,6 +35,13 @@ import java.util.concurrent.TimeUnit;
@RequiredArgsConstructor
public class CheckinService {
private static final Logger log = LoggerFactory.getLogger(CheckinService.class);
/** Redis 中标记"已签发但未消费"的签到码 */
private static final String TOKEN_STATE_ISSUED = "issued";
/** Redis 中标记"已消费"的签到码(一次性,不可重放) */
private static final String TOKEN_STATE_USED = "used";
@Value("${checkin.token.secret:danyang-match-checkin-secret-change-in-production}")
private String tokenSecret;
@@ -38,15 +50,16 @@ public class CheckinService {
private final ActivityRegistrationMapper registrationMapper;
private final ActivityMapper activityMapper;
private final ActivityStaffAssignmentMapper staffAssignmentMapper;
private final AuditLogMapper auditLogMapper;
private final DataScopeGuard dataScopeGuard;
private final StringRedisTemplate redisTemplate;
/**
* 生成签到Token(短时有效,二维码内容)
* Token格式: base64(userId:activityId:timestamp: HMAC)
* Token格式: base64(userId:activityId:timestamp:HMAC)
*/
public Map<String, Object> generateCheckinToken(String userId, String activityId) {
// 检查是否有有效的报名记录
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("您没有报名该活动"));
@@ -58,24 +71,21 @@ public class CheckinService {
throw new RuntimeException("您已经签到过了");
}
// 检查活动是否在进行中
Activity activity = activityMapper.selectById(activityId);
if (activity == null) {
throw new RuntimeException("活动不存在");
}
Activity activity = requireCheckableActivity(activityId);
Instant now = Instant.now();
if (now.isBefore(activity.getStartAt()) || now.isAfter(activity.getEndAt())) {
throw new RuntimeException("活动未开始或已结束,无法签到");
}
// 生成签名Token
String payload = userId + ":" + activityId + ":" + now.toEpochMilli();
String signature = sign(payload);
String token = Base64.getEncoder().encodeToString((payload + ":" + signature).getBytes(StandardCharsets.UTF_8));
// 缓存Token(防止重放攻击)
String cacheKey = "checkin:token:" + token;
redisTemplate.opsForValue().set(cacheKey, "used", tokenTtlMinutes, TimeUnit.MINUTES);
// Phase 15 修复(必崩):此前此处写入的值为 "used",而 scanCheckin 见到 "used" 立即拒绝 ——
// 结果是**每一个刚生成的签到码都自判已使用**,扫码签到 100% 失败。
// 生成时只能标记 issued,真正的消费标记在扫码成功后写入。
redisTemplate.opsForValue().set(tokenKey(token), TOKEN_STATE_ISSUED, tokenTtlMinutes, TimeUnit.MINUTES);
Map<String, Object> result = new HashMap<>();
result.put("token", token);
@@ -87,125 +97,188 @@ public class CheckinService {
/**
* 工作人员扫码签到
*
* <p>幂等:同一签到码重复扫码、或用户已签到时返回 {@code alreadyCheckedIn=true} 且不重复记审计。
*/
@Transactional
public Map<String, Object> scanCheckin(String token, String staffOperatorId) {
// 1. 解码Token
String decoded;
try {
decoded = new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8);
} catch (Exception e) {
throw new RuntimeException("无效的签到码");
throw new IllegalArgumentException("无效的签到码");
}
String[] parts = decoded.split(":");
if (parts.length != 4) {
throw new RuntimeException("无效的签到码格式");
throw new IllegalArgumentException("无效的签到码格式");
}
String userId = parts[0];
String activityId = parts[1];
long timestamp = Long.parseLong(parts[2]);
long timestamp;
try {
timestamp = Long.parseLong(parts[2]);
} catch (NumberFormatException e) {
// Phase 15 修复:此前非法时间戳会抛 NumberFormatException → 500 而非 400
throw new IllegalArgumentException("无效的签到码格式");
}
String signature = parts[3];
// 2. 验证签名
String expectedPayload = userId + ":" + activityId + ":" + timestamp;
String expectedSignature = sign(expectedPayload);
if (!expectedSignature.equals(signature)) {
throw new RuntimeException("签到码签名验证失败");
// 恒定时间比较,避免通过响应时间侧信道逐字节爆破签名
if (!MessageDigest.isEqual(sign(userId + ":" + activityId + ":" + timestamp).getBytes(StandardCharsets.UTF_8),
signature.getBytes(StandardCharsets.UTF_8))) {
throw new IllegalArgumentException("签到码签名验证失败");
}
// 3. 检查Token是否已使用(防重放)
String cacheKey = "checkin:token:" + token;
String cached = redisTemplate.opsForValue().get(cacheKey);
if ("used".equals(cached)) {
throw new RuntimeException("该签到码已使用过");
if (Instant.now().isAfter(Instant.ofEpochMilli(timestamp).plusSeconds(tokenTtlMinutes * 60L))) {
throw new IllegalArgumentException("签到码已过期");
}
// 4. 检查Token是否过期
Instant tokenTime = Instant.ofEpochMilli(timestamp);
if (Instant.now().isAfter(tokenTime.plusSeconds(tokenTtlMinutes * 60L))) {
throw new RuntimeException("签到码已过期");
}
// Gate「签到不可跨活动复用」:码里带的 activityId 决定作用域,
// 工作人员必须对该活动有操作权限(显式指派),否则拒绝并记审计。
assertCheckinScope(activityId, staffOperatorId);
// 5. 检查报名记录
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("未找到报名记录"));
.orElseThrow(() -> new IllegalArgumentException("未找到报名记录"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("报名未审核通过,无法签到");
throw new IllegalArgumentException("报名未审核通过,无法签到");
}
String cacheKey = tokenKey(token);
// 幂等:无论拿着哪个码,只要该用户已签到就直接返回成功,避免现场重复扫码报错
if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) {
throw new RuntimeException("该用户已经签到");
redisTemplate.opsForValue().set(cacheKey, TOKEN_STATE_USED, 1, TimeUnit.HOURS);
Map<String, Object> idempotent = new HashMap<>();
idempotent.put("success", true);
idempotent.put("userId", userId);
idempotent.put("activityId", activityId);
idempotent.put("alreadyCheckedIn", true);
idempotent.put("checkedInAt", registration.getCheckinAt());
return idempotent;
}
// 6. 执行签到
registration.setCheckinStatus(Constants.CHECKIN_STATUS_CHECKED_IN);
registration.setCheckinAt(Instant.now());
registrationMapper.updateById(registration);
// 一次性:已消费的码不可被**换人**重放
if (TOKEN_STATE_USED.equals(redisTemplate.opsForValue().get(cacheKey))) {
throw new IllegalArgumentException("该签到码已使用过");
}
// 7. 标记Token为已使用
redisTemplate.opsForValue().set(cacheKey, "used", 1, TimeUnit.HOURS);
// 原子签到:affectedRows=0 即并发下已被他人签到
int affected = registrationMapper.checkinIfNotCheckedIn(registration.getId());
// 8. 审计
saveAuditLog(staffOperatorId, "CHECKIN", "activity_registration", registration.getId(), null, null);
redisTemplate.opsForValue().set(cacheKey, TOKEN_STATE_USED, 1, TimeUnit.HOURS);
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("userId", userId);
result.put("activityId", activityId);
result.put("checkedInAt", Instant.now());
result.put("alreadyCheckedIn", affected == 0);
if (affected > 0) {
Instant checkedInAt = Instant.now();
saveAuditLog(staffOperatorId, "CHECKIN", "activity_registration", registration.getId(), null, null);
result.put("checkedInAt", checkedInAt);
} else {
ActivityRegistration latest = registrationMapper.selectById(registration.getId());
result.put("checkedInAt", latest == null ? null : latest.getCheckinAt());
}
return result;
}
/**
* 手工签到(工作人员手动输入)
*
* <p>Phase 15 修复:原实现既无活动存在性/状态校验,也无工作人员数据范围校验 ——
* 任意持 {@code activity.checkin} 权限的人可以对任意活动的任意用户签到(违反 INV-020 精神)。
*/
@Transactional
public Map<String, Object> manualCheckin(String userId, String activityId, String staffOperatorId) {
requireCheckableActivity(activityId);
assertCheckinScope(activityId, staffOperatorId);
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
.orElseThrow(() -> new RuntimeException("未找到报名记录"));
.orElseThrow(() -> new IllegalArgumentException("未找到报名记录"));
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
throw new RuntimeException("报名未审核通过");
throw new IllegalArgumentException("报名未审核通过,无法签到");
}
if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) {
throw new RuntimeException("该用户已经签到");
}
registration.setCheckinStatus(Constants.CHECKIN_STATUS_CHECKED_IN);
registration.setCheckinAt(Instant.now());
registrationMapper.updateById(registration);
saveAuditLog(staffOperatorId, "MANUAL_CHECKIN", "activity_registration", registration.getId(), null, null);
int affected = registrationMapper.checkinIfNotCheckedIn(registration.getId());
Map<String, Object> result = new HashMap<>();
result.put("success", true);
result.put("checkedInAt", Instant.now());
result.put("userId", userId);
result.put("activityId", activityId);
result.put("alreadyCheckedIn", affected == 0);
if (affected > 0) {
saveAuditLog(staffOperatorId, "MANUAL_CHECKIN", "activity_registration", registration.getId(), null, null);
result.put("checkedInAt", Instant.now());
} else {
ActivityRegistration latest = registrationMapper.selectById(registration.getId());
result.put("checkedInAt", latest == null ? null : latest.getCheckinAt());
}
return result;
}
/**
* 获取活动签到统计
*
* <p>Phase 15 修复:活动不存在/已软删时原实现直接 NPE;
* 且 {@code remainingCount} 用「总名额 − 已签到」计算,语义上是"空座"而非"未签到人数"。
*/
public Map<String, Object> getCheckinStats(String activityId) {
ActivityRegistrationMapper regMapper = registrationMapper;
// 统计已签到人数
long checkedInCount = registrationMapper.findApprovedByActivity(activityId).stream()
Activity activity = activityMapper.selectById(activityId);
if (activity == null) {
throw new IllegalArgumentException("活动不存在");
}
java.util.List<ActivityRegistration> approved = registrationMapper.findApprovedByActivity(activityId);
long checkedInCount = approved.stream()
.filter(r -> Constants.CHECKIN_STATUS_CHECKED_IN.equals(r.getCheckinStatus()))
.count();
Activity activity = activityMapper.selectById(activityId);
Map<String, Object> stats = new HashMap<>();
stats.put("activityId", activityId);
stats.put("totalApproved", registrationMapper.findApprovedByActivity(activityId).size());
stats.put("totalApproved", approved.size());
stats.put("checkedInCount", checkedInCount);
stats.put("remainingCount", Math.max(0, (activity.getTotalLimit() != null ? activity.getTotalLimit() : 0) - checkedInCount));
// 语义修正:运营关心的是"还有多少已通过的人没到场"
stats.put("notCheckedInCount", approved.size() - checkedInCount);
return stats;
}
/**
* 签到数据范围校验(Phase 15 计划项③)。
*
* <p>服务站操作员只能操作**被显式指派**的活动;主办/协办组织关系不授予任何操作权限(INV-020)。
* 没有任何指派记录的工作人员视为平台级活动管理员,不受此限制。
*/
private void assertCheckinScope(String activityId, String operatorId) {
if (operatorId == null) {
dataScopeGuard.deny(operatorId, "activity", activityId, "无法识别签到操作人");
}
if (staffAssignmentMapper.countAssignmentsForOperator(operatorId) > 0
&& staffAssignmentMapper.canOperateActivity(activityId, operatorId) == 0) {
dataScopeGuard.deny(operatorId, "activity", activityId, "只能为被指派的活动执行签到");
}
}
/** 活动必须存在且未被取消,否则签到无意义 */
private Activity requireCheckableActivity(String activityId) {
Activity activity = activityMapper.selectById(activityId);
if (activity == null) {
throw new IllegalArgumentException("活动不存在");
}
if (Constants.ACTIVITY_STATUS_CANCELLED.equals(activity.getStatus())) {
throw new IllegalArgumentException("活动已取消,无法签到");
}
return activity;
}
private static String tokenKey(String token) {
return "checkin:token:" + token;
}
/**
* 计算HMAC-SHA256签名
*/
@@ -2,6 +2,7 @@ package com.danyang.match.platform.activity_mutual.controller;
import com.danyang.match.platform.activity_mutual.dto.MutualInterestResultDTO;
import com.danyang.match.platform.activity_mutual.service.ActivityMutualConsentService;
import com.danyang.match.platform.shared.idempotency.Idempotent;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
@@ -38,6 +39,7 @@ public class ActivityMutualConsentController {
* P16-T04: 创建互选同意记录
* POST /api/v1/activities/{id}/mutual/consent
*/
@Idempotent
@PostMapping("/consent")
public ApiResponse<?> createConsent(
@AuthenticationPrincipal UserPrincipal principal,
@@ -52,6 +54,7 @@ public class ActivityMutualConsentController {
* P16-T05/T06: 用户确认/拒绝开启沟通
* POST /api/v1/activities/{id}/mutual/confirm
*/
@Idempotent
@PostMapping("/confirm")
public ApiResponse<Map<String, Object>> confirmConnection(
@AuthenticationPrincipal UserPrincipal principal,
@@ -66,6 +69,7 @@ public class ActivityMutualConsentController {
* P16-T12: 用户拒绝开启沟通
* POST /api/v1/activities/{id}/mutual/decline
*/
@Idempotent
@PostMapping("/decline")
public ApiResponse<Map<String, Object>> declineConnection(
@AuthenticationPrincipal UserPrincipal principal,
@@ -23,6 +23,10 @@ public class ActivityMutualConsent extends BaseEntity {
private String userAStatus;
/** B的同意状态:pending / accepted / declined */
private String userBStatus;
/** 双方都同意时的时间戳 */
/** 双方都同意时的时间戳(非 NULL 即代表已双确认,exactly-once 的判定依据) */
private Instant bothConfirmedAt;
/** 二阶段同意截止时间(V041);NULL 表示不限制。超时后不能再推动成双确认 */
private Instant expiresAt;
/** 双确认形成的 Match Episode id(V041),仅用于溯源 */
private String matchId;
}
@@ -5,6 +5,7 @@ import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
import java.util.List;
import java.util.Optional;
@@ -12,20 +13,98 @@ import java.util.Optional;
@Mapper
public interface ActivityMutualConsentMapper extends BaseMapper<ActivityMutualConsent> {
// Phase 16 修复:以下查询此前全部缺少 deleted_at IS NULL。
// V037 已为该表补上 deleted_at,软删后的同意记录仍会被检出,
// 导致已作废的互选关系继续参与判断。
@Select("SELECT * FROM activity_mutual_connection_consents " +
"WHERE activity_id = #{activityId} " +
"AND ((user_a_id = #{userId} AND user_b_id = #{partnerId}) " +
"OR (user_a_id = #{partnerId} AND user_b_id = #{userId})) " +
"AND deleted_at IS NULL " +
"LIMIT 1")
Optional<ActivityMutualConsent> findByActivityPair(@Param("activityId") String activityId,
@Param("userId") String userId,
@Param("partnerId") String partnerId);
@Select("SELECT * FROM activity_mutual_connection_consents " +
"WHERE activity_id = #{activityId} AND user_a_status = 'accepted' AND user_b_status = 'accepted'")
"WHERE activity_id = #{activityId} AND user_a_status = 'accepted' AND user_b_status = 'accepted' " +
"AND deleted_at IS NULL")
List<ActivityMutualConsent> findBothConfirmedByActivity(String activityId);
@Select("SELECT * FROM activity_mutual_connection_consents " +
"WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) AND user_a_status = 'accepted' AND user_b_status = 'accepted'")
"WHERE (user_a_id = #{userId} OR user_b_id = #{userId}) " +
"AND user_a_status = 'accepted' AND user_b_status = 'accepted' " +
"AND deleted_at IS NULL")
List<ActivityMutualConsent> findBothConfirmedByUser(String userId);
/**
* 原子确认本方(Phase 16 核心)。
*
* <p>单条 SQL 内完成三件事,避免「先读后写」在并发下丢更新:
* <ol>
* <li>只把「调用方所在侧」置为 accepted(CASE WHEN 定位本侧);</li>
* <li>若两侧因此都变成 accepted,则同一语句写入 both_confirmed_at = NOW();</li>
* <li>WHERE 同时要求:未软删、尚未双确认、未过期、本侧当前为 pending。</li>
* </ol>
*
* <p><b>exactly-once 依据</b>:把 both_confirmed_at 从 NULL 改成非 NULL 只能有一个事务成功
* (其它并发事务的 WHERE both_confirmed_at IS NULL 不再成立),
* 因此只有 affectedRows == 1 且回读 both_confirmed_at != null 的那一方负责创建 Match。
*
* @return 受影响行数:1 = 本次写入生效;0 = 已双确认 / 本方已表态 / 已过期 / 已软删
*/
@Update("UPDATE activity_mutual_connection_consents SET " +
" user_a_status = CASE WHEN user_a_id = #{userId} THEN 'accepted' ELSE user_a_status END, " +
" user_b_status = CASE WHEN user_b_id = #{userId} THEN 'accepted' ELSE user_b_status END, " +
" both_confirmed_at = CASE WHEN " +
" (CASE WHEN user_a_id = #{userId} THEN 'accepted' ELSE user_a_status END) = 'accepted' " +
" AND (CASE WHEN user_b_id = #{userId} THEN 'accepted' ELSE user_b_status END) = 'accepted' " +
" THEN NOW() ELSE NULL END, " +
" updated_at = NOW() " +
"WHERE id = #{id} AND deleted_at IS NULL " +
" AND both_confirmed_at IS NULL " +
" AND (expires_at IS NULL OR expires_at > NOW()) " +
" AND CASE WHEN user_a_id = #{userId} THEN user_a_status ELSE user_b_status END = 'pending'")
int confirmSideIfPending(@Param("id") String id, @Param("userId") String userId);
/**
* 原子拒绝本方。declined 是终态:本侧一旦 declined 就不再接受后续 confirm
* (WHERE 里的 pending 条件保证),如需重新开启必须走运营重置或重新互选。
*/
@Update("UPDATE activity_mutual_connection_consents SET " +
" user_a_status = CASE WHEN user_a_id = #{userId} THEN 'declined' ELSE user_a_status END, " +
" user_b_status = CASE WHEN user_b_id = #{userId} THEN 'declined' ELSE user_b_status END, " +
" updated_at = NOW() " +
"WHERE id = #{id} AND deleted_at IS NULL " +
" AND both_confirmed_at IS NULL " +
" AND CASE WHEN user_a_id = #{userId} THEN user_a_status ELSE user_b_status END = 'pending'")
int declineSideIfPending(@Param("id") String id, @Param("userId") String userId);
/** 记录双确认后形成的 Match,便于溯源与幂等回查 */
@Update("UPDATE activity_mutual_connection_consents SET match_id = #{matchId}, updated_at = NOW() " +
"WHERE id = #{id} AND deleted_at IS NULL")
int bindMatch(@Param("id") String id, @Param("matchId") String matchId);
// ==================== Phase 20:互选口径统计 ====================
/**
* **双向已确认**的互选对数(Gate「活动10对互选 ≠ 10 对 Success」的分母侧)。
*
* <p>「互选」的严格定义是两侧均为 {@code accepted},而不是「有人点了同意」。
* Match 的 {@code activity_mutual} 来源行只有在双确认后才创建,
* 但互选**对数**与后续是否走到 {@code status='successful'} 是两件事:
* 前者是活动互选环节的产出,后者是关系推进的产出,二者不可互相替代。
*
* <p>去重键:{@code id}(同一对用户在同一活动内只有一行)。
*/
@Select("SELECT COUNT(*) FROM activity_mutual_connection_consents "
+ "WHERE user_a_status = 'accepted' AND user_b_status = 'accepted' AND deleted_at IS NULL")
long countBothConfirmed();
/** 仅单侧表态的记录数(用于观察"已表达但未配成"的漏斗中间态)。 */
@Select("SELECT COUNT(*) FROM activity_mutual_connection_consents WHERE deleted_at IS NULL "
+ "AND (user_a_status = 'accepted' OR user_b_status = 'accepted') "
+ "AND NOT (user_a_status = 'accepted' AND user_b_status = 'accepted')")
long countOneSidedAccepted();
}
@@ -3,10 +3,12 @@ package com.danyang.match.platform.activity_mutual.service;
import com.danyang.match.platform.activity_mutual.dto.MutualInterestResultDTO;
import com.danyang.match.platform.activity_mutual.entity.ActivityMutualConsent;
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
import com.danyang.match.platform.relationship.service.RelationshipService;
import com.danyang.match.platform.shared.constant.Constants;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.*;
@@ -17,7 +19,7 @@ import java.util.*;
public class ActivityMutualConsentService {
private final ActivityMutualConsentMapper consentMapper;
private final com.danyang.match.platform.relationship.mapper.MatchMapper matchMapper;
private final RelationshipService relationshipService;
public List<MutualInterestResultDTO> detectMutualResults(String activityId) {
List<ActivityMutualConsent> bothConfirmed = consentMapper.findBothConfirmedByActivity(activityId);
@@ -47,32 +49,60 @@ public class ActivityMutualConsentService {
return consent;
}
@Transactional
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
if (userId.equals(consent.getUserAId())) {
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_ACCEPTED);
} else {
consent.setUserBStatus(Constants.MUTUAL_CONSENT_STATUS_ACCEPTED);
}
consent.setUpdatedAt(Instant.now());
consentMapper.updateById(consent);
// 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted,
// 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。
// return 1 表示该事务是「把双确认落定」的那一方。
int affected = consentMapper.confirmSideIfPending(consent.getId(), userId);
// 回读权威状态(可能受并发另一方提交影响)
consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("互选记录已不存在"));
boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus());
boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus());
boolean amA = userId.equals(consent.getUserAId());
Map<String, Object> result = new HashMap<>();
result.put("myStatus", userId.equals(consent.getUserAId()) ? (aAccepted ? "accepted" : "pending") : (bAccepted ? "accepted" : "pending"));
result.put("partnerStatus", userId.equals(consent.getUserAId()) ? (bAccepted ? "accepted" : "pending") : (aAccepted ? "accepted" : "pending"));
result.put("myStatus", amA ? (aAccepted ? "accepted" : "pending") : (bAccepted ? "accepted" : "pending"));
result.put("partnerStatus", amA ? (bAccepted ? "accepted" : "pending") : (aAccepted ? "accepted" : "pending"));
if (aAccepted && bAccepted) {
consent.setBothConfirmedAt(Instant.now());
consentMapper.updateById(consent);
result.put("bothConfirmed", true);
log.info("双方确认活动互选: activityId={}, userA={}, userB={}", activityId, consent.getUserAId(), consent.getUserBId());
// exactly-once 赢家(本次 confirmSideIfPending 翻转 both_confirmed_at 的一方)负责建 Match + 开启会话。
// 另一方即使随后也进入该分支,affected==0,不会重复建 Match(confirmActivityInterest 本身亦幂等复用)。
if (affected == 1 && consent.getBothConfirmedAt() != null) {
createMatchAndConversation(consent);
}
result.put("matchCreated", true);
} else {
result.put("bothConfirmed", false);
result.put("matchCreated", false);
}
return result;
}
/**
* 双确认后形成 Match 并开启 Conversation(Gate:互选确认后双方可沟通)。
* 通过统一的 {@code RelationshipService.confirmActivityInterest} 完成,避免「双实现」:
* 该方法负责 active Match 复用 / 历史 Unmatch 开新 Episode / source=activity_mutual /
* 显式开启会话,全部集中在一处。
*/
private void createMatchAndConversation(ActivityMutualConsent consent) {
Map<String, Object> matchResult = relationshipService.confirmActivityInterest(
consent.getUserAId(), consent.getUserBId());
Object matchIdObj = matchResult.get("matchId");
if (matchIdObj != null) {
consentMapper.bindMatch(consent.getId(), String.valueOf(matchIdObj));
}
log.info("活动互选双确认后形成Match并开启会话: activityId={}, userA={}, userB={}, matchId={}",
consent.getActivityId(), consent.getUserAId(), consent.getUserBId(), matchIdObj);
}
public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) {
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
@@ -89,10 +119,6 @@ public class ActivityMutualConsentService {
return result;
}
public boolean hasActiveMatch(String userId, String partnerId) {
return matchMapper.findActiveMatch(userId, partnerId).isPresent();
}
public List<MutualInterestResultDTO> getMyMutualResults(String userId) {
List<ActivityMutualConsent> consents = consentMapper.findBothConfirmedByUser(userId);
return consents.stream().map(c -> toResultDTO(c, c.getActivityId())).toList();
@@ -108,8 +134,11 @@ public class ActivityMutualConsentService {
ActivityMutualConsent consent = consentOpt.get();
boolean myAccepted = (userId.equals(consent.getUserAId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus()))
|| (userId.equals(consent.getUserBId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus()));
boolean partnerAccepted = (userId.equals(consent.getUserAId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus()))
boolean partnerSideAccepted = (userId.equals(consent.getUserAId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus()))
|| (userId.equals(consent.getUserBId()) && Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus()));
// INV-006 单向意向保密:只有本侧也已确认,才允许回显对方是否已确认。
// 否则 A 已确认、B 未确认时,B 能通过此接口探知 A 的意向,违反「单向意向不暴露」。
boolean partnerAccepted = myAccepted && partnerSideAccepted;
Map<String, Object> result = new HashMap<>();
result.put("exists", true);
result.put("myStatus", myAccepted ? "accepted" : "pending");
@@ -1,41 +1,30 @@
package com.danyang.match.platform.analytics;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.match.entity.RecommendationRecord;
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
import com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper;
import com.danyang.match.platform.messaging.entity.Conversation;
import com.danyang.match.platform.messaging.mapper.ConversationMapper;
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.analytics.metric.AnalyticsMetricsCalculator;
import com.danyang.match.platform.chat.entity.Conversation;
import com.danyang.match.platform.chat.mapper.ConversationMapper;
import com.danyang.match.platform.content.entity.ContentArticle;
import com.danyang.match.platform.content.mapper.ContentArticleMapper;
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.match.entity.Match;
import com.danyang.match.platform.match.mapper.MatchMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.relationship.entity.Match;
import com.danyang.match.platform.relationship.entity.RelationshipSuccessRequest;
import com.danyang.match.platform.relationship.mapper.MatchMapper;
import com.danyang.match.platform.relationship.mapper.RelationshipSuccessRequestMapper;
import com.danyang.match.platform.risk.entity.Report;
import com.danyang.match.platform.risk.mapper.ReportMapper;
import com.danyang.match.platform.risk.entity.RiskAction;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
import com.danyang.match.platform.shared.exception.BusinessException;
import com.danyang.match.platform.shared.enums.ApiResponseCode;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.banner.mapper.HomeBannerMapper;
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
import com.danyang.match.platform.content.mapper.ContentArticleMapper;
import com.danyang.match.platform.service_program.mapper.ServiceProgramMapper;
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
import com.danyang.match.platform.activity.mapper.ActivityMapper;
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
import com.danyang.match.platform.counseling.entity.CounselingService;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.content.entity.ContentArticle;
import com.danyang.match.platform.service_program.entity.ServiceProgram;
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
import com.danyang.match.platform.activity.entity.ActivityRegistration;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
@@ -43,43 +32,70 @@ import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.*;
import java.util.stream.Collectors;
/**
* Analytics 服务
* 提供:Dashboard / 用户漏斗 / Match漏斗 / 每日汇总 / 趋势图数据
* Analytics 服务 —— 对外提供各统计视图的数据装配。
*
* <p><b>Phase 20 重构说明</b>:本类不再**自己**计算任何指标数字,
* 一律委托 {@link AnalyticsMetricsCalculator}(唯一口径来源)。
*
* <p>修复前的问题:Dashboard / 用户漏斗 / V11 专项仪表盘各自实现了一遍同名指标,
* 且互不相同:
* <ul>
* <li>"实名人数"被算成 {@code profile_status != 'rejected'}(资料没被退回),
* 把「资料还在填」和「被退回待改」都算成了已实名;</li>
* <li>"开启聊天数"直接数会话行数,从不看消息类型,一条系统通知即计入;</li>
* <li>"Match 数"混用全部来源,导致活动互选、红娘促成、用户自主匹配
* 被当成同一个数字,Gate 要求的「10 对互选 ≠ 10 对 Success」无法体现;</li>
* <li>活动漏斗全部硬编码为 0,后台永远显示 0。</li>
* </ul>
*
* <p><b>字段名兼容性</b>:admin-web 已消费的响应字段名({@code verifiedCount} /
* {@code totalLiked} / {@code chatsStarted} 等)一律保留,改动只发生在**取值来源**上,
* 不破坏前端契约。新增的按来源分离字段为增量添加。
*/
@Service
@RequiredArgsConstructor
public class AnalyticsService {
/** 唯一口径来源。 */
private final AnalyticsMetricsCalculator metrics;
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final MatchMapper matchMapper;
private final RelationshipSuccessRequestMapper successRequestMapper;
private final ReportMapper reportMapper;
private final RiskActionMapper riskActionMapper;
private final RecommendationRecordMapper recommendationRecordMapper;
private final ConversationMapper conversationMapper;
private final MatchmakerServiceMapper matchmakerServiceMapper;
private final HomeBannerMapper homeBannerMapper;
private final CounselingServiceMapper counselingServiceMapper;
private final LocalEligibilityMapper localEligibilityMapper;
private final ServiceAppointmentMapper serviceAppointmentMapper;
private final ActivityMutualConsentMapper activityMutualConsentMapper;
private final ContentArticleMapper contentArticleMapper;
private final ServiceProgramMapper serviceProgramMapper;
private final ServiceProgramMemberMapper serviceProgramMemberMapper;
private final ActivityRegistrationMapper activityRegistrationMapper;
// ==================== Dashboard ====================
/**
* 综合看板。
*
* <p>与 {@link #getUserFunnel} / {@link #getMatchFunnel} 共用同一批口径,
* 确保 Gate「Dashboard 和专项页面相同指标必须一致」成立。
*/
public Map<String, Object> getDashboardStats() {
Map<String, Object> stats = new HashMap<>();
stats.putAll(userBasicStats());
stats.putAll(matchFunnelStats());
stats.putAll(safetyStats());
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("totalUsers", metrics.registeredUsers());
stats.put("verifiedUsers", metrics.realNameVerifiedUsers());
stats.put("approvedProfiles", metrics.profileApprovedUsers());
stats.put("activeUsers7d", metrics.activeUsers7d());
stats.put("totalLikes", metrics.likes());
stats.put("totalMatches", metrics.matches());
stats.put("chatsStarted", metrics.chatsStarted());
stats.put("gettingToKnow", matchMapper.countByStatus("getting_to_know"));
stats.put("successPairs", metrics.successfulMatches());
stats.putAll(activityFunnelStats());
stats.putAll(safetyStats());
return stats;
}
@@ -88,45 +104,46 @@ public class AnalyticsService {
public Map<String, Object> getUserFunnel(LocalDate since, LocalDate until) {
if (since == null) since = LocalDate.now().minusMonths(1);
if (until == null) until = LocalDate.now();
Map<String, Object> result = new HashMap<>();
Map<String, Object> result = new LinkedHashMap<>();
// 各阶段总量
long totalRegistered = userMapper.selectCount(null);
long verifiedCount = userProfileMapper.selectCount(
new LambdaQueryWrapper<UserProfile>()
.ne(UserProfile::getProfileStatus, "rejected"));
long profileApproved = userProfileMapper.selectCount(
new LambdaQueryWrapper<UserProfile>()
.eq(UserProfile::getProfileStatus, "approved"));
long totalRegistered = metrics.registeredUsers();
// Phase 20 D1:实名口径改为 user_identities.verification_status='verified'。
long verifiedCount = metrics.realNameVerifiedUsers();
long profileApproved = metrics.profileApprovedUsers();
// activeUsers 在本视图中的语义是「所选时间范围内登录过」,
// 因此使用范围起点而非固定 7 天窗口(与 Dashboard 的 activeUsers7d 语义不同,勿混用)。
// deleted_at 必须显式过滤:BaseEntity 无 @TableLogic,selectCount 不会自动排除软删。
long activeUsers = userMapper.selectCount(
new LambdaQueryWrapper<User>()
.ge(User::getLastLoginAt, since.atStartOfDay(ZoneId.systemDefault()).toInstant()));
.ge(User::getLastLoginAt, since.atStartOfDay(ZoneId.systemDefault()).toInstant())
.isNull(User::getDeletedAt));
result.put("totalRegistered", totalRegistered);
result.put("verifiedCount", verifiedCount);
result.put("profileApproved", profileApproved);
result.put("activeUsers", activeUsers);
// 转化率
result.put("verifyRate", totalRegistered > 0 ? String.format("%.1f%%", 100.0 * verifiedCount / totalRegistered) : "0%");
result.put("approvalRate", verifiedCount > 0 ? String.format("%.1f%%", 100.0 * profileApproved / verifiedCount) : "0%");
result.put("activeRate", totalRegistered > 0 ? String.format("%.1f%%", 100.0 * activeUsers / totalRegistered) : "0%");
result.put("verifyRate", rate(verifiedCount, totalRegistered));
result.put("approvalRate", rate(profileApproved, verifiedCount));
result.put("activeRate", rate(activeUsers, totalRegistered));
// 每日新增趋势
List<Map<String, Object>> dailyTrend = buildDailyNewUsers(since, until);
result.put("dailyTrend", dailyTrend);
result.put("dailyTrend", buildDailyNewUsers(since, until));
// 男女比例
// Phase 20 D12(补全):性别/年龄分布此前漏了软删过滤,
// 会让"分布"与已完成软删过滤的 totalRegistered 对不上账。
Map<String, Long> genderRatio = userProfileMapper.selectList(
new LambdaQueryWrapper<UserProfile>().isNotNull(UserProfile::getGender))
new LambdaQueryWrapper<UserProfile>()
.isNotNull(UserProfile::getGender)
.isNull(UserProfile::getDeletedAt))
.stream()
.collect(Collectors.groupingBy(
UserProfile::getGender, Collectors.counting()));
.collect(java.util.stream.Collectors.groupingBy(
UserProfile::getGender, java.util.stream.Collectors.counting()));
result.put("genderRatio", genderRatio);
// 年龄分布
List<UserProfile> profiles = userProfileMapper.selectList(
new LambdaQueryWrapper<UserProfile>().isNotNull(UserProfile::getBirthDate));
new LambdaQueryWrapper<UserProfile>()
.isNotNull(UserProfile::getBirthDate)
.isNull(UserProfile::getDeletedAt));
List<Integer> breaks = Arrays.asList(18, 25, 30, 35, 40, 45, 100);
Map<String, Long> ageDist = new LinkedHashMap<>();
for (int i = 0; i < breaks.size() - 1; i++) {
@@ -146,28 +163,20 @@ public class AnalyticsService {
return result;
}
// ==================== Match漏斗 ====================
// ==================== Match 漏斗 ====================
public Map<String, Object> getMatchFunnel(LocalDate since, LocalDate until) {
if (since == null) since = LocalDate.now().minusMonths(1);
if (until == null) until = LocalDate.now();
Map<String, Object> result = new HashMap<>();
Map<String, Object> result = new LinkedHashMap<>();
// 总量
long totalRecommended = recommendationRecordMapper.selectCount(
new LambdaQueryWrapper<RecommendationRecord>()
.isNotNull(RecommendationRecord::getShownAt));
long totalLiked = recommendationRecordMapper.selectCount(
new LambdaQueryWrapper<RecommendationRecord>()
.eq(RecommendationRecord::getAction, "liked"));
long totalMatches = matchMapper.selectCount(null);
long chatsStarted = conversationMapper.selectCount(null);
long gettingToKnow = matchMapper.selectCount(
new LambdaQueryWrapper<Match>()
.eq(Match::getStatus, "getting_to_know"));
long successPairs = successRequestMapper.selectCount(
new LambdaQueryWrapper<RelationshipSuccessRequest>()
.eq(RelationshipSuccessRequest::getStatus, "confirmed"));
long totalRecommended = metrics.recommendationsShown();
long totalLiked = metrics.likes();
long totalMatches = metrics.matches();
// Phase 20 D3:只统计有真人消息的会话,系统通知不计入「开启聊天」。
long chatsStarted = metrics.chatsStarted();
long gettingToKnow = matchMapper.countByStatus("getting_to_know");
long successPairs = metrics.successfulMatches();
result.put("totalRecommended", totalRecommended);
result.put("totalLiked", totalLiked);
@@ -176,61 +185,75 @@ public class AnalyticsService {
result.put("gettingToKnow", gettingToKnow);
result.put("successPairs", successPairs);
// 各阶段转化率
result.put("likeRate", totalRecommended > 0 ? String.format("%.1f%%", 100.0 * totalLiked / totalRecommended) : "0%");
result.put("matchRate", totalLiked > 0 ? String.format("%.1f%%", 100.0 * totalMatches / totalLiked) : "0%");
result.put("chatRate", totalMatches > 0 ? String.format("%.1f%%", 100.0 * chatsStarted / totalMatches) : "0%");
result.put("successRate", totalMatches > 0 ? String.format("%.1f%%", 100.0 * successPairs / totalMatches) : "0%");
result.put("likeRate", rate(totalLiked, totalRecommended));
result.put("matchRate", rate(totalMatches, totalLiked));
result.put("chatRate", rate(chatsStarted, totalMatches));
result.put("successRate", rate(successPairs, totalMatches));
// 每日新增匹配趋势
List<Map<String, Object>> dailyTrend = buildDailyNewMatches(since, until);
result.put("dailyTrend", dailyTrend);
// Phase 20 D4:按来源分离,避免把活动互选/红娘促成的量算进用户自主匹配。
Map<String, Object> bySource = new LinkedHashMap<>();
bySource.put("userLike", metrics.matchesBySource("user_like"));
bySource.put("activityMutual", metrics.matchesBySource("activity_mutual"));
bySource.put("matchmaker", metrics.matchesBySource("matchmaker"));
result.put("matchesBySource", bySource);
result.put("matchmakerRecommendations", metrics.matchmakerRecommendations());
result.put("dailyTrend", buildDailyNewMatches(since, until));
return result;
}
// ==================== 活动漏斗 ====================
/**
* 活动漏斗。
*
* <p><b>Phase 20 D5 修复</b>:修复前本方法的所有指标**硬编码为 0**
* (并且留着一行 {@code userMapper.selectCount(...)} 把"用户数"当成"活动数"的死代码),
* 后台"活动漏斗"因此永远显示 0 —— 不是没有数据,是根本没有查询。
*
* <p>同时严格区分「互选成立」与「互选后成功」:Gate 要求
* 「活动10对互选 ≠ 10 对 Success」。
*/
public Map<String, Object> getActivityFunnel() {
Map<String, Object> result = new HashMap<>();
// 活动数量(简化:统计 distinct activity_id)
long activityCount = userMapper.selectCount(
new LambdaQueryWrapper<>() {}); // placeholder
// 报名数、通过数、签到数需要从 activity_registrations 统计
// 简化处理
result.put("totalActivities", 0);
result.put("totalRegistrations", 0);
result.put("approvedRegistrations", 0);
result.put("checkedInCount", 0);
return result;
return activityFunnelStats();
}
private Map<String, Object> activityFunnelStats() {
Map<String, Object> s = new LinkedHashMap<>();
s.put("totalActivities", metrics.activities());
s.put("totalRegistrations", metrics.activityRegistrations());
s.put("approvedRegistrations", metrics.approvedActivityRegistrations());
s.put("checkedInCount", metrics.checkedInRegistrations());
// 互选成立对数(严格双确认)
s.put("mutualBothConfirmed", metrics.mutualBothConfirmed());
// 互选后建立 Match 数
s.put("mutualMatches", metrics.mutualMatches());
// 互选后成功牵手数 —— 与互选对数严格区分
s.put("mutualSuccessful", metrics.mutualSuccessful());
return s;
}
// ==================== 安全统计 ====================
public Map<String, Object> getSafetyStats() {
Map<String, Object> stats = new HashMap<>();
long pendingReports = reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING);
long resolvedReports = reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED);
long highRiskUsers = riskActionMapper.selectCount(
new LambdaQueryWrapper<RiskAction>()
.in(RiskAction::getActionType,
Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
.eq(RiskAction::getStatus, "active"));
long bannedUsers = riskActionMapper.selectCount(
new LambdaQueryWrapper<RiskAction>()
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
.eq(RiskAction::getStatus, "active"));
return safetyStats();
}
stats.put("pendingReports", pendingReports);
stats.put("resolvedReports", resolvedReports);
stats.put("highRiskUsers", highRiskUsers);
stats.put("bannedUsers", bannedUsers);
private Map<String, Object> safetyStats() {
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("pendingReports", metrics.pendingReports());
stats.put("resolvedReports", metrics.resolvedReports());
stats.put("highRiskUsers", metrics.highRiskUsers());
stats.put("bannedUsers", metrics.bannedUsers());
return stats;
}
// ==================== 每日汇总 ====================
public List<Map<String, Object>> getDailySummary(int days) {
if (days < 1 || days > 365) {
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR.getCode(), "days 必须在 1~365 之间");
}
List<Map<String, Object>> summary = new ArrayList<>();
LocalDate today = LocalDate.now();
@@ -239,7 +262,7 @@ public class AnalyticsService {
Instant start = date.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = date.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
Map<String, Object> day = new HashMap<>();
Map<String, Object> day = new LinkedHashMap<>();
day.put("date", date.toString());
day.put("newUsers", countEntityByDateRange(start, end));
day.put("newMatches", countMatchByDateRange(start, end));
@@ -250,97 +273,36 @@ public class AnalyticsService {
return summary;
}
// ==================== 私有辅助方法 ====================
private Map<String, Object> userBasicStats() {
Map<String, Object> s = new HashMap<>();
s.put("totalUsers", userMapper.selectCount(null));
s.put("verifiedUsers", userProfileMapper.selectCount(
new LambdaQueryWrapper<UserProfile>().ne(UserProfile::getProfileStatus, "rejected")));
s.put("approvedProfiles", userProfileMapper.selectCount(
new LambdaQueryWrapper<UserProfile>().eq(UserProfile::getProfileStatus, "approved")));
s.put("activeUsers7d", userMapper.selectCount(
new LambdaQueryWrapper<User>()
.ge(User::getLastLoginAt, Instant.now().minusSeconds(7L * 86400))));
return s;
}
private Map<String, Object> matchFunnelStats() {
Map<String, Object> s = new HashMap<>();
s.put("totalLikes", recommendationRecordMapper.selectCount(
new LambdaQueryWrapper<RecommendationRecord>().eq(RecommendationRecord::getAction, "liked")));
s.put("totalMatches", matchMapper.selectCount(null));
s.put("gettingToKnow", matchMapper.selectCount(
new LambdaQueryWrapper<Match>().eq(Match::getStatus, "getting_to_know")));
s.put("successPairs", successRequestMapper.selectCount(
new LambdaQueryWrapper<RelationshipSuccessRequest>().eq(RelationshipSuccessRequest::getStatus, "confirmed")));
return s;
}
private Map<String, Object> safetyStats() {
Map<String, Object> s = new HashMap<>();
s.put("pendingReports", reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING));
s.put("resolvedReports", reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED));
s.put("highRiskUsers", riskActionMapper.selectCount(
new LambdaQueryWrapper<RiskAction>()
.in(RiskAction::getActionType, Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
.eq(RiskAction::getStatus, "active")));
s.put("bannedUsers", riskActionMapper.selectCount(
new LambdaQueryWrapper<RiskAction>()
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
.eq(RiskAction::getStatus, "active")));
return s;
}
private Map<String, Object> activityFunnelStats() {
Map<String, Object> s = new HashMap<>();
s.put("totalActivities", 0);
s.put("totalRegistrations", 0);
return s;
}
private List<Map<String, Object>> buildDailyNewUsers(LocalDate since, LocalDate until) {
List<Map<String, Object>> trend = new ArrayList<>();
LocalDate current = since;
while (!current.isAfter(until)) {
Instant start = current.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = current.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
Map<String, Object> point = new HashMap<>();
point.put("date", current.toString());
point.put("value", countEntityByDateRange(start, end));
trend.add(point);
current = current.plusDays(1);
}
return trend;
}
// ==================== V1.1 公益项目统计 ====================
/**
* Banner 统计。
*
* <p><b>Phase 20 D6 修复</b>:修复前 {@code impressions} / {@code clicks} 被**硬编码为 0**。
* 0 会被读成"真的没人看到 / 没人点击",而实际是项目内根本不存在曝光与点击的埋点表或上报端点。
* 两者对运营决策的含义完全相反,因此改为显式标注不可用,绝不返回 0 冒充真实值。
*/
public Map<String, Object> getBannerStats() {
Map<String, Object> stats = new HashMap<>();
long totalBanners = homeBannerMapper.selectCount(null);
Instant now = Instant.now();
Instant finalNow = now;
long activeBanners = homeBannerMapper.selectCount(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<com.danyang.match.platform.banner.entity.HomeBanner>()
.eq(com.danyang.match.platform.banner.entity.HomeBanner::getStatus, "active")
.le(com.danyang.match.platform.banner.entity.HomeBanner::getStartAt, finalNow)
.ge(com.danyang.match.platform.banner.entity.HomeBanner::getEndAt, finalNow)
);
stats.put("totalBanners", totalBanners);
stats.put("activeBanners", activeBanners);
stats.put("impressions", 0);
stats.put("clicks", 0);
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("totalBanners", metrics.banners());
stats.put("activeBanners", metrics.activeBanners());
// 未埋点:明确置 null 并给出标记,避免被当成真实的 0。
stats.put("impressions", null);
stats.put("clicks", null);
stats.put("impressionsAvailable", false);
stats.put("clicksAvailable", false);
stats.put("unavailableReason", "未埋点:项目内不存在 Banner 曝光/点击的上报端与事件表,"
+ "故无法计算。修复前此处返回硬编码 0,会被误读为真实数据。");
return stats;
}
public Map<String, Object> getServiceStationStats() {
Map<String, Object> stats = new HashMap<>();
Map<String, Object> stats = new LinkedHashMap<>();
List<ServiceAppointment> appointments = serviceAppointmentMapper.selectList(
new LambdaQueryWrapper<ServiceAppointment>()
.isNotNull(ServiceAppointment::getServiceLocationId)
.ne(ServiceAppointment::getStatus, "cancelled")
);
new LambdaQueryWrapper<ServiceAppointment>()
.isNotNull(ServiceAppointment::getServiceLocationId)
.ne(ServiceAppointment::getStatus, "cancelled")
.isNull(ServiceAppointment::getDeletedAt));
Set<String> uniqueLocations = new HashSet<>();
for (ServiceAppointment a : appointments) {
if (a.getServiceLocationId() != null) uniqueLocations.add(a.getServiceLocationId());
@@ -351,140 +313,181 @@ public class AnalyticsService {
}
public Map<String, Object> getServiceAppointmentStats() {
Map<String, Object> stats = new HashMap<>();
List<ServiceAppointment> all = serviceAppointmentMapper.selectList(
new LambdaQueryWrapper<ServiceAppointment>().isNull(ServiceAppointment::getDeletedAt)
);
stats.put("total", all.size());
stats.put("submitted", all.stream().filter(a -> "submitted".equals(a.getStatus())).count());
stats.put("confirmed", all.stream().filter(a -> "confirmed".equals(a.getStatus())).count());
stats.put("completed", all.stream().filter(a -> "completed".equals(a.getStatus())).count());
stats.put("cancelled", all.stream().filter(a -> "cancelled".equals(a.getStatus())).count());
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("total", metrics.serviceAppointments());
stats.put("submitted", countAppointmentsByStatus("submitted"));
stats.put("confirmed", countAppointmentsByStatus("confirmed"));
stats.put("completed", countAppointmentsByStatus("completed"));
stats.put("cancelled", countAppointmentsByStatus("cancelled"));
return stats;
}
public Map<String, Object> getCounselingStats() {
Map<String, Object> stats = new HashMap<>();
List<CounselingService> all = counselingServiceMapper.selectList(
new LambdaQueryWrapper<CounselingService>().isNull(CounselingService::getDeletedAt)
);
stats.put("total", all.size());
stats.put("pending", all.stream().filter(c -> "pending".equals(c.getStatus())).count());
stats.put("assigned", all.stream().filter(c -> "assigned".equals(c.getStatus())).count());
stats.put("completed", all.stream().filter(c -> "completed".equals(c.getStatus())).count());
stats.put("cancelled", all.stream().filter(c -> "cancelled".equals(c.getStatus())).count());
stats.put("premaritalGuidance", all.stream().filter(c -> "premarital_guidance".equals(c.getServiceType())).count());
Map<String, Object> stats = new LinkedHashMap<>();
long total = metrics.counselingServices();
stats.put("total", total);
stats.put("pending", countCounselingByStatus("pending"));
stats.put("assigned", countCounselingByStatus("assigned"));
stats.put("completed", countCounselingByStatus("completed"));
stats.put("cancelled", countCounselingByStatus("cancelled"));
stats.put("premaritalGuidance", countCounselingByServiceType("premarital_guidance"));
return stats;
}
public Map<String, Object> getPremaritalStats() {
Map<String, Object> stats = new HashMap<>();
List<CounselingService> all = counselingServiceMapper.selectList(
new LambdaQueryWrapper<CounselingService>()
.eq(CounselingService::getServiceType, "premarital_guidance")
.isNull(CounselingService::getDeletedAt)
);
stats.put("totalRequests", all.size());
stats.put("completed", all.stream().filter(c -> "completed".equals(c.getStatus())).count());
stats.put("pending", all.stream().filter(c -> "pending".equals(c.getStatus())).count());
stats.put("withCompanion", all.stream().filter(c -> c.getCompanionUserId() != null).count());
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("totalRequests", countCounselingByServiceType("premarital_guidance"));
stats.put("completed", counselingServiceMapper.selectCount(
new LambdaQueryWrapper<CounselingServiceRecord>()
.eq(CounselingServiceRecord::getServiceType, "premarital_guidance")
.eq(CounselingServiceRecord::getStatus, "completed")
.isNull(CounselingServiceRecord::getDeletedAt)));
stats.put("pending", counselingServiceMapper.selectCount(
new LambdaQueryWrapper<CounselingServiceRecord>()
.eq(CounselingServiceRecord::getServiceType, "premarital_guidance")
.eq(CounselingServiceRecord::getStatus, "pending")
.isNull(CounselingServiceRecord::getDeletedAt)));
stats.put("withCompanion", counselingServiceMapper.selectCount(
new LambdaQueryWrapper<CounselingServiceRecord>()
.eq(CounselingServiceRecord::getServiceType, "premarital_guidance")
.isNotNull(CounselingServiceRecord::getCompanionUserId)
.isNull(CounselingServiceRecord::getDeletedAt)));
return stats;
}
public Map<String, Object> getContentStats() {
Map<String, Object> stats = new HashMap<>();
Map<String, Object> stats = new LinkedHashMap<>();
List<ContentArticle> articles = contentArticleMapper.selectList(
new LambdaQueryWrapper<ContentArticle>()
.eq(ContentArticle::getStatus, "published")
.isNull(ContentArticle::getDeletedAt)
);
long totalViews = articles.stream()
.mapToInt(a -> a.getViewCount() != null ? a.getViewCount() : 0).sum();
new LambdaQueryWrapper<ContentArticle>()
.eq(ContentArticle::getStatus, "published")
.isNull(ContentArticle::getDeletedAt));
stats.put("totalPublishedArticles", articles.size());
stats.put("totalViews", totalViews);
stats.put("totalViews", metrics.contentViews());
articles.sort((a, b) -> Integer.compare(
b.getViewCount() != null ? b.getViewCount() : 0,
a.getViewCount() != null ? a.getViewCount() : 0));
b.getViewCount() != null ? b.getViewCount() : 0,
a.getViewCount() != null ? a.getViewCount() : 0));
List<Map<String, Object>> top = articles.stream().limit(5)
.map(a -> { Map<String, Object> m = new HashMap<>(); m.put("id", a.getId()); m.put("title", a.getTitle()); m.put("views", a.getViewCount()); return m; })
.toList();
.map(a -> {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", a.getId());
m.put("title", a.getTitle());
m.put("views", a.getViewCount());
return m;
})
.toList();
stats.put("topArticles", top);
return stats;
}
public Map<String, Object> getLocalEligibilityStats() {
Map<String, Object> stats = new HashMap<>();
List<LocalEligibility> all = localEligibilityMapper.selectList(new LambdaQueryWrapper<>());
stats.put("total", all.size());
stats.put("verified", all.stream().filter(l -> "verified".equals(l.getVerificationStatus())).count());
stats.put("pending", all.stream().filter(l -> "pending_review".equals(l.getVerificationStatus()) || "pending".equals(l.getVerificationStatus())).count());
stats.put("rejected", all.stream().filter(l -> "rejected".equals(l.getVerificationStatus())).count());
Map<String, Object> stats = new LinkedHashMap<>();
long total = localEligibilityMapper.selectCount(
new LambdaQueryWrapper<LocalEligibility>().isNull(LocalEligibility::getDeletedAt));
stats.put("total", total);
stats.put("verified", localEligibilityMapper.selectCount(
new LambdaQueryWrapper<LocalEligibility>()
.eq(LocalEligibility::getVerificationStatus, "verified")
.isNull(LocalEligibility::getDeletedAt)));
stats.put("pending", localEligibilityMapper.selectCount(
new LambdaQueryWrapper<LocalEligibility>()
.in(LocalEligibility::getVerificationStatus, "pending_review", "pending")
.isNull(LocalEligibility::getDeletedAt)));
stats.put("rejected", localEligibilityMapper.selectCount(
new LambdaQueryWrapper<LocalEligibility>()
.eq(LocalEligibility::getVerificationStatus, "rejected")
.isNull(LocalEligibility::getDeletedAt)));
return stats;
}
public Map<String, Object> getServiceProgramStats() {
Map<String, Object> stats = new HashMap<>();
List<ServiceProgram> programs = serviceProgramMapper.selectList(
new LambdaQueryWrapper<ServiceProgram>().isNull(ServiceProgram::getDeletedAt));
List<ServiceProgramMember> members = serviceProgramMemberMapper.selectList(null);
stats.put("totalPrograms", programs.size());
stats.put("activePrograms", programs.stream().filter(p -> "active".equals(p.getStatus())).count());
stats.put("totalMembers", members.stream().filter(m -> "approved".equals(m.getStatus())).count());
stats.put("pendingApplications", members.stream().filter(m -> "pending".equals(m.getStatus())).count());
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("totalPrograms", metrics.servicePrograms());
stats.put("activePrograms", metrics.activeServicePrograms());
stats.put("totalMembers", metrics.approvedProgramMembers());
stats.put("pendingApplications", serviceProgramMemberMapper.selectCount(
new LambdaQueryWrapper<ServiceProgramMember>()
.eq(ServiceProgramMember::getStatus, "pending")
.isNull(ServiceProgramMember::getDeletedAt)));
return stats;
}
public Map<String, Object> getRegistrationSourceStats() {
Map<String, Object> stats = new HashMap<>();
List<ActivityRegistration> regs = activityRegistrationMapper.selectList(
new LambdaQueryWrapper<ActivityRegistration>().ne(ActivityRegistration::getStatus, "cancelled"));
Map<String, Long> bySource = regs.stream()
.collect(java.util.stream.Collectors.groupingBy(
r -> r.getSource() != null ? r.getSource() : "unknown",
java.util.stream.Collectors.counting()));
stats.put("totalRegistrations", regs.size());
Map<String, Object> stats = new LinkedHashMap<>();
List<String> sources = List.of("user_self", "matchmaker_referral", "activity_followup",
"service_station", "operator_assisted");
Map<String, Long> bySource = new LinkedHashMap<>();
long total = 0;
for (String source : sources) {
long count = activityRegistrationMapper.countRegistrationsBySource(source);
bySource.put(source, count);
total += count;
}
stats.put("totalRegistrations", total);
stats.put("bySource", bySource);
return stats;
}
/**
* 公益服务用户数与服务人次。
*
* <p><b>Phase 20 计划明确要求「分离服务用户数与服务人次」</b>:
* 前者是去重后的**人数**(覆盖面),后者是不去重的**人次**(承载量),
* 两者比值即人均服务次数。修复前只有去重人数、没有服务人次。
*/
public Map<String, Object> getUniqueServiceUsersStats() {
Map<String, Object> stats = new HashMap<>();
Set<String> cu = counselingServiceMapper.selectList(new LambdaQueryWrapper<CounselingService>().isNull(CounselingService::getDeletedAt))
.stream().map(CounselingService::getUserId).collect(java.util.stream.Collectors.toSet());
Set<String> au = serviceAppointmentMapper.selectList(new LambdaQueryWrapper<ServiceAppointment>().isNull(ServiceAppointment::getDeletedAt))
.stream().map(ServiceAppointment::getUserId).collect(java.util.stream.Collectors.toSet());
Set<String> pu = serviceProgramMemberMapper.selectList(
new LambdaQueryWrapper<ServiceProgramMember>().eq(ServiceProgramMember::getStatus, "approved"))
.stream().map(ServiceProgramMember::getUserId).collect(java.util.stream.Collectors.toSet());
Set<String> eu = localEligibilityMapper.selectList(new LambdaQueryWrapper<>())
.stream().map(LocalEligibility::getUserId).collect(java.util.stream.Collectors.toSet());
stats.put("counselingUniqueUsers", cu.size());
stats.put("appointmentUniqueUsers", au.size());
stats.put("programUniqueUsers", pu.size());
stats.put("eligibilityUniqueUsers", eu.size());
Set<String> all = new HashSet<>(); all.addAll(cu); all.addAll(au); all.addAll(pu); all.addAll(eu);
stats.put("totalUniqueServiceUsers", all.size());
Map<String, Object> stats = new LinkedHashMap<>();
Set<String> counselingUsers = new HashSet<>();
counselingServiceMapper.selectList(
new LambdaQueryWrapper<CounselingServiceRecord>()
.isNull(CounselingServiceRecord::getDeletedAt))
.forEach(c -> counselingUsers.add(c.getUserId()));
Set<String> appointmentUsers = new HashSet<>();
serviceAppointmentMapper.selectList(
new LambdaQueryWrapper<ServiceAppointment>()
.isNull(ServiceAppointment::getDeletedAt))
.forEach(a -> appointmentUsers.add(a.getUserId()));
Set<String> programUsers = new HashSet<>();
serviceProgramMemberMapper.selectList(
new LambdaQueryWrapper<ServiceProgramMember>()
.eq(ServiceProgramMember::getStatus, "approved")
.isNull(ServiceProgramMember::getDeletedAt))
.forEach(m -> programUsers.add(m.getUserId()));
Set<String> eligibilityUsers = new HashSet<>();
localEligibilityMapper.selectList(
new LambdaQueryWrapper<LocalEligibility>()
.isNull(LocalEligibility::getDeletedAt))
.forEach(e -> eligibilityUsers.add(e.getUserId()));
stats.put("counselingUniqueUsers", sizeOf(counselingUsers));
stats.put("appointmentUniqueUsers", sizeOf(appointmentUsers));
stats.put("programUniqueUsers", sizeOf(programUsers));
stats.put("eligibilityUniqueUsers", sizeOf(eligibilityUsers));
// 服务用户数(跨域去重)
stats.put("totalUniqueServiceUsers", metrics.uniqueServiceUsers());
// 服务人次(不去重)—— 与上面的"用户数"是两个不同指标
stats.put("totalServiceVisits", metrics.serviceVisits());
return stats;
}
public Map<String, Object> getEffectiveConnectionStats() {
Map<String, Object> stats = new HashMap<>();
long activeMatches = matchMapper.selectCount(
new LambdaQueryWrapper<Match>().eq(Match::getStatus, "active"));
long matchesWithChat = conversationMapper.selectCount(
new LambdaQueryWrapper<Conversation>().in(Conversation::getStatus, "active", "muted"));
long successRequests = successRequestMapper.selectCount(
new LambdaQueryWrapper<RelationshipSuccessRequest>().eq(RelationshipSuccessRequest::getStatus, "confirmed"));
Map<String, Object> stats = new LinkedHashMap<>();
long activeMatches = matchMapper.countByStatus("active");
long matchesWithChat = metrics.chatsStarted();
long successRequests = metrics.successfulMatches();
stats.put("activeMatches", activeMatches);
stats.put("matchesWithChat", matchesWithChat);
stats.put("successConnections", successRequests);
double rate = activeMatches > 0 ? (double) matchesWithChat / activeMatches : 0;
stats.put("effectiveConnectionRate", Math.round(rate * 1000.0) / 10.0);
// 口径未确认,必须显式标注,禁止对外宣传。
stats.put("effectiveMatchesProvisional", true);
stats.put("provisionalNote", "「有效交友匹配」口径尚未经业务确认,仅内部参考,禁止对外宣传。");
return stats;
}
public Map<String, Object> getV11ServiceDashboard() {
Map<String, Object> d = new HashMap<>();
Map<String, Object> d = new LinkedHashMap<>();
d.put("banners", getBannerStats());
d.put("serviceStations", getServiceStationStats());
d.put("serviceAppointments", getServiceAppointmentStats());
@@ -496,50 +499,100 @@ public class AnalyticsService {
d.put("registrationSources", getRegistrationSourceStats());
d.put("uniqueServiceUsers", getUniqueServiceUsersStats());
d.put("effectiveConnections", getEffectiveConnectionStats());
// 活跃活动漏斗(含互选/成功分离)
d.put("activityFunnel", getActivityFunnel());
return d;
}
// ==================== 私有辅助 ====================
private List<Map<String, Object>> buildDailyNewMatches(LocalDate since, LocalDate until) {
private static int sizeOf(Set<String> set) {
set.remove(null);
return set.size();
}
/** 统一的比率格式化,保证同一比率在任何视图里呈现一致。 */
private static String rate(long numerator, long denominator) {
if (denominator <= 0) return "0%";
return String.format("%.1f%%", 100.0 * numerator / denominator);
}
private long countAppointmentsByStatus(String status) {
return serviceAppointmentMapper.selectCount(
new LambdaQueryWrapper<ServiceAppointment>()
.eq(ServiceAppointment::getStatus, status)
.isNull(ServiceAppointment::getDeletedAt));
}
private long countCounselingByStatus(String status) {
return counselingServiceMapper.selectCount(
new LambdaQueryWrapper<CounselingServiceRecord>()
.eq(CounselingServiceRecord::getStatus, status)
.isNull(CounselingServiceRecord::getDeletedAt));
}
private long countCounselingByServiceType(String serviceType) {
return counselingServiceMapper.selectCount(
new LambdaQueryWrapper<CounselingServiceRecord>()
.eq(CounselingServiceRecord::getServiceType, serviceType)
.isNull(CounselingServiceRecord::getDeletedAt));
}
private List<Map<String, Object>> buildDailyNewUsers(LocalDate since, LocalDate until) {
return buildDailyTrend(since, until, this::countEntityByDateRange);
}
private List<Map<String, Object>> buildDailyNewMatches(LocalDate since, LocalDate until) {
return buildDailyTrend(since, until, this::countMatchByDateRange);
}
private List<Map<String, Object>> buildDailyTrend(LocalDate since, LocalDate until,
java.util.function.BiFunction<Instant, Instant, Long> counter) {
List<Map<String, Object>> trend = new ArrayList<>();
LocalDate current = since;
while (!current.isAfter(until)) {
Instant start = current.atStartOfDay(ZoneId.systemDefault()).toInstant();
Instant end = current.plusDays(1).atStartOfDay(ZoneId.systemDefault()).toInstant();
Map<String, Object> point = new HashMap<>();
Map<String, Object> point = new LinkedHashMap<>();
point.put("date", current.toString());
point.put("value", countMatchByDateRange(start, end));
point.put("value", counter.apply(start, end));
trend.add(point);
current = current.plusDays(1);
}
return trend;
}
// 以下四个"按日区间"计数同样必须显式过滤软删,
// 否则每日汇总里的"新增用户/新增 Match"会把已删除记录继续算进当天。
private long countEntityByDateRange(Instant start, Instant end) {
return userMapper.selectCount(
new LambdaQueryWrapper<User>()
.ge(User::getCreatedAt, start)
.le(User::getCreatedAt, end));
.le(User::getCreatedAt, end)
.isNull(User::getDeletedAt));
}
private long countMatchByDateRange(Instant start, Instant end) {
return matchMapper.selectCount(
new LambdaQueryWrapper<Match>()
.ge(Match::getMatchedAt, start)
.le(Match::getMatchedAt, end));
.le(Match::getMatchedAt, end)
.isNull(Match::getDeletedAt));
}
private long countConversationByDateRange(Instant start, Instant end) {
return conversationMapper.selectCount(
new LambdaQueryWrapper<Conversation>()
.ge(Conversation::getCreatedAt, start)
.le(Conversation::getCreatedAt, end));
.le(Conversation::getCreatedAt, end)
.isNull(Conversation::getDeletedAt));
}
private long countReportByDateRange(Instant start, Instant end) {
return reportMapper.selectCount(
new LambdaQueryWrapper<Report>()
.ge(Report::getCreatedAt, start)
.le(Report::getCreatedAt, end));
.le(Report::getCreatedAt, end)
.isNull(Report::getDeletedAt));
}
}
@@ -0,0 +1,380 @@
package com.danyang.match.platform.analytics.metric;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.danyang.match.platform.activity.entity.Activity;
import com.danyang.match.platform.activity.mapper.ActivityMapper;
import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
import com.danyang.match.platform.activity_mutual.mapper.ActivityMutualConsentMapper;
import com.danyang.match.platform.banner.entity.HomeBanner;
import com.danyang.match.platform.banner.mapper.HomeBannerMapper;
import com.danyang.match.platform.chat.mapper.ConversationMapper;
import com.danyang.match.platform.chat.mapper.MessageMapper;
import com.danyang.match.platform.content.entity.ContentArticle;
import com.danyang.match.platform.content.mapper.ContentArticleMapper;
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
import com.danyang.match.platform.counseling.mapper.CounselingServiceMapper;
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
import com.danyang.match.platform.match.mapper.MatchMapper;
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
import com.danyang.match.platform.profile.entity.UserProfile;
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
import com.danyang.match.platform.risk.entity.RiskAction;
import com.danyang.match.platform.risk.mapper.ReportMapper;
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
import com.danyang.match.platform.service_appointment.entity.ServiceAppointment;
import com.danyang.match.platform.service_appointment.mapper.ServiceAppointmentMapper;
import com.danyang.match.platform.service_program.entity.ServiceProgram;
import com.danyang.match.platform.service_program.entity.ServiceProgramMember;
import com.danyang.match.platform.service_program.mapper.ServiceProgramMapper;
import com.danyang.match.platform.service_program.mapper.ServiceProgramMemberMapper;
import com.danyang.match.platform.shared.constant.Constants;
import com.danyang.match.platform.user.entity.User;
import com.danyang.match.platform.user.mapper.UserMapper;
import com.danyang.match.platform.verification.mapper.UserIdentityMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.HashSet;
import java.util.Set;
/**
* 统一口径计算层 —— 所有统计数字的**唯一**出处。
*
* <p>为什么要单独抽一层:Phase 20 的 Gate 是「Dashboard 和专项页面相同指标必须一致」。
* 修复前 {@code AnalyticsService} 里同一个指标有 2~3 份实现
* ({@code userBasicStats} / {@code getUserFunnel} / {@code getV11ServiceDashboard}),
* 名字还各不相同({@code verifiedUsers} vs {@code verifiedCount}),
* 只要有一处漏改就永久不一致,且没有任何测试能发现。
*
* <p>本类中每个方法都对应 {@link MetricCatalog} 里的一个已登记指标,
* 方法上标注的指标编码即其口径来源。
*
* <h2>为什么每个查询都显式写 {@code deleted_at IS NULL}</h2>
*
* <p><b>Phase 20 D12</b>:{@code BaseEntity.deletedAt} **没有** {@code @TableLogic} 注解,
* 项目也没有配置 MyBatis-Plus 的全局逻辑删除字段。这意味着
* {@code selectCount(null)} 生成的是 {@code SELECT COUNT(*) FROM t} ——
* **软删除的数据会被完整计入统计**。
*
* <p>实测影响:用户注销/被删、Banner 下架删除、活动软删之后,
* 所有看板数字依然把它们算在内。这类偏差不会有任何报错,
* 只会让"累计注册用户数"永远只增不减。因此本层所有查询统一显式过滤。
*
* <p><b>关于性能</b>:少量需要"跨域去重"的指标(如服务用户数)仍采用
* 「查出各域 user_id 再内存去重」的写法。这在单区县体量下无问题;
* 若数据量增长到十万级,应改为数据库侧 {@code UNION} 去重聚合。
*/
@Component
@RequiredArgsConstructor
public class AnalyticsMetricsCalculator {
private final UserMapper userMapper;
private final UserProfileMapper userProfileMapper;
private final UserIdentityMapper userIdentityMapper;
private final MessageMapper messageMapper;
private final ConversationMapper conversationMapper;
private final MatchMapper matchMapper;
private final RecommendationRecordMapper recommendationRecordMapper;
private final ActivityMapper activityMapper;
private final ActivityRegistrationMapper activityRegistrationMapper;
private final ActivityMutualConsentMapper activityMutualConsentMapper;
private final ServiceAppointmentMapper serviceAppointmentMapper;
private final CounselingServiceMapper counselingServiceMapper;
private final ServiceProgramMapper serviceProgramMapper;
private final ServiceProgramMemberMapper serviceProgramMemberMapper;
private final LocalEligibilityMapper localEligibilityMapper;
private final ContentArticleMapper contentArticleMapper;
private final HomeBannerMapper homeBannerMapper;
private final ReportMapper reportMapper;
private final RiskActionMapper riskActionMapper;
// ==================== 用户域 ====================
/** {@code user.registered.total} —— 累计注册用户数(不含软删)。 */
public long registeredUsers() {
return userMapper.selectCount(new LambdaQueryWrapper<User>().isNull(User::getDeletedAt));
}
/**
* {@code user.identity.verified} —— 实名认证通过用户数。
*
* <p><b>Phase 20 D1 修复点</b>:此前该口径被写成
* {@code user_profiles.profile_status != 'rejected'}("资料没被退回"),
* 把 in_progress(资料还在填)、need_edit(被退回待改)等状态全都算成了"已实名",
* 与 Gate「注册10 / 实名7 / 审核通过4」中的实名口径完全不符。
*/
public long realNameVerifiedUsers() {
return userIdentityMapper.countVerifiedUsers();
}
/** {@code user.profile.approved} —— 资料审核通过人数(不含软删)。 */
public long profileApprovedUsers() {
return userProfileMapper.selectCount(
new LambdaQueryWrapper<UserProfile>()
.eq(UserProfile::getProfileStatus, "approved")
.isNull(UserProfile::getDeletedAt));
}
/** {@code user.active.7d} —— 近 7 日活跃用户数(不含软删)。 */
public long activeUsers7d() {
return userMapper.selectCount(
new LambdaQueryWrapper<User>()
.ge(User::getLastLoginAt, Instant.now().minusSeconds(7L * 86400))
.isNull(User::getDeletedAt));
}
// ==================== 推荐 / 匹配域 ====================
/** {@code like.total} —— 用户 Like 总数(单向行为)。 */
public long likes() {
return recommendationRecordMapper.countByAction("liked");
}
/** {@code recommendation.shown.total} —— 已展示推荐总数。 */
public long recommendationsShown() {
return recommendationRecordMapper.countShown();
}
/**
* {@code recommendation.shown.matchmaker} —— 红娘发出的推荐数。
*
* <p>按 {@code source='matchmaker'} 过滤,避免把用户端自动推荐(daily / discover)
* 算进红娘业绩。
*/
public long matchmakerRecommendations() {
return recommendationRecordMapper.countShownBySource("matchmaker");
}
/** {@code match.total} —— Match 总数(全来源)。 */
public long matches() {
return matchMapper.countAllMatches();
}
/** 按来源统计 Match 数({@code user_like} / {@code activity_mutual} / {@code matchmaker})。 */
public long matchesBySource(String source) {
return matchMapper.countBySource(source);
}
/** {@code match.successful} —— 成功牵手数。 */
public long successfulMatches() {
return matchMapper.countByStatus("successful");
}
/** {@code match.effective}(PROVISIONAL)—— 已建 Match 且有过真人消息的配对数。 */
public long effectiveMatches() {
// PROVISIONAL:口径未确认(「有效」是否要求见面/持续 N 天由业务方裁决)。
// 当前取「真人会话数」与「Match 数」的较小值作为保守估计,
// 绝不可当作正式成功率对外引用。
return Math.min(messageMapper.countConversationsWithRealMessages(), matchMapper.countAllMatches());
}
/** {@code chat.started} —— 开启聊天会话数(排除 system / risk_notice)。 */
public long chatsStarted() {
return messageMapper.countConversationsWithRealMessages();
}
/** {@code chat.conversation.total} —— 会话总数(不含软删)。 */
public long conversations() {
return conversationMapper.selectCount(
new LambdaQueryWrapper<com.danyang.match.platform.chat.entity.Conversation>()
.isNull(com.danyang.match.platform.chat.entity.Conversation::getDeletedAt));
}
// ==================== 活动域 ====================
/** {@code activity.total} —— 活动总数(不含软删)。 */
public long activities() {
return activityMapper.selectCount(new LambdaQueryWrapper<Activity>().isNull(Activity::getDeletedAt));
}
/** {@code activity.registration.total} —— 活动报名人次(不去重)。 */
public long activityRegistrations() {
return activityRegistrationMapper.countAllRegistrations();
}
/** {@code activity.registration.approved} —— 报名通过人次。 */
public long approvedActivityRegistrations() {
return activityRegistrationMapper.countRegistrationsByStatus("approved");
}
/** {@code activity.registration.checked_in} —— 实际签到人次。 */
public long checkedInRegistrations() {
return activityRegistrationMapper.countCheckedIn();
}
/** {@code activity.mutual.both_confirmed} —— 活动互选成立对数(严格双确认)。 */
public long mutualBothConfirmed() {
return activityMutualConsentMapper.countBothConfirmed();
}
/** {@code activity.mutual.match} —— 互选后建立 Match 数。 */
public long mutualMatches() {
return matchMapper.countBySource("activity_mutual");
}
/**
* {@code activity.mutual.successful} —— 互选后成功牵手数。
*
* <p>与 {@link #mutualBothConfirmed()} **严格区分**:
* 10 对互选成立 ≠ 10 对成功,中间还有聊天、相处、确认关系。
*/
public long mutualSuccessful() {
return matchMapper.countBySourceAndStatus("activity_mutual", "successful");
}
// ==================== 公益服务域 ====================
/** {@code service.appointment.total} —— 服务预约人次(不含已取消、不含软删)。 */
public long serviceAppointments() {
return serviceAppointmentMapper.selectCount(
new LambdaQueryWrapper<ServiceAppointment>()
.ne(ServiceAppointment::getStatus, "cancelled")
.isNull(ServiceAppointment::getDeletedAt));
}
/** {@code counseling.total} —— 婚恋咨询受理量(不含软删)。 */
public long counselingServices() {
return counselingServiceMapper.selectCount(
new LambdaQueryWrapper<CounselingServiceRecord>()
.isNull(CounselingServiceRecord::getDeletedAt));
}
/** {@code service.program.member.approved} —— 专项项目在册成员数(不含软删)。 */
public long approvedProgramMembers() {
return serviceProgramMemberMapper.selectCount(
new LambdaQueryWrapper<ServiceProgramMember>()
.eq(ServiceProgramMember::getStatus, "approved")
.isNull(ServiceProgramMember::getDeletedAt));
}
/**
* {@code service.visit.total} —— 公益服务人次(**不去重**)。
*
* <p>与 {@link #uniqueServiceUsers()} 是**两个不同的指标**:
* 本指标衡量服务承载量,同一用户来 3 次算 3 人次。
*/
public long serviceVisits() {
return serviceAppointments()
+ counselingServices()
+ approvedProgramMembers()
+ localEligibilityCount();
}
/**
* {@code service.user.unique} —— 服务用户数(**跨域去重**)。
*
* <p>同一用户既预约又咨询又参加项目,只算 1 人。与 {@link #serviceVisits()}
* 的比值即人均服务次数,因此两者绝不可混用或互相替代。
*/
public long uniqueServiceUsers() {
Set<String> users = new HashSet<>();
serviceAppointmentMapper.selectList(
new LambdaQueryWrapper<ServiceAppointment>()
.isNull(ServiceAppointment::getDeletedAt))
.forEach(a -> users.add(a.getUserId()));
counselingServiceMapper.selectList(
new LambdaQueryWrapper<CounselingServiceRecord>()
.isNull(CounselingServiceRecord::getDeletedAt))
.forEach(c -> users.add(c.getUserId()));
serviceProgramMemberMapper.selectList(
new LambdaQueryWrapper<ServiceProgramMember>()
.eq(ServiceProgramMember::getStatus, "approved")
.isNull(ServiceProgramMember::getDeletedAt))
.forEach(m -> users.add(m.getUserId()));
localEligibilityMapper.selectList(
new LambdaQueryWrapper<LocalEligibility>().isNull(LocalEligibility::getDeletedAt))
.forEach(e -> users.add(e.getUserId()));
users.remove(null);
return users.size();
}
private long localEligibilityCount() {
return localEligibilityMapper.selectCount(
new LambdaQueryWrapper<LocalEligibility>().isNull(LocalEligibility::getDeletedAt));
}
/** 专项项目总数(不含软删),供服务仪表盘使用。 */
public long servicePrograms() {
return serviceProgramMapper.selectCount(
new LambdaQueryWrapper<ServiceProgram>().isNull(ServiceProgram::getDeletedAt));
}
/** 状态为 active 的专项项目数(不含软删)。 */
public long activeServicePrograms() {
return serviceProgramMapper.selectCount(
new LambdaQueryWrapper<ServiceProgram>()
.eq(ServiceProgram::getStatus, "active")
.isNull(ServiceProgram::getDeletedAt));
}
/** {@code content.published.total} —— 已发布内容数(不含软删)。 */
public long publishedArticles() {
return contentArticleMapper.selectCount(
new LambdaQueryWrapper<ContentArticle>()
.eq(ContentArticle::getStatus, "published")
.isNull(ContentArticle::getDeletedAt));
}
/** {@code content.views.total} —— 内容累计阅读量。 */
public long contentViews() {
return contentArticleMapper.selectList(
new LambdaQueryWrapper<ContentArticle>()
.eq(ContentArticle::getStatus, "published")
.isNull(ContentArticle::getDeletedAt))
.stream()
.mapToLong(a -> a.getViewCount() != null ? a.getViewCount() : 0)
.sum();
}
// ==================== Banner 域 ====================
/** {@code banner.total} —— Banner 总数(不含软删)。 */
public long banners() {
return homeBannerMapper.selectCount(
new LambdaQueryWrapper<HomeBanner>().isNull(HomeBanner::getDeletedAt));
}
/** {@code banner.active} —— 生效中的 Banner 数(不含软删)。 */
public long activeBanners() {
Instant now = Instant.now();
return homeBannerMapper.selectCount(
new LambdaQueryWrapper<HomeBanner>()
.eq(HomeBanner::getStatus, "active")
.isNull(HomeBanner::getDeletedAt)
.le(HomeBanner::getStartAt, now)
.ge(HomeBanner::getEndAt, now));
}
// ==================== 安全域 ====================
/** {@code risk.report.pending} —— 待处理举报数(自定义 SQL 已含软删过滤)。 */
public long pendingReports() {
return reportMapper.countByStatus(Constants.REPORT_STATUS_PENDING);
}
/** {@code risk.report.resolved} —— 已处理举报数(自定义 SQL 已含软删过滤)。 */
public long resolvedReports() {
return reportMapper.countByStatus(Constants.REPORT_STATUS_RESOLVED);
}
/** {@code risk.user.high_risk} —— 高风险用户数(生效中的封禁或临时停用,不含软删)。 */
public long highRiskUsers() {
return riskActionMapper.selectCount(
new LambdaQueryWrapper<RiskAction>()
.in(RiskAction::getActionType,
Constants.RISK_ACTION_BAN, Constants.RISK_ACTION_TEMPORARY_SUSPEND)
.eq(RiskAction::getStatus, "active")
.isNull(RiskAction::getDeletedAt));
}
/** 仅封禁用户数(高风险用户的子集)。 */
public long bannedUsers() {
return riskActionMapper.selectCount(
new LambdaQueryWrapper<RiskAction>()
.eq(RiskAction::getActionType, Constants.RISK_ACTION_BAN)
.eq(RiskAction::getStatus, "active")
.isNull(RiskAction::getDeletedAt));
}
}
@@ -0,0 +1,336 @@
package com.danyang.match.platform.analytics.metric;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 全部统计指标的注册表 —— Phase 20 的**单一事实来源**。
*
* <p>修复前的状态:同一个概念在 {@code AnalyticsService} 里有 2~3 份独立实现
* ({@code userBasicStats()} / {@code getUserFunnel()} / {@code getV11ServiceDashboard()}
* 各算一遍),且**同名不同义**(Dashboard 叫 {@code totalLikes},漏斗叫 {@code totalLiked};
* Dashboard 叫 {@code verifiedUsers},漏斗叫 {@code verifiedCount})。
* Phase 20 的 Gate 明确要求「Dashboard 和专项页面相同指标必须一致」,
* 靠"记得同步改两处"是守不住的,必须有唯一来源。
*
* <p>本类同时是 {@code docs/ANALYTICS_METRICS.md} 的生成依据,
* 由 {@code MetricCatalogGuardTest} 校验「文档覆盖全部指标」与「口径不自相矛盾」。
*/
public final class MetricCatalog {
/** 本项目统计口径统一时区(丹阳本地时间;跨时区部署会改变"某天"的边界)。 */
public static final String TIMEZONE = "Asia/Shanghai";
/** 当前口径版本;任何口径变更都必须递增,否则历史数据不可比。 */
public static final String VERSION = "v1";
private MetricCatalog() {
}
private static final List<MetricDefinition> ALL = List.of(
// ==================== 用户域 ====================
new MetricDefinition(
"user.registered.total", "累计注册用户数",
"所有已注册用户账号数。含未完善资料、未实名、被封禁的账号(只要账号行存在即计入)。",
"COUNT(users.id)", "—", "users.id", "users.created_at", TIMEZONE, VERSION, "平台运营",
MetricStatus.OFFICIAL, "users"),
new MetricDefinition(
"user.identity.verified", "实名认证通过用户数",
"实名认证状态为 verified 的用户数。不含 verifying(认证中)、failed(失败)、"
+ "manual_review(人工复核)、not_verified。**不等于**资料审核通过数。",
"COUNT(DISTINCT user_identities.user_id) WHERE verification_status='verified'",
"—", "user_identities.user_id", "user_identities.verified_at", TIMEZONE, VERSION, "平台运营",
MetricStatus.OFFICIAL, "user_identities"),
new MetricDefinition(
"user.profile.approved", "资料审核通过人数",
"资料审核状态为 approved 的用户数。仅表示资料通过审核,**不表示已实名**。",
"COUNT(DISTINCT user_profiles.user_id) WHERE profile_status='approved'",
"—", "user_profiles.user_id", "user_profiles.updated_at", TIMEZONE, VERSION, "审核员",
MetricStatus.OFFICIAL, "user_profiles"),
new MetricDefinition(
"user.active.7d", "近 7 日活跃用户数",
"最近 7 天内登录过的用户数。",
"COUNT(users.id) WHERE last_login_at >= NOW() - 7d", "—", "users.id",
"users.last_login_at", TIMEZONE, VERSION, "平台运营",
MetricStatus.OFFICIAL, "users"),
// ==================== 推荐 / 匹配域 ====================
new MetricDefinition(
"recommendation.shown.total", "已展示推荐总数",
"系统向用户展示过的推荐记录数(含全部来源 daily/discover/matchmaker/activity)。",
"COUNT(recommendation_records.id) WHERE shown_at IS NOT NULL", "—",
"recommendation_records.id", "recommendation_records.shown_at", TIMEZONE, VERSION, "平台运营",
MetricStatus.OFFICIAL, "recommendation_records"),
new MetricDefinition(
"recommendation.shown.matchmaker", "红娘发出的推荐数",
"来源为 matchmaker 的推荐记录数 = 红娘实际推给用户的候选数。"
+ "**不含**红娘在候选池里浏览看过但未发出的候选,也不含用户端自动推荐。",
"COUNT(recommendation_records.id) WHERE source='matchmaker' AND shown_at IS NOT NULL", "—",
"recommendation_records.id", "recommendation_records.shown_at", TIMEZONE, VERSION, "红娘主管",
MetricStatus.OFFICIAL, "recommendation_records"),
new MetricDefinition(
"like.total", "用户 Like 总数",
"action='liked' 的推荐记录数(单向行为,A 喜欢 B 记 1 条)。"
+ "两条 Like(A→B 与 B→A)才可能形成 1 个 Match,因此 Like 数约为 Mutual Match 数的 2 倍量级。",
"COUNT(recommendation_records.id) WHERE action='liked'", "—",
"recommendation_records.id", "recommendation_records.action_at", TIMEZONE, VERSION, "平台运营",
MetricStatus.OFFICIAL, "recommendation_records"),
new MetricDefinition(
"match.total", "Match 总数",
"全部来源的 Match 记录数(去重键为 matches.id,同一对用户只有一行,重新牵手递增 episode 而不新增行)。",
"COUNT(matches.id)", "—", "matches.id", "matches.matched_at", TIMEZONE, VERSION, "平台运营",
MetricStatus.OFFICIAL, "matches"),
new MetricDefinition(
"match.source.user_like", "用户互相 Like 形成的 Match 数",
"source='user_like' 的 Match 数。与 Match 总数分开统计,"
+ "避免把红娘促成、活动互选的量算进「用户自主匹配」的成果。",
"COUNT(matches.id) WHERE source='user_like'", "—", "matches.id", "matches.matched_at",
TIMEZONE, VERSION, "平台运营", MetricStatus.OFFICIAL, "matches"),
new MetricDefinition(
"match.source.activity_mutual", "活动互选形成的 Match 数",
"source='activity_mutual' 的 Match 数。注意:它 ≤ 活动互选对数,"
+ "且**不等于**活动互选的成功数(见 activity.mutual.successful)。",
"COUNT(matches.id) WHERE source='activity_mutual'", "—", "matches.id", "matches.matched_at",
TIMEZONE, VERSION, "活动管理员", MetricStatus.OFFICIAL, "matches"),
new MetricDefinition(
"match.source.matchmaker", "红娘促成的 Match 数",
"source='matchmaker' 的 Match 数。",
"COUNT(matches.id) WHERE source='matchmaker'", "—", "matches.id", "matches.matched_at",
TIMEZONE, VERSION, "红娘主管", MetricStatus.OFFICIAL, "matches"),
new MetricDefinition(
"match.successful", "成功牵手数",
"status='successful' 的 Match 数。仅表示双方在平台上确认了关系结果,"
+ "不涉及线下真实性核验。",
"COUNT(matches.id) WHERE status='successful'", "—", "matches.id",
"matches.successful_at", TIMEZONE, VERSION, "平台运营",
MetricStatus.OFFICIAL, "matches"),
new MetricDefinition(
"match.effective", "有效交友匹配数",
"**口径未确认(provisional)**。当前近似定义为:已建立 Match 且双方在会话中"
+ "产生过真人消息(排除 system/risk_notice)的配对数。"
+ "「有效」到哪一步算数(是否要求见面、是否要求持续 N 天)业务方尚未裁决,"
+ "**禁止作为正式成功率口径,禁止对外宣传**。",
"COUNT(matches.id) WHERE status IN ('active','getting_to_know','successful') "
+ "AND EXISTS(真人消息)", "match.total", "matches.id", "matches.matched_at",
TIMEZONE, VERSION, "平台运营",
MetricStatus.PROVISIONAL, "matches"),
// ==================== 聊天域 ====================
new MetricDefinition(
"chat.started", "开启聊天会话数",
"**至少存在一条真人消息**(message_type 不在 system/risk_notice 内)的会话数。"
+ "系统通知(如「你们已互相喜欢」)不作为开启聊天的依据。",
"COUNT(DISTINCT messages.conversation_id) WHERE message_type NOT IN ('system','risk_notice')",
"—", "messages.conversation_id", "messages.created_at", TIMEZONE, VERSION, "平台运营",
MetricStatus.OFFICIAL, "messages"),
new MetricDefinition(
"chat.conversation.total", "会话总数",
"已创建的会话数(含仅由系统消息开头的会话)。与 chat.started 的差额即"
+ "「建了会话但双方从未真正开口」的数量。",
"COUNT(conversations.id)", "—", "conversations.id", "conversations.created_at",
TIMEZONE, VERSION, "平台运营", MetricStatus.OFFICIAL, "conversations"),
// ==================== 活动域 ====================
new MetricDefinition(
"activity.total", "活动总数",
"全部已创建活动数(含草稿、已结束)。",
"COUNT(activities.id)", "—", "activities.id", "activities.created_at", TIMEZONE, VERSION,
"活动管理员", MetricStatus.OFFICIAL, "activities"),
new MetricDefinition(
"activity.registration.total", "活动报名人次",
"报名记录总数(含 pending/approved/rejected/cancelled)。"
+ "同一用户报多个活动计多次 —— 这是**人次**而非人数。",
"COUNT(activity_registrations.id)", "—", "activity_registrations.id",
"activity_registrations.registered_at", TIMEZONE, VERSION, "活动管理员",
MetricStatus.OFFICIAL, "activity_registrations"),
new MetricDefinition(
"activity.registration.approved", "活动报名通过人次",
"status='approved' 的报名记录数。",
"COUNT(activity_registrations.id) WHERE status='approved'", "—",
"activity_registrations.id", "activity_registrations.reviewed_at", TIMEZONE, VERSION,
"活动管理员", MetricStatus.OFFICIAL, "activity_registrations"),
new MetricDefinition(
"activity.registration.checked_in", "活动实际签到人次",
"checkin_status='checked_in' 的报名记录数。与报名通过人次的差额即活动到场流失。",
"COUNT(activity_registrations.id) WHERE checkin_status='checked_in'", "—",
"activity_registrations.id", "activity_registrations.checkin_at", TIMEZONE, VERSION,
"活动管理员", MetricStatus.OFFICIAL, "activity_registrations"),
new MetricDefinition(
"activity.mutual.both_confirmed", "活动互选成立对数",
"两侧均 accepted 的互选记录数(严格互选,**不是**「有人点了同意」)。"
+ "这是互选环节的产出。",
"COUNT(activity_mutual_connection_consents.id) WHERE user_a_status='accepted' "
+ "AND user_b_status='accepted'", "—", "activity_mutual_connection_consents.id",
"activity_mutual_connection_consents.both_confirmed_at", TIMEZONE, VERSION, "活动管理员",
MetricStatus.OFFICIAL, "activity_mutual_connection_consents"),
new MetricDefinition(
"activity.mutual.match", "互选后建立 Match 数",
"source='activity_mutual' 的 Match 数。应当 ≤ 活动互选成立对数;"
+ "若大于,说明存在未经双确认就建 Match 的路径。",
"COUNT(matches.id) WHERE source='activity_mutual'", "activity.mutual.both_confirmed",
"matches.id", "matches.matched_at", TIMEZONE, VERSION, "活动管理员",
MetricStatus.OFFICIAL, "matches"),
new MetricDefinition(
"activity.mutual.successful", "互选后成功牵手数",
"source='activity_mutual' 且 status='successful' 的 Match 数。"
+ "**明确区别于**活动互选成立对数:10 对互选成立远不等于 10 对成功,"
+ "中间还要经过聊天、相处、确认关系。绝不可互相替代引用。",
"COUNT(matches.id) WHERE source='activity_mutual' AND status='successful'",
"activity.mutual.both_confirmed", "matches.id", "matches.successful_at", TIMEZONE, VERSION,
"活动管理员", MetricStatus.OFFICIAL, "matches"),
// ==================== 公益服务域 ====================
new MetricDefinition(
"service.visit.total", "公益服务人次",
"全部公益服务记录的条数之和(预约 + 咨询 + 专项项目参与 + 本地资格办理)。"
+ "**不去重** —— 同一用户来 3 次就是 3 人次。这是服务承载量的口径。",
"Σ(预约, 咨询, 项目参与, 资格办理) 记录数", "—", "各域记录 id(**不去重**)",
"各域 created_at", TIMEZONE, VERSION, "平台运营",
MetricStatus.OFFICIAL, "service_appointments + counseling_services + "
+ "service_program_members + user_local_eligibilities"),
new MetricDefinition(
"service.user.unique", "服务用户数",
"**跨全部公益服务域去重**后的用户数。同一用户既预约又咨询又参加项目,只算 1 人。"
+ "这是服务覆盖面的口径,与 service.visit.total 是**两个不同的指标**,"
+ "两者的比值即人均服务次数。",
"COUNT(DISTINCT user_id) over (预约 ∪ 咨询 ∪ 项目参与 ∪ 资格办理)", "—",
"user_id(**跨域去重**)", "各域 created_at", TIMEZONE, VERSION, "平台运营",
MetricStatus.OFFICIAL, "service_appointments + counseling_services + "
+ "service_program_members + user_local_eligibilities"),
new MetricDefinition(
"service.appointment.total", "服务预约人次",
"服务站预约记录数(不含已取消)。",
"COUNT(service_appointments.id) WHERE status<>'cancelled'", "—", "service_appointments.id",
"service_appointments.created_at", TIMEZONE, VERSION, "服务站运营",
MetricStatus.OFFICIAL, "service_appointments"),
new MetricDefinition(
"counseling.total", "婚恋咨询受理量",
"婚恋咨询记录数(含婚前辅导)。",
"COUNT(counseling_services.id)", "—", "counseling_services.id",
"counseling_services.created_at", TIMEZONE, VERSION, "咨询管理员",
MetricStatus.OFFICIAL, "counseling_services"),
new MetricDefinition(
"service.program.member.approved", "专项服务项目在册成员数",
"status='approved' 的项目成员记录数。成员身份**不得**被用作对外的用户价值标签。",
"COUNT(service_program_members.id) WHERE status='approved'", "—",
"service_program_members.id", "service_program_members.created_at", TIMEZONE, VERSION,
"平台运营", MetricStatus.OFFICIAL, "service_program_members"),
new MetricDefinition(
"content.published.total", "已发布内容数",
"status='published' 的内容文章数(婚恋课堂 / 反诈 / 公益信息)。",
"COUNT(content_articles.id) WHERE status='published'", "—", "content_articles.id",
"content_articles.published_at", TIMEZONE, VERSION, "内容编辑",
MetricStatus.OFFICIAL, "content_articles"),
new MetricDefinition(
"content.views.total", "内容累计阅读量",
"已发布文章的 view_count 之和。",
"SUM(content_articles.view_count) WHERE status='published'", "—", "content_articles.id",
"content_articles.published_at", TIMEZONE, VERSION, "内容编辑",
MetricStatus.OFFICIAL, "content_articles"),
// ==================== Banner 域 ====================
new MetricDefinition(
"banner.total", "Banner 总数",
"全部 Banner 数(含草稿与已下架,不含软删)。",
"COUNT(home_banners.id) WHERE deleted_at IS NULL", "—", "home_banners.id",
"home_banners.created_at", TIMEZONE, VERSION, "内容编辑",
MetricStatus.OFFICIAL, "home_banners"),
new MetricDefinition(
"banner.active", "生效中的 Banner 数",
"status='active' 且当前时间落在 [start_at, end_at] 区间内的 Banner 数。",
"COUNT(home_banners.id) WHERE status='active' AND NOW() BETWEEN start_at AND end_at", "—",
"home_banners.id", "NOW()", TIMEZONE, VERSION, "内容编辑",
MetricStatus.OFFICIAL, "home_banners"),
new MetricDefinition(
"banner.impression", "Banner 曝光量",
"Banner 在小程序首页被渲染展示的次数。"
+ "**当前不可用**:项目内不存在任何曝光埋点表或上报端点,"
+ "修复前该指标被硬编码返回 0 —— 会被误读成「真的没人看到」,"
+ "故改为显式标记为未埋点,绝不返回 0 冒充真实值。",
"—(无数据源)", "—(无数据源)", "—(尚未埋点)", "—", TIMEZONE, VERSION, "内容编辑",
MetricStatus.NOT_INSTRUMENTED, "(待建 home_banner_events)"),
new MetricDefinition(
"banner.click", "Banner 点击量",
"Banner 被点击进入落地页的次数。**当前不可用**,原因同 banner.impression。",
"—(无数据源)", "—(无数据源)", "—(尚未埋点)", "—", TIMEZONE, VERSION, "内容编辑",
MetricStatus.NOT_INSTRUMENTED, "(待建 home_banner_events)"),
// ==================== 安全域 ====================
new MetricDefinition(
"risk.report.pending", "待处理举报数",
"状态为待处理的举报记录数。",
"COUNT(reports.id) WHERE status='pending'", "—", "reports.id", "reports.created_at",
TIMEZONE, VERSION, "风控人员", MetricStatus.OFFICIAL, "reports"),
new MetricDefinition(
"risk.report.resolved", "已处理举报数",
"状态为已处理的举报记录数。",
"COUNT(reports.id) WHERE status='resolved'", "—", "reports.id", "reports.resolved_at",
TIMEZONE, VERSION, "风控人员", MetricStatus.OFFICIAL, "reports"),
new MetricDefinition(
"risk.user.high_risk", "高风险用户数",
"存在生效中封禁或临时停用处置的用户数。",
"COUNT(risk_actions.id) WHERE action_type IN ('ban','temporary_suspend') AND status='active'",
"—", "risk_actions.id", "risk_actions.created_at", TIMEZONE, VERSION, "风控人员",
MetricStatus.OFFICIAL, "risk_actions")
);
private static final Map<String, MetricDefinition> BY_CODE = ALL.stream()
.collect(Collectors.toUnmodifiableMap(MetricDefinition::code, Function.identity()));
/** 全部指标定义(顺序稳定,供文档生成与守卫遍历)。 */
public static List<MetricDefinition> all() {
return ALL;
}
/** 按编码取定义;编码不存在时抛异常(调用处写错编码必须立刻暴露,而不是静默拿到 null)。 */
public static MetricDefinition of(String code) {
MetricDefinition definition = BY_CODE.get(code);
if (definition == null) {
throw new IllegalArgumentException("未登记的指标编码: " + code
+ "(新指标必须先加入 MetricCatalog 并同步 docs/ANALYTICS_METRICS.md)");
}
return definition;
}
/** 口径未确认、禁止对外宣传的指标。 */
public static List<MetricDefinition> provisional() {
return ALL.stream().filter(MetricDefinition::provisional).toList();
}
/** 因缺少埋点而当前无法计算的指标。 */
public static List<MetricDefinition> notInstrumented() {
return ALL.stream().filter(MetricDefinition::notInstrumented).toList();
}
}
@@ -0,0 +1,56 @@
package com.danyang.match.platform.analytics.metric;
/**
* 单个指标的完整定义(Phase 20 指标字典的代码化载体)。
*
* <p>Phase 20 计划原文要求:「为每个指标登记名称、定义、分子分母、去重键、时间字段、
* 时区、版本和负责人」。这些字段此前散落在各统计方法里(大多数情况下**根本不存在**,
* 口径只存在于写代码人的脑子里),导致:
* <ul>
* <li>同一个概念在不同接口里各算一遍,结果不一致(Dashboard 与专项页对不上);</li>
* <li>「实名人数」被误算成「资料没被退回的人数」,没人能在评审时发现;</li>
* <li>「服务用户数」与「服务人次」混用,去重与否无从判断。</li>
* </ul>
*
* <p>把定义变成结构化数据后,{@code MetricCatalogGuardTest} 才能把
* 「每个对外指标都必须有定义」「文档必须覆盖全部指标」「provisional 状态不得缺失」
* 变成可执行断言,而不是靠人肉 review。
*
* @param code 指标唯一编码(如 {@code user.identity.verified}),命名空间用点分层
* @param name 中文名(后台与文档展示用)
* @param definition 口径的自然语言定义(必须说明"算什么"以及"不算什么")
* @param numerator 分子
* @param denominator 分母(非比率指标填 {@code "—"})
* @param dedupeKey 去重键(说明该指标的"1 个单位"是什么,防止口径混淆)
* @param timeField 时间字段(决定"属于哪一天")
* @param timezone 时区(本项目统一 {@code Asia/Shanghai})
* @param version 口径版本(口径变更必须递增,否则历史数据不可比)
* @param owner 口径负责人(业务侧角色,口径争议时的裁决人)
* @param status 口径状态,见 {@link MetricStatus}
* @param sourceTable 数据来源表(便于追溯与索引优化)
*/
public record MetricDefinition(
String code,
String name,
String definition,
String numerator,
String denominator,
String dedupeKey,
String timeField,
String timezone,
String version,
String owner,
MetricStatus status,
String sourceTable
) {
/** 是否为口径未确认的临时指标(禁止对外宣传)。 */
public boolean provisional() {
return status == MetricStatus.PROVISIONAL;
}
/** 是否因缺少埋点而当前无法计算。 */
public boolean notInstrumented() {
return status == MetricStatus.NOT_INSTRUMENTED;
}
}
@@ -0,0 +1,34 @@
package com.danyang.match.platform.analytics.metric;
/**
* 指标口径状态(Phase 20 指标字典的一部分)。
*
* <p>存在的理由:统计接口最大的风险不是"算错",而是**算出来的数字被当成别的意思用了**。
* 「有效交友匹配」在口径确认前如果被后台当成"成功数"展示、被运营写进宣传稿,
* 错误就从代码扩散到了对外承诺。把状态显式建模,可让守卫测试把
* 「provisional 指标不得标记为正式」变成可执行的不变量。
*/
public enum MetricStatus {
/**
* 口径已确认,可作为正式统计口径使用。
*/
OFFICIAL,
/**
* 口径尚未确认,禁止当作正式成功口径,禁止对外宣传。
*
* <p>Phase 20 计划:「有效交友匹配在口径未确认前标为 provisional,
* 不能当正式成功或对外宣传」。
*/
PROVISIONAL,
/**
* 数据源尚未埋点,当前**无法**计算。
*
* <p>此状态的意义在于**诚实**:宁可明确返回「不可用」,
* 也不能像修复前那样硬编码返回 0 —— 0 会被读成"真的没人点击",
* 而实际是"根本没有记录点击的地方",两者对决策的含义完全相反。
*/
NOT_INSTRUMENTED
}
@@ -3,6 +3,7 @@ package com.danyang.match.platform.appeal.controller;
import com.danyang.match.platform.appeal.dto.AppealProcessDTO;
import com.danyang.match.platform.appeal.dto.AppealSubmitDTO;
import com.danyang.match.platform.appeal.service.AppealService;
import com.danyang.match.platform.shared.idempotency.Idempotent;
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
import com.danyang.match.platform.shared.dto.ApiResponse;
import lombok.RequiredArgsConstructor;
@@ -36,6 +37,7 @@ public class AppealController {
* 提交申诉
* POST /api/v1/appeals
*/
@Idempotent
@PostMapping
public ApiResponse<Map<String, Object>> submitAppeal(
@AuthenticationPrincipal UserPrincipal principal,

Some files were not shown because too many files have changed in this diff Show More