Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16c82739db | ||
|
|
f73a116a10 | ||
|
|
21275ef770 | ||
|
|
d3735e3afa | ||
|
|
4248e9574e | ||
|
|
6c2601950b | ||
|
|
1be0d98ebd | ||
|
|
5e484d0e74 | ||
|
|
3caf5f8816 | ||
|
|
861c78efb5 | ||
|
|
6187ec69df | ||
|
|
2d60f7899f | ||
|
|
f76bda1efa | ||
|
|
4edb7619bc | ||
|
|
dfb74f3386 | ||
|
|
9fc3385d6c | ||
|
|
c1c75edc55 | ||
|
|
47cccc9ca5 | ||
|
|
5c97c57c76 | ||
|
|
ca0db57f24 | ||
|
|
8335ef780c | ||
|
|
1ad9793ea2 | ||
|
|
45e84722e1 | ||
|
|
f4e00b4d95 | ||
|
|
e740cbf403 | ||
|
|
44f75acebe | ||
|
|
0a100548a9 | ||
|
|
c0298c27a5 | ||
|
|
1fc34f1bcd | ||
|
|
2c325136ab | ||
|
|
631e3000d3 | ||
|
|
269adf1fed | ||
|
|
825fd41ee4 | ||
|
|
2162612892 | ||
|
|
63c18d89c2 | ||
|
|
79ed89c89f | ||
|
|
dc0a62d4c5 | ||
|
|
797b1c46cf | ||
|
|
aa1114338e | ||
|
|
7186a6b694 | ||
|
|
4c98170107 | ||
|
|
b624c43432 | ||
|
|
c6b48d5d91 | ||
|
|
8bd1ab804a | ||
|
|
c195133680 | ||
|
|
60c591fc68 | ||
|
|
438b38d121 | ||
|
|
b60c1ffb71 | ||
|
|
6e76e2f2ea | ||
|
|
76a152bccf | ||
|
|
08b9cb2468 | ||
|
|
469d7194bd | ||
|
|
28cf25756a | ||
|
|
e9b99d020b | ||
|
|
0aa5a7b4e6 | ||
|
|
d605c04707 | ||
|
|
018626935a | ||
|
|
d9c08d85f9 | ||
|
|
04b07b5821 | ||
|
|
0242ea44e8 | ||
|
|
9466b2b636 | ||
|
|
b834fa86a0 | ||
|
|
e666ee6f8a | ||
|
|
2ae72aabe7 | ||
|
|
0da93008d0 | ||
|
|
b67a56479e | ||
|
|
d036c9b986 | ||
|
|
5715987049 | ||
|
|
6b2ff0f73e | ||
|
|
f63e579a8d | ||
|
|
70134dd62e | ||
|
|
b2ef60c04c | ||
|
|
d707fb1f60 | ||
|
|
c061a55151 | ||
|
|
75272a5c21 | ||
|
|
4e1827295d | ||
|
|
ae2405f645 | ||
|
|
4f37df3284 | ||
|
|
f96dcb8aa6 | ||
|
|
16538d7af5 | ||
|
|
c77e6fcd63 | ||
|
|
3ff0c0641a | ||
|
|
9c26d4b7b2 |
@@ -0,0 +1,37 @@
|
||||
# CODEOWNERS — 善缘丹阳公益婚恋平台 审查角色分配(草案)
|
||||
#
|
||||
# 作用:开 PR 时 GitHub 自动向对应路径的 owner 请求审查;配合分支保护与
|
||||
# docs/governance/CODE_REVIEW_STANDARDS.md 的 T0/T1/T2 分级与双审规则使用。
|
||||
# 语法:https://docs.github.com/en/repositories/managing-your-repositories-settings-and-features/customizing-your-repository/about-code-owners
|
||||
#
|
||||
# 当前为一人团队,默认全部归技术负责人 @g1307092382。
|
||||
# 待扩员时,把各端拆给对应熟悉者即可;T2 高危改动按审查标准须 2 人复核(其中 1 人为该端 owner)。
|
||||
# 说明:CODEOWNERS 只负责「请求谁审」,不强制「必须不同人」;双审由审查流程纪律保证。
|
||||
# 排序:子路径必须放在父路径之后(GitHub 取最后一个匹配的规则)。
|
||||
|
||||
# —— 默认兜底 ——
|
||||
* @g1307092382
|
||||
|
||||
# —— 后端(Spring Boot / Java)——
|
||||
/backend/ @g1307092382
|
||||
|
||||
# 数据库迁移(Flyway):schema 变更属 T2,须重点复核(放在 /backend/ 之后)
|
||||
/backend/src/main/resources/db/migration/ @g1307092382
|
||||
|
||||
# —— 微信小程序(原生 WXML/WXSS/TS)——
|
||||
/miniapp/ @g1307092382
|
||||
|
||||
# —— 管理端(Vue3 / TS / pnpm)——
|
||||
/admin-web/ @g1307092382
|
||||
|
||||
# —— 测试与契约守卫(跨端契约,需熟悉各端)——
|
||||
/tests/ @g1307092382
|
||||
/ui-tests/ @g1307092382
|
||||
/scripts/run-ui-contracts.mjs @g1307092382
|
||||
|
||||
# —— 基础设施 / 部署(Compose / 镜像 / 密钥)——
|
||||
/infrastructure/ @g1307092382
|
||||
|
||||
# —— 治理与审查机制文档(变更须经技术负责人确认)——
|
||||
/docs/governance/ @g1307092382
|
||||
/.github/ @g1307092382
|
||||
@@ -0,0 +1,69 @@
|
||||
<!--
|
||||
提 PR 前请先读 docs/governance/CODE_REVIEW_STANDARDS.md。
|
||||
本模板是合并准入的硬性门槛:逐项打勾 = 已实际验证(不是"应该没问题")。
|
||||
证据优先:描述里的"✅已完成"不是证据,请贴命令+输出/代码 diff/实测结论。
|
||||
-->
|
||||
|
||||
## 变更摘要
|
||||
<!-- 改了什么、为什么、影响面 -->
|
||||
|
||||
## 风险分级(见审查标准 §2)
|
||||
- [ ] T0 琐碎(纯文案/文档/格式化/资源重命名)
|
||||
- [ ] T1 常规(单端功能/Bug/UI/单 API)
|
||||
- [ ] T2 高危(鉴权/支付/隐私/跨端契约/DB schema/并发幂等/已知阻塞项)— 需 2 人复核
|
||||
|
||||
## 验证方式(必填,证据优先)
|
||||
<!-- 贴实际跑过的命令与关键输出;不要只写"已自测通过" -->
|
||||
- 本地测试/契约守卫命令与结果:
|
||||
- 运行时实测(如涉及渲染/接口,贴 mock/路由/关键响应):
|
||||
- 负向注入(新增或修改测试时必填:故意改坏后测试是否转红):
|
||||
|
||||
## 技术栈审查清单(按本 PR 涉及项打勾)
|
||||
### 后端 Java / Spring Boot
|
||||
- [ ] 业务拒绝用 `BusinessException`+`ApiResponseCode`(HTTP 400≤code<500),无裸 `RuntimeException`
|
||||
- [ ] 测试对业务拒绝断言 `400 ≤ status < 500`(非仅 `!= 200`)
|
||||
- [ ] 核心不变量(INV-014/015、Required/Mutual/Block 等)未被绕过
|
||||
- [ ] 主键/唯一键/CHECK 约束与 Java 枚举一致,无静默 no-op 写
|
||||
- [ ] 无新增死表/死配置(`system_configs`/`match_rule_configs` 类)
|
||||
|
||||
### 小程序 miniapp
|
||||
- [ ] `app.json`/`pages.json` 路径与磁盘文件一致、无前导 `/`
|
||||
- [ ] `ignoreDevUnusedFiles`/`ignoreUploadUnusedFiles` = false(资源不被吞)
|
||||
- [ ] 跨端字段名与后端规范名一致(tsc 不保护 JS 层字段名)
|
||||
- [ ] WXSS 无通配符/属性选择器/嵌套注释;`<image>` 显式高度
|
||||
- [ ] 胶囊安全区让出 `--nav-right`
|
||||
|
||||
### 管理端 admin-web
|
||||
- [ ] 渲染相关状态走 `ref`/`reactive`,已补渲染态实测
|
||||
- [ ] 颜色/字号收敛到 `src/theme/` token,EP 主色阶补全 `-light-N`/`-dark-2`/`-rgb`
|
||||
- [ ] 表单校验类型安全、出错保留输入
|
||||
- [ ] 触摸目标 ≥44px,尊重 `prefers-reduced-motion`
|
||||
|
||||
### 数据库迁移 SQL
|
||||
- [ ] `V<序号>__<描述>.sql` 命名,版本连续唯一
|
||||
- [ ] 向后兼容,NOT NULL 新列有默认值/迁移
|
||||
- [ ] CHECK 约束与枚举一致,破坏性变更有回滚方案
|
||||
|
||||
### 测试 / 契约守卫
|
||||
- [ ] 无空壳 `skip`/`pass` 测试
|
||||
- [ ] 已做负向注入证明判别力
|
||||
- [ ] `scripts/run-ui-contracts.mjs` 的 `MIN_GUARDS`(当前 23) 同步(增删守卫必改)
|
||||
|
||||
### 脚本 / 基础设施
|
||||
- [ ] 脚本真实依赖已在 CI 对应 job 安装(非仅本地能跑)
|
||||
- [ ] 密钥仅运行期、不落盘;本地栈固定 `-p danyang`
|
||||
|
||||
## 判据纪律自查(见审查标准 §5,对应改动必查)
|
||||
- [ ] 非空数据验证(无空数据假绿,如推荐/列表返回空也能过)
|
||||
- [ ] 无直连绕过(关键路径至少一个经 HTTP 层断言)
|
||||
- [ ] 约束相关改动在带约束环境验证(无测试库缺约束假绿)
|
||||
- [ ] 小程序改动不走 `Mini Program Compile Check` 的"仅 tsc"假绿,已真编译/查资源
|
||||
- [ ] 合并后回读 `git rev-parse HEAD` + 轮询 CI 确认本次 head 真 success
|
||||
|
||||
## 已知阻塞 / 决策关联
|
||||
- [ ] 本 PR 是否触碰上线阻塞项(微信真实登录 / 照片直传落库)?若是:✅本次解决 / ⚠️未解决已登记 known-risks
|
||||
- [ ] 是否与 `decision-register` 的 LOCKED 决策冲突?若是:已回决策层确认(附结论)
|
||||
- [ ] 是否产生新债务?若是:已登记 `known-risks.md`(编号 KR-xx)
|
||||
|
||||
## 关联
|
||||
<!-- 关联 Issue / 决策 / 风险编号 -->
|
||||
@@ -25,6 +25,13 @@ jobs:
|
||||
working-directory: backend
|
||||
run: mvn verify -B
|
||||
|
||||
# 后端契约门禁(与 scripts/run-ui-contracts.mjs 同级):
|
||||
# 固化关键后端回归/契约测试(含 v1.1.0 修复的 3 个回归测试 + JwtAuthFilter Cookie 兜底
|
||||
# + 核心不变量),先校验这些测试源文件存在性下限(防改名/删除后门禁假绿),
|
||||
# 再用 `mvn test -Dtest=<列表>` 真跑。复用本 job 已编译产物与依赖缓存,开销极小。
|
||||
- name: 后端契约门禁(curated + 下限校验)
|
||||
run: node scripts/run-backend-contracts.mjs
|
||||
|
||||
- name: Upload Test Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -42,8 +49,36 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: admin-web/pnpm-lock.yaml
|
||||
# 这里不能写 cache: 'pnpm'。
|
||||
# 2026-09-22 实测:runner 镜像(ubuntu-24.04 / runner 2.337.0)**已不再预装
|
||||
# pnpm**,而 setup-node 的 cache 步骤在 restore 阶段就需要调用 pnpm 可执行文件,
|
||||
# 于是直接报 "Unable to locate executable file: pnpm",整个 job 在第一步失败、
|
||||
# 后续步骤全部 skipped —— 表现为 admin-web job 秒红,且与业务代码无关。
|
||||
# (同批的 miniapp job 没配 cache 所以活了下来,正好反向印证了这一点。)
|
||||
# 因此:先让 pnpm 存在于 PATH,缓存改为手动配置。
|
||||
|
||||
- name: 启用并锁定 pnpm
|
||||
# corepack 随 Node 分发,runner 上没有 pnpm 但有 corepack。
|
||||
# 钉 9.15.9 的理由:pnpm 9 与 10 对「是否默认放行依赖 build 脚本」语义相反,
|
||||
# 而 pnpm-workspace.yaml 里的 onlyBuiltDependencies 正是版本敏感项
|
||||
# (esbuild / vue-demi 的 postinstall 若被静默阻断,会出现 install 能过、
|
||||
# build 变红,且错误信息不指向真正原因)。钉死后不再随 runner 镜像升级而变化。
|
||||
run: |
|
||||
corepack enable pnpm
|
||||
corepack prepare pnpm@9.15.9 --activate
|
||||
pnpm --version
|
||||
|
||||
- name: 解析 pnpm store 路径
|
||||
id: pnpm-store
|
||||
run: echo "path=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: 缓存 pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.pnpm-store.outputs.path }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('admin-web/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install Dependencies
|
||||
working-directory: admin-web
|
||||
@@ -143,9 +178,45 @@ jobs:
|
||||
- name: 禁止 skip/pass 空壳测试
|
||||
run: python3 scripts/check-no-empty-tests.py tests
|
||||
|
||||
ui-contracts:
|
||||
name: UI Contracts (source-structure gates)
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 23 遗留(收尾计划 批 1 Task 1.1):UI 契约门禁此前**只在本地手工执行**,
|
||||
# 没有 CI 把关 —— 即「改动破坏了源码结构契约也不会被发现」。
|
||||
# 2026-09-19:14 → 15 → 18 个守卫。
|
||||
# 第 15 项 WXML 结构守卫(此前 14 绿时小程序仍有两个页面编译失败:
|
||||
# service-history / service-program 各多一个 </view>);
|
||||
# 第 16/17 项:`this._x()` 无定义(运行期 TypeError,曾致所有请求 Promise 永不 settle)
|
||||
# 与组件 WXSS 违规选择器(标签/ID/属性选择器);
|
||||
# 第 18 项:api.js 请求层**行为**契约(桩 wx.request 真跑,非源码断言)。
|
||||
# 统一执行器负责逐个跑 + 汇总 + 非零退出,
|
||||
# 并校验守卫数量下限(防止守卫被删除/改名后仍然报绿)。
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
# 2026-09-22 修正:本 job 此前注释称「零 npm 依赖,故无需装依赖」,这个声明是错的。
|
||||
# 至少 phase04 守卫会 import miniapp/node_modules/typescript/lib/typescript.js
|
||||
# (用 TS 编译器 API 解析小程序源码)。本地能跑通只因为工作区早已装过 miniapp 依赖;
|
||||
# CI 的每个 job 都在**独立 runner**上,miniapp job 装的东西不会共享过来,
|
||||
# 于是这里直接 ERR_MODULE_NOT_FOUND。
|
||||
# 「依赖声明与实际不符」正是本地绿 / CI 红最典型的一类来源。
|
||||
- name: 安装 miniapp 依赖(phase04 守卫需要 typescript 编译器 API)
|
||||
working-directory: miniapp
|
||||
run: npm install
|
||||
|
||||
- name: 运行 UI 契约门禁
|
||||
run: node scripts/run-ui-contracts.mjs
|
||||
|
||||
e2e:
|
||||
name: E2E (live stack)
|
||||
runs-on: ubuntu-latest
|
||||
# 防挂死:GH Actions 默认 360 分钟,起栈失败若无人超时会把流水线拖住数小时。
|
||||
timeout-minutes: 30
|
||||
# Phase 23 遗留 D-23-D:计划要求 CI 串联 E2E。此前 E2E 从未真正执行过——
|
||||
# 无 conftest、fixture 不存在、文件在 import 期就崩。本 job 起真实栈后执行。
|
||||
needs: [backend, migration]
|
||||
@@ -176,20 +247,56 @@ jobs:
|
||||
WECHAT_APP_SECRET=
|
||||
ENV
|
||||
|
||||
- name: 起真实栈(postgres + redis + backend)
|
||||
# 「活栈供给方式」决策(2026-09-18):**在 runner 内用 Compose 起全栈**。
|
||||
# 备选是 service containers + 在 runner 上 `mvn package` 后 `java -jar`,但那需要为后端
|
||||
# 另写一套 host 网络配置,等于制造第二条与本地不一致的路径;Compose 全栈则与本地验证
|
||||
# 路径完全相同(2026-09-18 本机按同一序列实测:中间件三项 healthcheck 全 Healthy、
|
||||
# backend /health/live 200、seed 幂等可执行、**pytest tests/e2e 49 passed**)。
|
||||
- name: 校验 compose 配置(秒级快速失败,早于任何构建)
|
||||
working-directory: infrastructure
|
||||
run: docker compose up -d --build postgres redis backend
|
||||
run: docker compose config --quiet
|
||||
|
||||
# 2026-09-22:compose 的对象存储已换成 chrislusf/seaweedfs:4.47 并钉版。
|
||||
# 单独保留这一步的理由:镜像拉取失败在 compose up 里会埋进几十行输出,
|
||||
# 这里能在起栈前秒级暴露「上游镜像下架」这类外部依赖失效 ——
|
||||
# 上一轮正是靠日志里的 pull access denied 才定位到 MinIO 已移除 Docker Hub 仓库
|
||||
# (minio/minio 现返回 404;教训是「第三方镜像」本身就是外部依赖风险点)。
|
||||
- name: 校验 seaweedfs 钉版镜像可拉取并打印版本
|
||||
run: |
|
||||
docker pull chrislusf/seaweedfs:4.47
|
||||
docker run --rm chrislusf/seaweedfs:4.47 version
|
||||
|
||||
# 保证库为全新:seed 用 `ON CONFLICT (username) DO NOTHING`,若卷是上一轮残留,
|
||||
# 账号密码会停留在上一轮的 `E2E_OPERATOR_PASSWORD` 上 → 本轮登录 401(表现为各种业务失败)。
|
||||
- name: 清理可能的残留栈与数据卷
|
||||
working-directory: infrastructure
|
||||
run: docker compose down -v --remove-orphans || true
|
||||
|
||||
# 构建与起栈分两步:构建失败时的日志不混杂在 up 输出里,定位更快。
|
||||
- name: 构建 backend 镜像
|
||||
working-directory: infrastructure
|
||||
run: docker compose build backend
|
||||
|
||||
- name: 起真实栈(postgres + redis + seaweedfs + 建桶 + backend)
|
||||
# 显式列出 seaweedfs 与 storage-init:backend 的 depends_on 虽会隐式拉起它们,
|
||||
# 但隐式依赖不利于阅读,且 seaweedfs 的 healthcheck 是 backend 启动的前置条件。
|
||||
# storage-init 是**一次性**建桶容器 —— SeaweedFS 不会自动建桶,
|
||||
# PutObject 到不存在的 bucket 会直接失败,少了它照片链路就是断的
|
||||
# (E2E 对存储零覆盖,不会替你发现)。
|
||||
working-directory: infrastructure
|
||||
run: docker compose up -d postgres redis seaweedfs storage-init backend
|
||||
|
||||
- name: 等待 backend 就绪(超时即失败,不静默跳过)
|
||||
# 窗口 90×5s=7.5min:首次构建后还需 Spring 启动 + Flyway 46 个迁移,5min 偏紧。
|
||||
run: |
|
||||
for i in $(seq 1 60); do
|
||||
for i in $(seq 1 90); 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 "::error::backend 未在 5 分钟内就绪"
|
||||
docker compose -f infrastructure/docker-compose.yml logs backend
|
||||
echo "::error::backend 未在 7.5 分钟内就绪"
|
||||
docker compose -f infrastructure/docker-compose.yml logs --tail=200 backend
|
||||
exit 1
|
||||
|
||||
- name: Seed E2E 运营账号(迁移不含 operator 种子,全新库无账号可登录)
|
||||
@@ -199,7 +306,10 @@ jobs:
|
||||
-v pwd="$E2E_OPERATOR_PASSWORD" -f - < ../tests/e2e/sql/seed_e2e_operators.sql
|
||||
|
||||
- name: Run E2E
|
||||
run: pytest -v
|
||||
# 显式给出目录:pytest.ini 的 `testpaths = tests/e2e` 已限定范围,但显式传参可防
|
||||
# 该配置被改动后悄悄扩大收集范围(仓库根另有 tests/*_test.py 不是 E2E 用例,
|
||||
# 且它们在 import 期即依赖 psycopg2 与 E2E_DB_PASSWORD,会被收集成 error)。
|
||||
run: pytest tests/e2e -v
|
||||
|
||||
- name: Teardown(无论成败都清理并留日志)
|
||||
if: always()
|
||||
|
||||
@@ -10,11 +10,30 @@ package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
|
||||
# 例外:依赖锁文件必须入库,CI 的可复现安装依赖它们。
|
||||
#
|
||||
# 2026-09-22 修复背景:上面四条规则此前把 admin-web/pnpm-lock.yaml 与
|
||||
# miniapp/package-lock.json 一并挡在版本控制之外,于是 CI checkout 后的环境
|
||||
# 与本机完全不同:
|
||||
# - admin-web:`pnpm install --frozen-lockfile` 找不到 lockfile → 直接失败
|
||||
# (frozen 模式的语义就是「必须存在且不可变更」,缺文件不是警告而是错误);
|
||||
# - miniapp:`npm install` 无 lockfile 约束,版本随上游浮动,
|
||||
# `npx tsc --noEmit` 的结果不与任何一次本地验证对齐。
|
||||
# 二者共同导致「本地绿、CI 红」且二者都不可解释。
|
||||
#
|
||||
# 只放行每个包真正使用的那把锁:admin-web 走 pnpm,miniapp 走 npm。
|
||||
# admin-web/package-lock.json 仍然忽略 —— 同一目录并存两把锁会让两种包管理器
|
||||
# 各自按自己的锁安装,漂移时彼此看不出问题,属于典型的双锁冲突。
|
||||
!admin-web/pnpm-lock.yaml
|
||||
!miniapp/package-lock.json
|
||||
|
||||
# Build outputs
|
||||
backend/target/
|
||||
admin-web/dist/
|
||||
miniapp/dist/
|
||||
miniapp/unpackage/
|
||||
# vite 在 dev server 被强杀(未优雅退出)时会遗留配置临时文件
|
||||
admin-web/vite.config.ts.timestamp-*.mjs
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -51,7 +70,17 @@ Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# Secrets and certificates
|
||||
docker-compose.prod.yml
|
||||
#
|
||||
# 2026-09-22 修正:`docker-compose.prod.yml` 原先被列在本段(当作密钥)而排除出
|
||||
# 版本控制,这是**误分类** —— 该文件不含任何明文密钥:所有敏感值都通过
|
||||
# `${VAR:?请设置...}` 注入,db 密码走 Docker secrets(./secrets/db_password.txt),
|
||||
# 文件头注释本身也写着「敏感信息通过 .env 文件注入,不进 Git」。
|
||||
#
|
||||
# 后果:backend 的 `DeploymentTests.testDockerComposeProdExists` 断言该文件存在,
|
||||
# 于是「本地恒绿(文件在工作区)、CI 恒红(checkout 拿不到)」——
|
||||
# 一次典型的本地绿/CI 红假绿,且从本地永远发现不了。
|
||||
# 生产部署配置需要可复现、可评审,应与 staging 版一致纳入版本控制。
|
||||
# 真正的密钥仍由下面三条挡住。
|
||||
infrastructure/secrets/
|
||||
infrastructure/certs/
|
||||
infrastructure/backups/
|
||||
|
||||
@@ -22,7 +22,7 @@ V1.1 是 V1.0 的增量升级。正确的 V1.0 核心按 KEEP 处理,不得因
|
||||
|
||||
业务 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 全绿。管理端桌面端目视复验仍待用户执行。
|
||||
当前 UI 重设计唯一总计划:`docs/superpowers/plans/2026-09-13-reference-driven-dual-client-ui-redesign.md`。首批范围(小程序 11 个核心页面 + 管理端框架/工作台)已于 **2026-09-16 通过用户验收(结论:基本达标)**,此前"视觉否决 / REOPENED"的判定随之关闭,UI 契约门禁 **18/18** 全绿(2026-09-16 时为 13 项,此后新增端口与 `/admin/` 子路径一致性门禁;2026-09-19 连补 4 项 —— 第 15 项 **WXML 结构守卫**:此前全绿时小程序实际有两个页面 `service-history` / `service-program` 因多一个 `</view>` **编译失败**;第 16 项 **私有方法调用守卫**:`this._x()` 无定义会抛 TypeError,且该调用位于 `resolve` 之前时会让**所有请求**的 Promise 永不 settle;第 17 项 **组件 WXSS 选择器守卫**;第 18 项 **api.js 请求层行为契约**(桩掉 `wx.request` 真跑)。前三项说明既有门禁全是源码内容断言、没有一条会解析 WXML/WXSS 或符号)。管理端桌面端目视复验仍待用户执行。
|
||||
|
||||
---
|
||||
|
||||
@@ -61,6 +61,7 @@ V1.1 是 V1.0 的增量升级。正确的 V1.0 核心按 KEEP 处理,不得因
|
||||
24. **Service Station Operator 不可浏览全用户库** — 服务站人员只能处理现场预约和指定任务
|
||||
25. **主办单位关系不自动授予数据权限** — Organization relationship ≠ 用户数据访问权限,权限由 Role+Permission+DataScope 控制
|
||||
26. **前端 Capability 控制入口** — 用户端各功能入口可见性由服务端 CapabilityService 返回决定,前端不自行拼状态判断
|
||||
27. **配置与规则治理(V1.0.1 纠偏基线)** — 技术默认值 ≠ 产品正式确认规则。所有未经产品确认的数量/阈值/时间/比例/权重/容量/开关/冷却期,一律按「`DEFAULT + CONFIG`」管理:可配置化(`@ConfigurationProperties`/Feature Flag)、**不得**作为 `LOCKED` 业务规则写死;每个配置有唯一 canonical key,登记 `docs/governance/config-register.md`,未确认项登记 `docs/governance/open-questions.md`,已确认项才入 `docs/governance/decision-register.md`。涉及并发的上限必须有服务器端强校验 + 并发突破测试。详见 `docs/governance/`(config-register / open-questions / decision-register / hardcoded-rule-audit / config-drift-audit)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
> **📌 指针(2026-09-18 头注)**:V1.1 变更记录见 `docs/V1_1_CHANGELOG.md`;发布级变更另见
|
||||
> `releases/v1.0.0-RC.1|RC.2/`。本文件不再逐版本维护。
|
||||
|
||||
# CHANGELOG
|
||||
|
||||
所有重要变更将记录在此文件中。
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
This is free and unencumbered software released into the public domain.
|
||||
|
||||
Anyone is free to copy, modify, publish, use, compile, sell, or
|
||||
distribute this software, either in source code form or as a compiled
|
||||
binary, for any purpose, commercial or non-commercial, and by any
|
||||
means.
|
||||
|
||||
In jurisdictions that recognize copyright laws, the author or authors
|
||||
of this software dedicate any and all copyright interest in the
|
||||
software to the public domain. We make this dedication for the benefit
|
||||
of the public at large and to the detriment of our heirs and
|
||||
successors. We intend this dedication to be an overt act of
|
||||
relinquishment in perpetuity of all present and future rights to this
|
||||
software under copyright law.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
||||
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
|
||||
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
||||
OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
For more information, please refer to <https://unlicense.org/>
|
||||
@@ -1,322 +1,96 @@
|
||||
# 丹阳公益婚恋服务平台 V1.0 — 上线前剩余工作
|
||||
# 丹阳公益婚恋服务平台 V1.1 — 上线前剩余工作
|
||||
|
||||
> 本文件为上线前必须完成的工作清单,按优先级排序。
|
||||
> 交由 WorkBuddy 执行。
|
||||
> **最后更新:2026-09-18**(原文为 2026-09-01,内容已严重过期,本轮按实测重写)
|
||||
>
|
||||
> ⚠️ **重写说明**:本文件此前被多处引用,但内容与实际严重不符 —— 称 `CHANGELOG.md` 有 **3.3MB**(实际
|
||||
> **4234 字节 / 73 行**);P1-3 指向**已删除**的 `docs/Phase-23-Completion-Report.md`。2026-09-18 文档治理按实测重写,
|
||||
> 并保留原编号以便对照历史引用。**逐项处置计划与批次划分**见
|
||||
> `docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md`。
|
||||
|
||||
---
|
||||
|
||||
## P0 — 上线前必须完成(阻塞性)
|
||||
## 一、已关闭项(原文标为阻塞 / 重要,实测已完成)
|
||||
|
||||
### P0-1: 产品确认16项技术默认值
|
||||
|
||||
**文件**: `docs/PENDING_DECISIONS.md`
|
||||
|
||||
**问题**: 以下技术默认值已硬编码,但无产品最终确认:
|
||||
|
||||
| 决策项 | 当前技术默认值 | 需要确认 |
|
||||
| 原编号 | 事项 | 结论(2026-09-18 复核) |
|
||||
|---|---|---|
|
||||
| 每日推荐数量 | 6 人 | 产品确认 |
|
||||
| 推荐最低阈值分数 | 60 分 | 产品确认 |
|
||||
| Pass 冷却时间 | 180 天 | 产品确认 |
|
||||
| 照片上限 | 6 张 | 产品确认 |
|
||||
| 兴趣标签上限 | 8 个 | 产品确认 |
|
||||
| 自我介绍长度上限 | 500 字符 | 产品确认 |
|
||||
| Privacy 默认值 | 全部 TRUE | 产品确认 |
|
||||
| 活动是否需要审核 | TRUE | 产品确认 |
|
||||
| 活动互选最大人数 | 3 人 | 产品确认 |
|
||||
| 活动互选截止时间 | 48 小时 | 产品确认 |
|
||||
| 红娘是否允许电话联系 | FALSE | 产品确认 |
|
||||
| "正在了解"是否自动暂停推荐 | 用户选择 | 产品确认 |
|
||||
| RelationshipStatus 最终命名 | searching/getting_to_know/paused/matched_successfully/service_exited | 产品确认 |
|
||||
| Appeal 是否 V1.0 首发 | 否(Feature Flag 预留) | 产品确认 |
|
||||
| 每日推荐重置时间 | 00:00 Asia/Shanghai | 产品确认 |
|
||||
|
||||
**操作**:
|
||||
1. 产品负责人逐一确认以上16项
|
||||
2. 确认后更新 `application.yml` 或对应配置类
|
||||
3. 从 `docs/PENDING_DECISIONS.md` 移除已确认项
|
||||
4. 更新 `AGENTS.md` 中的规则说明(如有影响)
|
||||
| P0-1 | 产品确认 16 项技术默认值 | ✅ **已确认 16 项**(见 `docs/PENDING_DECISIONS.md`「已确认决策列表」);仍有 13 项待确认(另见该文件「第三方开放遗留」7 项) |
|
||||
| P0-2 | 隐私政策最终版本确认 | ✅ `docs/legal/privacy-policy.md` 已存在(2996 字节)。上线前仍需确认是否经法务终审签署 |
|
||||
| P0-3 | PIA(隐私影响评估)书面记录 | ✅ `docs/security/PIA.md` 已存在(4700 字节) |
|
||||
| P1-1 | CoreInvariantsTest 补实质性测试 | ✅ **已如实处置**:该类(195 行 / 12 用例)已明确降级为「**常量取值口径守卫,不验证行为**」(DisplayName 已改),真实行为验证转由各 Integration 测试承担(Phase 13 复验)。原文"只是检查常量定义"的批评成立,但处置方式是**如实命名 + 转移**,而非硬塞行为断言 |
|
||||
| P1-2 | PermissionMatrixTest 补自动化验证 | ✅ 已有 341 行 / 12 用例;权限覆盖另由 `V11PermissionMatrixContractTest`(**扫描全部迁移**)+ `OperatorEndpointPermissionCoverageTest` 承担 |
|
||||
| P1-3 | 补全 Phase 23 系统测试完成报告 | ✅ `docs/Phase-23-V11-Recovery-Completion-Report-2026-09-18.md` 已存在(结论:**部分关闭**)。**注意原文指向的 `docs/Phase-23-Completion-Report.md` 从未存在** |
|
||||
| P1-4 | 清理 CHANGELOG 重复内容 | ✅ **不成立**:`CHANGELOG.md` 实际 **4234 字节 / 73 行**,无重复条目问题;已加指向 `docs/V1_1_CHANGELOG.md` 的指针头注(2026-09-18) |
|
||||
| P2-1 | AccountLifecycle 注销联动完善 | ✅ Phase 21 复验关闭(2026-09-18):**CLOSED 仍可登录、两处 TODO 桩、匿名化死代码、注销障碍设计矛盾**等 6 处缺陷已修 |
|
||||
| P2-2 | PhotoService Presigned URL 实现 | ⚠️ **部分**:`PhotoService` 魔数校验恒失败、PDF 被误挡等已修(Phase 10 复验);`POST /me/photos/upload-token` / 下载凭证 / EXIF 清理的落地程度**待确认** |
|
||||
| P2-3 | WebSocket 在线状态管理 | ✅ Phase 09 复验关闭(2026-09-16):下行链路死代码、握手鉴权、Unmatch 不联动会话等 4 处缺陷已修 |
|
||||
|
||||
---
|
||||
|
||||
### P0-2: 隐私政策最终版本确认
|
||||
## 二、仍未完成(按优先级)
|
||||
|
||||
**问题**: Go/No-Go 检查单标注"待确认",为上线前绝对阻塞项。
|
||||
### A. 阻塞 Phase 24(UAT)启动
|
||||
|
||||
**操作**:
|
||||
1. 确认隐私政策文本(用户数据采集、存储、使用、共享、权利)
|
||||
2. 确认用户协议文本
|
||||
3. 将最终版本放入 `docs/legal/` 目录
|
||||
4. 更新小程序登录页和设置页的协议链接
|
||||
1. **Phase 23 剩余**:E2E 场景真实实现 + CI 串联 E2E —— 需「活栈供给方式」**基础设施决策**
|
||||
(占位文件头已保留场景清单:首页 11 / 本地资格 11 / 互选 8 / 权限 10 / 预约 7 项)。
|
||||
2. **管理端桌面端目视复验** —— 需**用户执行**。
|
||||
3. **`docs/PENDING_DECISIONS.md` 13 项待决策** —— 需产品确认。
|
||||
|
||||
> 在 A 组完成前,**不应宣布「失败为零可进入 UAT」**(Phase 23 Gate 要求端到端链路被真实执行)。
|
||||
|
||||
### B. 上线前(运维 / 产品,需人工)
|
||||
|
||||
- **P0-4** 微信小程序审核提交(人工)。
|
||||
- **P0-5** SSL 证书申请与 DNS 配置(人工)。
|
||||
- **Phase 22 Deployment / Ops 口径 5 项未复验**:删除镜像跳过测试的发布路径、生产关 Mock WeChat/演示 Seed/Debug/Swagger、
|
||||
端口与依赖检测、**备份加密 + 真实恢复演练**、Rollback 目标明确。
|
||||
⚠️ 该 Phase 与 `STATUS.md` 所述「Phase 22 部署运维配置扩展(Feature Flag)」**是两份不同工作**。
|
||||
- **Phase 25 运营素材**(真实 Banner/站点/咨询/课堂/反诈内容,不 Seed 假数据)、**Phase 26 上线前 13 项 smoke**。
|
||||
|
||||
### C. 代码级遗留(不阻塞 UAT,但需清偿)
|
||||
|
||||
- **Phase 21**:① 红娘服务进行中拦截**硬编码 `0`** —— 根因是数据模型**没有 `matchmaker_services` 表**,
|
||||
拦截项在设计上无法落地(建表属扩权,待产品确认存储模型);② `getPrivacySettings` / `updatePrivacySettings` 仍是 TODO 桩。
|
||||
- **Phase 22**:T01~T05 五个模块开关 + T07 私有桶为**死配置**(配置暴露但从未被业务读取)。
|
||||
- **任务级 ⏳**:P16-T13(互选超时接线)、P13A-T31(随访审计)、P13A-T33(`RiskEvent source=counseling`)、
|
||||
P04-T10(Onboarding 本地资格步骤)。
|
||||
|
||||
---
|
||||
|
||||
### P0-3: PIA(隐私影响评估)书面记录
|
||||
## 三、原编号索引(便于对照历史引用)
|
||||
|
||||
**问题**: 9个场景需要书面记录,当前无记录。
|
||||
|
||||
**操作**:
|
||||
1. 按以下场景撰写PIA记录:
|
||||
- 实名认证(身份证采集)
|
||||
- 照片存储与展示
|
||||
- 位置/地区信息
|
||||
- 聊天消息存储
|
||||
- 活动报名数据
|
||||
- 红娘服务数据
|
||||
- 举报与风控数据
|
||||
- 账号注销与数据删除
|
||||
- 跨组织数据访问
|
||||
2. 保存到 `docs/security/PIA.md`
|
||||
| 原编号 | 原文标题 | 本文件位置 |
|
||||
|---|---|---|
|
||||
| P0-1 | 产品确认 16 项技术默认值 | 一、已关闭 |
|
||||
| P0-2 | 隐私政策最终版本确认 | 一、已关闭(待法务终审) |
|
||||
| P0-3 | PIA 书面记录 | 一、已关闭 |
|
||||
| P0-4 | 提交微信小程序审核 | 二、B |
|
||||
| P0-5 | SSL 证书申请与 DNS 配置 | 二、B |
|
||||
| P1-1 | CoreInvariantsTest 补充实质性测试 | 一、已关闭 |
|
||||
| P1-2 | PermissionMatrixTest 补充自动化验证 | 一、已关闭 |
|
||||
| P1-3 | 补全 Phase 23 系统测试完成报告 | 一、已关闭 |
|
||||
| P1-4 | 清理 CHANGELOG.md 重复内容 | 一、已关闭(原描述不成立) |
|
||||
| P2-1 | AccountLifecycle 注销联动完善 | 一、已关闭 |
|
||||
| P2-2 | PhotoService Presigned URL 实现 | 一、部分 |
|
||||
| P2-3 | WebSocket 在线状态管理 | 一、已关闭 |
|
||||
|
||||
---
|
||||
|
||||
### P0-4: 提交微信小程序审核
|
||||
## 四、验收标准(更新)
|
||||
|
||||
**问题**: 用户端核心入口,无审核通过的小程序无法服务用户。
|
||||
|
||||
**操作**:
|
||||
1. 在微信公众平台提交小程序审核
|
||||
2. 确保所有页面符合微信小程序审核规范
|
||||
3. 配置好 `app.json` 中的类目和标签
|
||||
4. 准备好隐私政策链接(见 P0-2)
|
||||
| 项目 | 验收条件 | 状态 |
|
||||
|---|---|---|
|
||||
| P0-1 | 16 项全部产品确认,配置正确,文档更新 | ✅ 已完成 |
|
||||
| P0-2 | 隐私政策文本在 `docs/legal/` 中,链接正确 | ✅ 文件已就位(待终审确认) |
|
||||
| P0-3 | `PIA.md` 存在,覆盖全部 9 个场景 | ✅ 文件已就位 |
|
||||
| P1-1 | CoreInvariantsTest 用例如实命名;真实行为由 Integration 测试覆盖 | ✅ 已如实处置 |
|
||||
| P1-2 | 权限矩阵 9×18 覆盖,实际调用验证 | ✅ 由契约守卫承担 |
|
||||
| P1-3 | Phase 23 完成报告存在且内容完整 | ✅ 已存在(部分关闭) |
|
||||
| P1-4 | `CHANGELOG.md` < 100KB,无重复条目 | ✅ 已满足(4.2KB) |
|
||||
| P0-4 | 微信审核提交并通过 | ⬜ 待人工 |
|
||||
| P0-5 | HTTPS 正常,证书有效 | ⬜ 待人工 |
|
||||
| Phase 23 剩余 | E2E 场景实现 + CI 串联 | ⬜ 待决策 |
|
||||
| Phase 24 | UAT 分角色验收,阻断缺陷为零 | ⬜ 待用户确认启动 |
|
||||
|
||||
---
|
||||
|
||||
### P0-5: SSL证书申请与DNS配置
|
||||
|
||||
**问题**: 生产HTTPS必需。
|
||||
|
||||
**操作**:
|
||||
1. 申请SSL证书(Let's Encrypt 或商业证书)
|
||||
2. 配置生产域名 DNS 解析(`match.danyang.gov.cn` 或约定域名)
|
||||
3. 将证书放入 `infrastructure/certs/`
|
||||
4. 验证 `nginx-production.conf` 中的证书路径
|
||||
|
||||
---
|
||||
|
||||
## P1 — 上线前应该完成(重要)
|
||||
|
||||
### P1-1: CoreInvariantsTest 补充实质性测试
|
||||
|
||||
**文件**: `backend/src/test/java/com/danyang/match/platform/invariants/CoreInvariantsTest.java`
|
||||
|
||||
**问题**: 当前测试只是检查常量定义,不是真正的业务逻辑验证。
|
||||
|
||||
**需要实现**(每个不变量需要真实业务逻辑测试):
|
||||
|
||||
```java
|
||||
// INV-001: Required不可自动放宽
|
||||
@Test
|
||||
void testRequiredConditionNotRelaxed() {
|
||||
// 创建用户A(要求对方学历=本科)和用户B(学历=专科)
|
||||
// B进入A的推荐池 → 应该被Required条件过滤
|
||||
// 验证B不在推荐结果中
|
||||
}
|
||||
|
||||
// INV-002: 未Mutual不可聊天
|
||||
@Test
|
||||
void testCannotMessageWithoutMutual() {
|
||||
// 用户A Like用户B,但B未Like A
|
||||
// 调用发送消息接口 → 应返回403/禁止
|
||||
}
|
||||
|
||||
// INV-003: Block优先级最高
|
||||
@Test
|
||||
void testBlockStopsAllInteractions() {
|
||||
// A Block B后
|
||||
// 验证B不在A的推荐中
|
||||
// 验证A Like B后自动取消
|
||||
// 验证A和B的Match自动解除
|
||||
}
|
||||
|
||||
// INV-004: 双向并发Like只创建一个Match
|
||||
@Test
|
||||
void testConcurrentLikeCreatesOneMatch() {
|
||||
// 线程A: A Like B
|
||||
// 线程B: B Like A(并发)
|
||||
// 验证只创建1个Match记录
|
||||
}
|
||||
|
||||
// INV-005: 活动名额不超卖
|
||||
@Test
|
||||
void testActivityCapacityNotOversold() {
|
||||
// 活动剩余1个名额
|
||||
// 线程A和线程B同时报名
|
||||
// 验证只有1人成功,1人被拒绝
|
||||
}
|
||||
|
||||
// INV-006: 单向活动意向不泄露
|
||||
@Test
|
||||
void testActivityInterestNotDisclosed() {
|
||||
// A选择B,但B没选A
|
||||
// 验证B的查询结果中看不到A的选择
|
||||
}
|
||||
|
||||
// INV-007: 红娘不能替用户Like
|
||||
@Test
|
||||
void testMatchmakerCannotLikeForUser() {
|
||||
// 红娘角色调用Like接口 → 应返回403
|
||||
}
|
||||
|
||||
// INV-008: 非授权Operator不能读取身份证
|
||||
@Test
|
||||
void testUnauthorizedOperatorCannotViewIdCard() {
|
||||
// reviewer角色调用敏感身份查看接口 → 应返回403
|
||||
}
|
||||
|
||||
// INV-009: 非案件上下文不能读取聊天
|
||||
@Test
|
||||
void testNoChatAccessOutsideCaseContext() {
|
||||
// risk_operator无案件授权调用聊天记录 → 应返回403
|
||||
}
|
||||
|
||||
// INV-010: 跨Organization不能越权
|
||||
@Test
|
||||
void testCrossOrgCannotAccess() {
|
||||
// Organization A的用户,不能访问Organization B的数据
|
||||
}
|
||||
```
|
||||
|
||||
**注意**: 测试应使用真实数据库(集成测试模式),通过 `@SpringBootTest` + Testcontainers 或指定测试数据库。
|
||||
|
||||
---
|
||||
|
||||
### P1-2: PermissionMatrixTest 补充自动化验证
|
||||
|
||||
**文件**: `backend/src/test/java/com/danyang/match/platform/invariants/PermissionMatrixTest.java`
|
||||
|
||||
**问题**: 当前只有权限矩阵定义,没有实际API调用验证。
|
||||
|
||||
**需要实现**:
|
||||
|
||||
```java
|
||||
@Test
|
||||
void testPermissionMatrixAllRoles() {
|
||||
// 遍历9个角色 × 18个权限
|
||||
// 每个组合:
|
||||
// 有权限 → 调用对应API → 应返回2xx
|
||||
// 无权限 → 调用对应API → 应返回403
|
||||
}
|
||||
|
||||
// 需要验证的角色:
|
||||
// super_admin, platform_admin, organization_admin,
|
||||
// reviewer, matchmaker, activity_manager,
|
||||
// risk_operator, volunteer, data_viewer
|
||||
|
||||
// 需要验证的权限模块:
|
||||
// user.view, user.sensitive.view, user.status.change,
|
||||
// review.view, review.process,
|
||||
// matchmaker.view, matchmaker.assign, matchmaker.service,
|
||||
// activity.view, activity.create, activity.review_registration,
|
||||
// risk.report.view, risk.report.process, risk.user.ban,
|
||||
// organization.manage, permission.manage,
|
||||
// analytics.view, data.export
|
||||
```
|
||||
|
||||
**验证方式**: 通过 `MockMvc` 或 `RestAssured` 调用管理端API,使用不同角色的JWT Token认证。
|
||||
|
||||
---
|
||||
|
||||
### P1-3: 补全 Phase 23 系统测试完成报告
|
||||
|
||||
**文件**: `docs/Phase-23-Completion-Report.md`
|
||||
|
||||
**问题**: Phase 23(系统测试)缺少完成报告。
|
||||
|
||||
**需要包含**:
|
||||
- 执行的测试类型和数量
|
||||
- 测试结果(通过/失败/阻塞)
|
||||
- 安全测试结果(SQL注入/XSS/CSRF等)
|
||||
- 并发测试结果
|
||||
- 发现的问题和修复情况
|
||||
|
||||
---
|
||||
|
||||
### P1-4: 清理 CHANGELOG.md 重复内容
|
||||
|
||||
**文件**: `CHANGELOG.md`
|
||||
|
||||
**问题**: 文件大小3.3MB,存在大量重复条目(同一个Phase变更被复制数十次)。
|
||||
|
||||
**操作**:
|
||||
1. 去重,保留每项变更的唯一记录
|
||||
2. 按版本(v1.0.0-rc.1, v1.0.0)组织
|
||||
3. 文件压缩到合理大小(目标 < 100KB)
|
||||
|
||||
---
|
||||
|
||||
## P2 — 建议完成(非阻塞)
|
||||
|
||||
### P2-1: AccountLifecycle 注销联动完善
|
||||
|
||||
**现状**: 代码已实现,STATUS.md 标记为TODO。
|
||||
|
||||
**验证**: 确认注销后以下联动正确:
|
||||
- 推荐停止
|
||||
- 聊天停止(新消息禁止)
|
||||
- 红娘服务停止
|
||||
- 活动处理(未开始的按规则)
|
||||
- 历史关系保留但标记为已注销
|
||||
|
||||
---
|
||||
|
||||
### P2-2: PhotoService Presigned URL 实现
|
||||
|
||||
**现状**: 代码骨架存在,功能未完整实现。
|
||||
|
||||
**需要完成**:
|
||||
- `PhotoService` 生成 MinIO Presigned URL
|
||||
- 上传凭证接口 `POST /me/photos/upload-token`
|
||||
- 下载凭证接口(私有图片访问)
|
||||
- EXIF信息清理逻辑
|
||||
|
||||
---
|
||||
|
||||
### P2-3: WebSocket 在线状态管理
|
||||
|
||||
**现状**: 骨架实现。
|
||||
|
||||
**需要完善**:
|
||||
- 用户在线/离线状态跟踪
|
||||
- 消息送达状态(已发送/已读)
|
||||
- WebSocket 重连后的状态恢复
|
||||
- 通知推送(`notification.new` 事件)
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序建议
|
||||
|
||||
```
|
||||
1. P0-1 (产品确认) → 产品负责人
|
||||
2. P0-2 (隐私政策) → 产品/法务
|
||||
3. P0-3 (PIA记录) → 安全负责人
|
||||
4. P1-1 (Invariants测试) → 研发
|
||||
5. P1-2 (权限矩阵测试) → 研发
|
||||
6. P1-3 (Phase23报告) → 研发
|
||||
7. P1-4 (CHANGELOG) → 研发
|
||||
8. P0-4 (微信审核) → 产品
|
||||
9. P0-5 (SSL/DNS) → 运维
|
||||
10. P2-1 ~ P2-3 → 研发(可并行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验收标准
|
||||
|
||||
每项完成后应满足:
|
||||
|
||||
| 项目 | 验收条件 |
|
||||
|---|---|
|
||||
| P0-1 | 16项全部产品确认,配置正确,文档更新 |
|
||||
| P0-2 | 隐私政策文本在 `docs/legal/` 中,链接正确 |
|
||||
| P0-3 | PIA.md 存在,覆盖全部9个场景 |
|
||||
| P1-1 | CoreInvariantsTest 10个用例全部包含真实业务逻辑 |
|
||||
| P1-2 | PermissionMatrixTest 9×18全部覆盖,实际调用验证 |
|
||||
| P1-3 | Phase-23-Completion-Report.md 存在且内容完整 |
|
||||
| P1-4 | CHANGELOG.md < 100KB,无重复条目 |
|
||||
| P0-4 | 微信审核提交并通过 |
|
||||
| P0-5 | HTTPS正常,证书有效 |
|
||||
|
||||
---
|
||||
|
||||
*最后更新: 2026-09-01*
|
||||
*最后更新: 2026-09-18 — 按实测重写(原文 2026-09-01,含 3 处与实际不符的陈述)*
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
> **⚠️ 部分取代(2026-09-18 头注)**:本文件为 V1.0 REST API 全量规范。V1.1 增量(Banner、
|
||||
> 服务站、预约、咨询、本地资格、首页聚合等)以 `docs/善缘丹阳 V1.1 数据库与API增量设计.md` 为准,
|
||||
> 路径收敛以 `docs/api/API_INVENTORY.md` 为准。本文件仍是后端代码注释与契约测试援引的协议出处
|
||||
> (如 WebSocket 事件信封第 125 章),该部分继续有效。
|
||||
|
||||
# 丹阳公益婚恋服务平台
|
||||
# V1.0 REST API 接口详细设计规范
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
> **⚠️ 已被取代(2026-09-18 头注)**:本文件为 V1.0 项目规则总纲(333 章),最高执行文件已由
|
||||
> `docs/善缘丹阳 V1.1 WorkBuddy最终主启动提示词(整合版).md` 取代;V1.0 核心规则中仍生效的
|
||||
> 部分已摘要进 `AGENTS.md`。本文件保留原位(`AGENTS_SUMMARY.md` 与历史报告仍援引),其余内容不再生效。
|
||||
|
||||
# 丹阳公益婚恋服务平台
|
||||
# V1.0 WorkBuddy开发执行总指令、项目规则与任务拆解
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
> **⚠️ 已被取代(2026-09-18 头注)**:本文件为 V1.0 首轮启动提示词,最高执行文件已由
|
||||
> `docs/善缘丹阳 V1.1 WorkBuddy最终主启动提示词(整合版).md` 取代;V1.1 需求以 `AGENTS.md`
|
||||
> 点名的六份修订文档为准。本文件保留原位,仅作为 V1.0 规则来源与 Phase Gate 行号出处
|
||||
> (`docs/Phase-XX-V11-Recovery-*` 报告援引)被继续引用,其余内容不再生效。
|
||||
|
||||
# 丹阳公益婚恋服务平台
|
||||
# WorkBuddy首轮启动提示词 + Phase 01~Phase 26 Jira级任务清单 + 每阶段验收条件
|
||||
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<!-- 原指向一个 SVG 图标文件,但仓库内并不存在该文件(public/ 下只有 brand/),
|
||||
属既有的 404;且以 / 开头的绝对路径不会随 vite base 变化,
|
||||
在 /admin/ 子路径部署下即使补上文件也取不到。
|
||||
改为指向已存在、且会被 vite 自动加上 base 前缀的品牌标。 -->
|
||||
<link rel="icon" type="image/png" href="/brand/logo-mark.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>丹阳公益婚恋 - 运营管理后台</title>
|
||||
</head>
|
||||
|
||||
@@ -15,9 +15,20 @@ server {
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# SPA fallback
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
# 根路径没有内容:管理端产物挂在 /admin/ 子路径下(构建 base=/admin/)。
|
||||
# 显式 301 而不是返回 nginx 默认页或 404,避免"打开有东西但其实是别的页面"的假成功。
|
||||
location = / {
|
||||
return 301 /admin/;
|
||||
}
|
||||
|
||||
# SPA:/admin/** 映射到产物根目录。
|
||||
# 上游入口(生产 nginx、本地整套栈 nginx)都**不剥离** /admin 前缀地转发到本容器,
|
||||
# 因此这里必须自己认识这个前缀。
|
||||
# try_files 的兜底写成 URI(/admin/index.html)而不是文件路径:与 alias 配合时,
|
||||
# 写文件路径会被再次拼接 alias 而失效 —— 这是 alias + try_files 的已知坑。
|
||||
location /admin/ {
|
||||
alias /usr/share/nginx/html/;
|
||||
try_files $uri $uri/ /admin/index.html;
|
||||
}
|
||||
|
||||
# API proxy to backend
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "danyang-match-admin",
|
||||
"version": "1.0.0-rc.2",
|
||||
"version": "1.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,3 +1,27 @@
|
||||
# 本目录只有一个前端包,不是 pnpm workspace。
|
||||
#
|
||||
# 2026-09-22 修复背景:
|
||||
# 本文件原先只有两行 allowBuilds(esbuild / vue-demi),既没有 packages 声明,
|
||||
# 也没写实际取值。pnpm 一旦检测到工作目录下存在 pnpm-workspace.yaml,
|
||||
# 就会强制进入 workspace 模式,随后因读取不到 packages 而直接报错退出:
|
||||
#
|
||||
# ERROR packages field missing or empty
|
||||
#
|
||||
# 结果是 admin-web 的 `pnpm install --frozen-lockfile` 在任何环境都装不上依赖
|
||||
# (本地与 CI 表现一致),表现为 admin-web job 秒红,且与业务代码无关。
|
||||
#
|
||||
# allowBuilds 是 pnpm 10 才引入的配置项;pnpm 9 不认识它。这意味着
|
||||
# 「这个残缺文件是否被接受」取决于 runner 上的 pnpm 版本 —— 典型的版本漂移陷阱。
|
||||
# 因此这里显式声明单包 workspace,并在 CI 中锁死 pnpm 版本(见 .github/workflows/ci.yml),
|
||||
# 让「workspace 模式」与「只读 RFC2119 白名单」两条路径都不再受 runner 升级影响。
|
||||
#
|
||||
# esbuild 与 vue-demi 的 postinstall 是必需步骤(前者下载平台原生二进制,
|
||||
# 后者生成 Vue 版本桥接),允许它们执行。
|
||||
packages:
|
||||
- '.'
|
||||
allowBuilds:
|
||||
esbuild: set this to true or false
|
||||
vue-demi: set this to true or false
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- vue-demi
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import router from '@/router'
|
||||
import { getAccessToken, setAccessToken, clearAccessToken } from '@/utils/token'
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 10000,
|
||||
})
|
||||
|
||||
// 防止 401 时并发触发多次刷新
|
||||
let refreshing = false
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
@@ -17,11 +21,45 @@ client.interceptors.request.use((config) => {
|
||||
|
||||
client.interceptors.response.use(
|
||||
(res) => res.data,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem('accessToken')
|
||||
router.push('/login')
|
||||
async (err) => {
|
||||
const status = err.response?.status
|
||||
const original = err.config
|
||||
const isAuthEndpoint = !!original?.url?.includes('/auth/operator/')
|
||||
|
||||
// access token 过期:尝试用 HttpOnly refresh Cookie 静默刷新一次后重试原请求
|
||||
// __retried 防止刷新后重试仍 401 时再次触发刷新,形成循环
|
||||
if (status === 401 && !refreshing && !isAuthEndpoint && !(original as any)?.__retried) {
|
||||
refreshing = true
|
||||
try {
|
||||
const resp: any = await client.post('/auth/operator/refresh', {})
|
||||
if (resp?.success) {
|
||||
const newToken = resp.data.accessToken
|
||||
setAccessToken(newToken)
|
||||
refreshing = false
|
||||
;(original as any).__retried = true
|
||||
const headers = (original.headers || {}) as Record<string, string>
|
||||
headers.Authorization = `Bearer ${newToken}`
|
||||
original.headers = headers as any
|
||||
return client(original)
|
||||
}
|
||||
} catch {
|
||||
// 刷新失败:清除并跳登录
|
||||
} finally {
|
||||
refreshing = false
|
||||
}
|
||||
clearAccessToken()
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
router.push('/login')
|
||||
return Promise.reject(err)
|
||||
}
|
||||
|
||||
// 刷新/登录等鉴权端点自身 401:交由调用方处理文案,不全局弹窗跳转
|
||||
if (status === 401) {
|
||||
if (!isAuthEndpoint) {
|
||||
clearAccessToken()
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
router.push('/login')
|
||||
}
|
||||
} else if (err.response?.data?.message) {
|
||||
ElMessage.error(err.response.data.message)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// 品牌静态资源的运行时前缀。
|
||||
//
|
||||
// admin-web/public/ 下的文件不受 vite `base` 影响(无论 base 是什么,都会被原样复制到
|
||||
// 构建产物的根目录),因此 .vue 模板里若写死 `src="/brand/xxx.png"`,在产物部署于
|
||||
// /admin/ 子路径时,浏览器会去站点根路径请求,最终 404(表现为 logo / 登录插画裂图)。
|
||||
//
|
||||
// 结论:public/ 下的运行时引用必须显式带上 import.meta.env.BASE_URL。
|
||||
// 这里集中导出,避免每处各写一遍拼接而在改 base 时漏改。
|
||||
const BRAND_BASE = `${import.meta.env.BASE_URL}brand/`
|
||||
|
||||
export const BRAND_LOGO = `${BRAND_BASE}logo-mark.png`
|
||||
export const BRAND_LOGIN_SCENE = `${BRAND_BASE}login-illustration.jpg`
|
||||
export const BRAND_ACTIVITY_FALLBACK = `${BRAND_BASE}activity-default.jpg`
|
||||
export const BRAND_BACKDROP = `${BRAND_BASE}admin-danyang-backdrop.jpg`
|
||||
@@ -2,7 +2,7 @@
|
||||
<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="善缘丹阳" />
|
||||
<img :src="BRAND_LOGO" alt="善缘丹阳" />
|
||||
<div class="scene-brand-copy">
|
||||
<strong>善缘丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
@@ -27,7 +27,7 @@
|
||||
<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="" />
|
||||
<img class="brand-mark" :src="BRAND_LOGO" alt="" />
|
||||
<div v-if="!isCollapse" class="brand-copy">
|
||||
<strong>善缘丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
@@ -129,6 +129,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { BRAND_BACKDROP, BRAND_LOGO } from '@/brand'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import {
|
||||
@@ -173,8 +174,8 @@ const navigation: NavigationItem[] = [
|
||||
{ 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: '/referral', permissions: ['matchmaker.service'], icon: Connection },
|
||||
{ label: '红娘服务', path: '/matchmaker', permissions: ['matchmaker.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 },
|
||||
@@ -216,7 +217,7 @@ const currentRoleName = computed(() => {
|
||||
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 '公益红娘'
|
||||
if (perms.includes('matchmaker.view')) return '公益红娘'
|
||||
return '运营人员'
|
||||
})
|
||||
const visibleNavigation = computed(() => navigation.filter((item) => item.permissions.every(hasPerm)))
|
||||
@@ -260,18 +261,23 @@ function handleLogout() {
|
||||
min-height: 100vh;
|
||||
padding: 18px 34px 14px;
|
||||
overflow: hidden;
|
||||
background: #fff7f7 url('/brand/admin-danyang-backdrop.jpg') center / cover fixed no-repeat;
|
||||
/* 背景图是 public/ 下的运行时资源,必须带 BASE_URL 前缀:
|
||||
写死品牌目录的绝对路径,在产物部署于 /admin/ 子路径时会 404(页面背景变纯色)。
|
||||
用 v-bind 把常量注入 CSS,避免在样式里各写一遍拼接。 */
|
||||
/* 先给纯色兜底,避免图未加载完成时闪白 */
|
||||
background: var(--admin-coral-lighter) center / cover fixed no-repeat;
|
||||
background-image: url(v-bind('BRAND_BACKDROP'));
|
||||
}
|
||||
|
||||
.scene-header {
|
||||
display: grid;
|
||||
max-width: 1840px;
|
||||
max-width: var(--admin-shell-max-width);
|
||||
height: 188px;
|
||||
margin: 0 auto;
|
||||
grid-template-columns: minmax(640px, 1fr) auto;
|
||||
grid-template-rows: 112px 76px;
|
||||
align-items: center;
|
||||
column-gap: 32px;
|
||||
column-gap: var(--space-8);
|
||||
}
|
||||
|
||||
.scene-brand,
|
||||
@@ -290,23 +296,23 @@ function handleLogout() {
|
||||
|
||||
.scene-slogan {
|
||||
display: flex;
|
||||
padding-right: 24px;
|
||||
padding-right: var(--space-6);
|
||||
align-items: flex-end;
|
||||
flex-direction: column;
|
||||
color: var(--admin-coral);
|
||||
font-family: KaiTi, STKaiti, serif;
|
||||
color: var(--admin-coral-strong);
|
||||
font-family: var(--font-family-brand);
|
||||
line-height: 1.05;
|
||||
transform: rotate(-7deg);
|
||||
}
|
||||
|
||||
.scene-slogan span {
|
||||
font-size: 17px;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.scene-slogan strong {
|
||||
margin-top: 4px;
|
||||
font-size: 25px;
|
||||
font-weight: 500;
|
||||
margin-top: var(--space-1);
|
||||
font-size: var(--font-size-3xl);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
|
||||
.scene-brand-copy {
|
||||
@@ -314,18 +320,18 @@ function handleLogout() {
|
||||
margin-left: 10px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
color: var(--admin-coral);
|
||||
color: var(--admin-coral-strong);
|
||||
}
|
||||
|
||||
.scene-brand-copy strong {
|
||||
font-size: 27px;
|
||||
font-size: var(--font-size-4xl);
|
||||
line-height: 1.15;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.scene-brand-copy span {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
@@ -333,18 +339,18 @@ function handleLogout() {
|
||||
min-width: 370px;
|
||||
margin-left: 28px;
|
||||
padding-left: 28px;
|
||||
border-left: 1px solid rgba(244, 95, 104, 0.22);
|
||||
border-left: 1px solid var(--admin-coral-border);
|
||||
}
|
||||
|
||||
.scene-title h1 {
|
||||
font-size: clamp(26px, 2.2vw, 38px);
|
||||
font-size: clamp(var(--font-size-3xl), 2.2vw, var(--font-size-display));
|
||||
line-height: 1.1;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.scene-title p {
|
||||
margin-top: 9px;
|
||||
color: #66717a;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.scene-benefits {
|
||||
@@ -352,40 +358,41 @@ function handleLogout() {
|
||||
align-self: start;
|
||||
justify-content: flex-start;
|
||||
gap: clamp(38px, 5vw, 92px);
|
||||
padding: 4px 0 0 50px;
|
||||
padding: var(--space-1) 0 0 50px;
|
||||
}
|
||||
|
||||
.scene-benefits span {
|
||||
display: grid;
|
||||
grid-template-columns: 36px auto;
|
||||
column-gap: 8px;
|
||||
color: var(--admin-coral);
|
||||
column-gap: var(--space-2);
|
||||
/* 13px 文字压在摄影背景图上,必须用 -strong 档(5.13:1),基础色只有 3.14:1 */
|
||||
color: var(--admin-coral-strong);
|
||||
}
|
||||
|
||||
.scene-benefits .el-icon {
|
||||
grid-row: 1 / 3;
|
||||
font-size: 30px;
|
||||
font-size: var(--font-size-4xl);
|
||||
}
|
||||
|
||||
.scene-benefits b {
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.scene-benefits small {
|
||||
margin-top: 2px;
|
||||
color: #7b7f86;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
width: min(1840px, 100%);
|
||||
width: min(var(--admin-shell-max-width), 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: 1px solid var(--color-bg-overlay-strong);
|
||||
border-radius: var(--admin-shell-radius);
|
||||
box-shadow: var(--admin-shadow);
|
||||
backdrop-filter: blur(14px);
|
||||
@@ -396,23 +403,23 @@ function handleLogout() {
|
||||
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;
|
||||
background: var(--admin-sidebar-gradient);
|
||||
border-right: 1px solid var(--admin-sidebar-hairline-soft);
|
||||
transition: width var(--motion-normal) var(--ease-standard);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
height: 82px;
|
||||
padding: 17px 16px;
|
||||
padding: 17px var(--space-4);
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-bottom: 1px solid var(--admin-sidebar-hairline);
|
||||
}
|
||||
|
||||
.brand.compact {
|
||||
justify-content: center;
|
||||
padding: 16px 8px;
|
||||
padding: var(--space-4) var(--space-2);
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
@@ -430,15 +437,15 @@ function handleLogout() {
|
||||
}
|
||||
|
||||
.brand-copy strong {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
color: var(--color-text-inverse);
|
||||
font-size: var(--font-size-lg);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.brand-copy span {
|
||||
margin-top: 4px;
|
||||
color: #b5c0c8;
|
||||
font-size: 10px;
|
||||
margin-top: var(--space-1);
|
||||
color: var(--color-text-on-dark-muted);
|
||||
font-size: var(--font-size-micro);
|
||||
letter-spacing: 1.5px;
|
||||
}
|
||||
|
||||
@@ -452,49 +459,54 @@ function handleLogout() {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 主导航行项:桌面密度下取 44px(兼顾触屏笔记本,且远高于 WCAG 2.5.8 的 24px 下限) */
|
||||
.sidebar-menu :deep(.el-menu-item) {
|
||||
height: 41px;
|
||||
height: var(--tap-min);
|
||||
margin: 2px 0;
|
||||
color: var(--admin-sidebar-text);
|
||||
border-radius: 9px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: background var(--motion-fast) var(--ease-standard),
|
||||
color var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-menu-item:hover) {
|
||||
color: #fff;
|
||||
color: var(--color-text-inverse);
|
||||
background: var(--admin-sidebar-surface);
|
||||
}
|
||||
|
||||
/* 激活态原先压的是明亮珊瑚渐变(#f45f68→#ee727b),白字只有 3.14:1;
|
||||
改用达标渐变(两端 4.60:1 / 5.33:1)。 */
|
||||
.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);
|
||||
color: var(--color-text-inverse);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
background: var(--gradient-brand-solid);
|
||||
box-shadow: var(--shadow-brand);
|
||||
}
|
||||
|
||||
.sidebar-menu :deep(.el-icon) {
|
||||
font-size: 17px;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.sidebar-motto {
|
||||
display: flex;
|
||||
margin: 8px 15px 2px;
|
||||
padding: 14px 12px;
|
||||
margin: var(--space-2) 15px 2px;
|
||||
padding: 14px var(--space-3);
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
color: #ff969c;
|
||||
font-family: KaiTi, STKaiti, serif;
|
||||
font-size: 15px;
|
||||
color: var(--admin-coral-on-dark);
|
||||
font-family: var(--font-family-brand);
|
||||
font-size: var(--font-size-md);
|
||||
line-height: 1.45;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-top: 1px solid var(--admin-sidebar-hairline);
|
||||
}
|
||||
|
||||
.sidebar-motto .el-icon {
|
||||
font-size: 31px;
|
||||
font-size: var(--font-size-4xl);
|
||||
}
|
||||
|
||||
.collapse-control {
|
||||
display: flex;
|
||||
height: 42px;
|
||||
min-height: var(--tap-min);
|
||||
margin: 2px 10px 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -503,10 +515,14 @@ function handleLogout() {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-sm);
|
||||
transition: color var(--motion-fast) var(--ease-standard),
|
||||
background var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.collapse-control:hover {
|
||||
color: #fff;
|
||||
color: var(--color-text-inverse);
|
||||
background: var(--admin-sidebar-surface);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
@@ -515,12 +531,12 @@ function handleLogout() {
|
||||
|
||||
.admin-topbar {
|
||||
display: flex;
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
height: var(--header-height);
|
||||
padding: 0 var(--space-6);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-bottom: 1px solid #edf0f2;
|
||||
background: var(--color-bg-overlay-strong);
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.page-context,
|
||||
@@ -532,15 +548,18 @@ function handleLogout() {
|
||||
|
||||
.page-context {
|
||||
min-width: 180px;
|
||||
gap: 12px;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.mobile-collapse {
|
||||
display: none;
|
||||
color: #56616a;
|
||||
min-width: var(--tap-icon);
|
||||
min-height: var(--tap-icon);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.header-tools {
|
||||
@@ -552,57 +571,59 @@ function handleLogout() {
|
||||
}
|
||||
|
||||
.global-search :deep(.el-input__wrapper) {
|
||||
background: #f6f7f8;
|
||||
border-radius: 9px;
|
||||
box-shadow: inset 0 0 0 1px #e8eaed;
|
||||
background: var(--color-bg-input);
|
||||
border-radius: var(--border-radius-sm);
|
||||
box-shadow: inset 0 0 0 1px var(--color-border);
|
||||
}
|
||||
|
||||
.notification-button {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: #4f5b65;
|
||||
width: var(--tap-icon);
|
||||
height: var(--tap-icon);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
place-items: center;
|
||||
transition: background var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.notification-button:hover {
|
||||
background: #f6f7f8;
|
||||
background: var(--color-bg-input);
|
||||
}
|
||||
|
||||
/* 角标字号原为 9px(低于可读下限),提到 11px,容器同步放大以容纳 */
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 1px;
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
min-width: var(--space-4);
|
||||
height: var(--space-4);
|
||||
padding: 0 3px;
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
line-height: 15px;
|
||||
color: var(--color-text-inverse);
|
||||
font-size: var(--font-size-micro);
|
||||
line-height: var(--space-4);
|
||||
text-align: center;
|
||||
background: var(--admin-coral);
|
||||
border-radius: 999px;
|
||||
background: var(--admin-coral-solid);
|
||||
border-radius: var(--border-radius-pill);
|
||||
}
|
||||
|
||||
.user-info {
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-info .el-avatar {
|
||||
color: #fff;
|
||||
background: var(--admin-coral);
|
||||
color: var(--color-text-inverse);
|
||||
background: var(--admin-coral-solid);
|
||||
}
|
||||
|
||||
.user-info strong {
|
||||
max-width: 92px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-size: var(--font-size-sm);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -610,27 +631,28 @@ function handleLogout() {
|
||||
.admin-content {
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
background: var(--admin-page-bg);
|
||||
background: var(--color-bg-page);
|
||||
}
|
||||
|
||||
.scene-footer {
|
||||
display: flex;
|
||||
max-width: 1840px;
|
||||
height: 32px;
|
||||
max-width: var(--admin-shell-max-width);
|
||||
height: var(--space-8);
|
||||
margin: 0 auto;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
color: #8d8587;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
|
||||
.scene-footer strong {
|
||||
color: var(--admin-coral);
|
||||
font-size: 14px;
|
||||
color: var(--admin-coral-strong);
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.scene-footer small {
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size-micro);
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
@@ -648,15 +670,15 @@ function handleLogout() {
|
||||
}
|
||||
|
||||
.scene-slogan {
|
||||
padding-right: 12px;
|
||||
padding-right: var(--space-3);
|
||||
}
|
||||
|
||||
.scene-slogan span {
|
||||
font-size: 14px;
|
||||
font-size: var(--font-size-base);
|
||||
}
|
||||
|
||||
.scene-slogan strong {
|
||||
font-size: 20px;
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
.scene-brand > img {
|
||||
@@ -665,7 +687,7 @@ function handleLogout() {
|
||||
}
|
||||
|
||||
.scene-brand-copy strong {
|
||||
font-size: 22px;
|
||||
font-size: var(--font-size-2xl);
|
||||
}
|
||||
|
||||
.scene-title {
|
||||
@@ -674,8 +696,9 @@ function handleLogout() {
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
/* 紧凑态下正文标题略大于品牌字号,层级保持「页面 → 品牌」的正确指向 */
|
||||
.scene-title h1 {
|
||||
font-size: 27px;
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
|
||||
.scene-benefits {
|
||||
@@ -702,7 +725,7 @@ function handleLogout() {
|
||||
}
|
||||
|
||||
.admin-sidebar {
|
||||
width: 76px !important;
|
||||
width: var(--sidebar-width-collapsed) !important;
|
||||
}
|
||||
|
||||
.brand-copy,
|
||||
@@ -736,7 +759,7 @@ function handleLogout() {
|
||||
}
|
||||
|
||||
.admin-topbar {
|
||||
padding: 0 12px;
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -80,6 +80,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
/**
|
||||
* U32 活动签到工作台(Phase 15)
|
||||
*
|
||||
@@ -111,7 +112,7 @@ const TYPE_TEXT: Record<string, string> = {
|
||||
}
|
||||
const typeText = (t: string) => TYPE_TEXT[t] || t || '未分类'
|
||||
|
||||
const authHeader = () => ({ Authorization: `Bearer ${localStorage.getItem('accessToken') || ''}` })
|
||||
const authHeader = () => ({ Authorization: `Bearer ${getAccessToken() || ''}` })
|
||||
|
||||
const formatTime = (v: any) => {
|
||||
if (!v) return '-'
|
||||
@@ -183,13 +184,13 @@ onMounted(loadActivities)
|
||||
}
|
||||
.form-hint {
|
||||
margin-top: 8px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-placeholder);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
.opt-type {
|
||||
float: right;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-placeholder);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
.result-card {
|
||||
margin-top: 12px;
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
@@ -55,7 +56,7 @@ const mutualList = ref<any[]>([])
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/activities/operator/activity-interest-summary', {}, { Authorization: `Bearer ${token}` })
|
||||
const rows = res.data || []
|
||||
tableData.value = searchActivityId.value
|
||||
@@ -72,7 +73,7 @@ async function viewMutuals(activityId: string) {
|
||||
drawerVisible.value = true
|
||||
currentActivity.value = tableData.value.find(row => row.activityId === activityId)
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const mutualRes: any = await get(`/activities/operator/${activityId}/mutuals`, {}, { Authorization: `Bearer ${token}` })
|
||||
mutualList.value = mutualRes.data || []
|
||||
} catch (e) {
|
||||
|
||||
@@ -168,6 +168,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
/**
|
||||
* 活动管理(V1.1 Phase 14)
|
||||
*
|
||||
@@ -259,7 +260,7 @@ function emptyForm() {
|
||||
}
|
||||
|
||||
function authHeader() {
|
||||
return { Authorization: `Bearer ${localStorage.getItem('accessToken')}` }
|
||||
return { Authorization: `Bearer ${getAccessToken()}` }
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
@@ -434,8 +435,8 @@ onMounted(() => {
|
||||
}
|
||||
.form-hint {
|
||||
margin-top: 4px;
|
||||
color: #8a90a2;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-placeholder);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.partner-row {
|
||||
|
||||
@@ -213,8 +213,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
import { token } from '@/theme/tokens'
|
||||
|
||||
const loading = ref(false)
|
||||
const dateRange = ref<[string, string]>([
|
||||
@@ -227,87 +229,108 @@ const safetyStats = ref<any>({})
|
||||
const v11 = ref<any>({})
|
||||
const dailySummary = ref<any[]>([])
|
||||
|
||||
const funnelStages = [
|
||||
{ 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' }
|
||||
]
|
||||
// 漏斗卡与安全卡一律**派生自上面的 ref**,不再各自持有一份可变副本。
|
||||
//
|
||||
// 历史缺陷(2026-09-23 渲染态实测发现):这三组曾是 `const x = [...]` 普通数组,
|
||||
// loadFunnel() 直接对元素赋值(`x[0].value = ...`),模板读的却是同一个数组 ——
|
||||
// 非响应式写入不触发重渲染,于是卡片恒显示 0,而同一张卡里的汇总表(走 ref)
|
||||
// 显示 12846/9217/8462/3812,界面自相矛盾。
|
||||
// 改为 computed 派生后两者同源,结构上不可能再对不上。
|
||||
const funnelStages = computed(() => [
|
||||
{
|
||||
key: 'registered', label: '累计注册',
|
||||
value: funnelStats.value.totalRegistered || 0,
|
||||
rate: null as string | null, color: token('--color-info')
|
||||
},
|
||||
{
|
||||
key: 'verified', label: '已实名',
|
||||
value: funnelStats.value.verifiedCount || 0,
|
||||
rate: funnelStats.value.verifyRate || '-', color: token('--color-success')
|
||||
},
|
||||
{
|
||||
key: 'approved', label: '审核通过',
|
||||
value: funnelStats.value.profileApproved || 0,
|
||||
rate: funnelStats.value.approvalRate || '-', color: token('--color-warning')
|
||||
},
|
||||
{
|
||||
key: 'active', label: '7日活跃',
|
||||
value: funnelStats.value.activeUsers || 0,
|
||||
rate: funnelStats.value.activeRate || '-', color: token('--color-primary')
|
||||
}
|
||||
])
|
||||
|
||||
const matchStages = [
|
||||
{ key: 'recommended', label: '推荐', value: 0, rate: '-', color: '#409EFF' },
|
||||
{ key: 'liked', label: '喜欢', value: 0, rate: '-', color: '#67C23A' },
|
||||
{ key: 'matches', label: 'Match', value: 0, rate: '-', color: '#E59A32' },
|
||||
{ key: 'chats', label: '聊天', value: 0, rate: '-', color: '#909399' },
|
||||
{ key: 'knowing', label: '了解中', value: 0, rate: '-', color: '#E96B6B' },
|
||||
{ key: 'success', label: '成功牵手', value: 0, rate: '-', color: '#F56C6C' }
|
||||
]
|
||||
const matchStages = computed(() => [
|
||||
{
|
||||
key: 'recommended', label: '推荐',
|
||||
value: matchFunnelStats.value.totalRecommended || 0,
|
||||
rate: '-', color: token('--color-info')
|
||||
},
|
||||
{
|
||||
key: 'liked', label: '喜欢',
|
||||
value: matchFunnelStats.value.totalLiked || 0,
|
||||
rate: matchFunnelStats.value.likeRate || '-', color: token('--color-success')
|
||||
},
|
||||
{
|
||||
key: 'matches', label: 'Match',
|
||||
value: matchFunnelStats.value.totalMatches || 0,
|
||||
rate: matchFunnelStats.value.matchRate || '-', color: token('--color-warning')
|
||||
},
|
||||
{
|
||||
key: 'chats', label: '聊天',
|
||||
value: matchFunnelStats.value.chatsStarted || 0,
|
||||
rate: matchFunnelStats.value.chatRate || '-', color: token('--color-neutral')
|
||||
},
|
||||
{
|
||||
key: 'knowing', label: '了解中',
|
||||
value: matchFunnelStats.value.gettingToKnow || 0,
|
||||
rate: '-', color: token('--color-primary')
|
||||
},
|
||||
{
|
||||
key: 'success', label: '成功牵手',
|
||||
value: matchFunnelStats.value.successPairs || 0,
|
||||
rate: matchFunnelStats.value.successRate || '-', color: token('--color-danger')
|
||||
}
|
||||
])
|
||||
|
||||
const safetyCards = [
|
||||
{ key: 'pending', label: '待处理举报', value: 0, color: '#E96B6B' },
|
||||
{ key: 'resolved', label: '已处理举报', value: 0, color: '#67C23A' },
|
||||
{ key: 'highRisk', label: '高风险用户', value: 0, color: '#E59A32' },
|
||||
{ key: 'banned', label: '封禁用户', value: 0, color: '#F56C6C' }
|
||||
]
|
||||
const safetyCards = computed(() => [
|
||||
{ key: 'pending', label: '待处理举报', value: safetyStats.value.pendingReports || 0, color: token('--color-primary') },
|
||||
{ key: 'resolved', label: '已处理举报', value: safetyStats.value.resolvedReports || 0, color: token('--color-success') },
|
||||
{ key: 'highRisk', label: '高风险用户', value: safetyStats.value.highRiskUsers || 0, color: token('--color-warning') },
|
||||
{ key: 'banned', label: '封禁用户', value: safetyStats.value.bannedUsers || 0, color: token('--color-danger') }
|
||||
])
|
||||
|
||||
async function loadFunnel() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
// 注意:这里曾是 `const token = ...`,与顶部从 @/theme/tokens 导入的**颜色助手**
|
||||
// `token` 同名(函数内遮蔽)。现已改名 authToken,避免后续在函数内误用颜色助手。
|
||||
const authToken = getAccessToken()
|
||||
if (dateRange.value[0] && dateRange.value[1]) {
|
||||
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}` }),
|
||||
}, { Authorization: `Bearer ${authToken}` }),
|
||||
get('/operator/analytics/match-funnel', {
|
||||
since: dateRange.value[0], until: dateRange.value[1]
|
||||
}, { Authorization: `Bearer ${token}` }),
|
||||
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${token}` }),
|
||||
}, { Authorization: `Bearer ${authToken}` }),
|
||||
get('/operator/analytics/safety', {}, { Authorization: `Bearer ${authToken}` }),
|
||||
// Phase 20:原先只接了 4/15 个端点,V1.1 公益服务的专项统计
|
||||
// (服务用户数/人次、活动互选 vs 成功、Banner、专项项目)
|
||||
// 后端已实现却无人调用 —— 本行起补齐。
|
||||
get('/operator/analytics/v11-dashboard', {}, { Authorization: `Bearer ${token}` })
|
||||
get('/operator/analytics/v11-dashboard', {}, { Authorization: `Bearer ${authToken}` })
|
||||
])
|
||||
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
|
||||
funnelStages[1].value = userData.verifiedCount || 0
|
||||
funnelStages[1].rate = userData.verifyRate || '-'
|
||||
funnelStages[2].value = userData.profileApproved || 0
|
||||
funnelStages[2].rate = userData.approvalRate || '-'
|
||||
funnelStages[3].value = userData.activeUsers || 0
|
||||
funnelStages[3].rate = userData.activeRate || '-'
|
||||
|
||||
matchStages[0].value = matchData.totalRecommended || 0
|
||||
matchStages[1].value = matchData.totalLiked || 0
|
||||
matchStages[1].rate = matchData.likeRate || '-'
|
||||
matchStages[2].value = matchData.totalMatches || 0
|
||||
matchStages[2].rate = matchData.matchRate || '-'
|
||||
matchStages[3].value = matchData.chatsStarted || 0
|
||||
matchStages[3].rate = matchData.chatRate || '-'
|
||||
matchStages[4].value = matchData.gettingToKnow || 0
|
||||
matchStages[5].value = matchData.successPairs || 0
|
||||
matchStages[5].rate = matchData.successRate || '-'
|
||||
|
||||
safetyCards[0].value = safetyData.pendingReports || 0
|
||||
safetyCards[1].value = safetyData.resolvedReports || 0
|
||||
safetyCards[2].value = safetyData.highRiskUsers || 0
|
||||
safetyCards[3].value = safetyData.bannedUsers || 0
|
||||
// 只落 ref,卡片由上面三个 computed 自动派生 —— 不再手工同步第二份副本。
|
||||
funnelStats.value = userRes.data || {}
|
||||
matchFunnelStats.value = matchRes.data || {}
|
||||
safetyStats.value = safetyRes.data || {}
|
||||
v11.value = v11Res.data || {}
|
||||
}
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
async function loadDailySummary() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${token}` })
|
||||
const authToken = getAccessToken()
|
||||
const res: any = await get('/operator/analytics/daily-summary', { days: 30 }, { Authorization: `Bearer ${authToken}` })
|
||||
dailySummary.value = res.data || []
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
@@ -329,21 +352,21 @@ onMounted(loadData)
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.funnel-label { font-size: 14px; color: #666; margin-bottom: 8px; }
|
||||
.funnel-value { font-size: 28px; font-weight: bold; color: #333; }
|
||||
.funnel-rate { font-size: 12px; color: #999; margin-top: 4px; }
|
||||
.funnel-label { font-size: var(--font-size-base); color: var(--color-text-tertiary); margin-bottom: 8px; }
|
||||
.funnel-value { font-size: var(--font-size-4xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
|
||||
.funnel-rate { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 4px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-value { font-size: 32px; font-weight: bold; }
|
||||
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
|
||||
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); }
|
||||
.stat-label { font-size: var(--font-size-sm); color: var(--color-text-tertiary); margin-top: 6px; }
|
||||
.mini-stat {
|
||||
border: 1px solid #ebeef5;
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
text-align: center;
|
||||
background: #fafafa;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.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; }
|
||||
.mini-value { font-size: var(--font-size-3xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
|
||||
.mini-label { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 6px; }
|
||||
.metric-note { font-size: var(--font-size-xs); color: var(--color-text-placeholder); margin-top: 12px; line-height: 1.6; }
|
||||
.unavailable { color: var(--color-text-disabled); font-style: italic; }
|
||||
</style>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
</el-descriptions>
|
||||
|
||||
<template v-if="detail.status==='pending'||detail.status==='reviewing'||detail.status==='escalated'">
|
||||
<h4 style="margin:20px 0 12px;color:#E96B6B">审核决策</h4>
|
||||
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">审核决策</h4>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="处理结果">
|
||||
<el-select v-model="decision" style="width:100%">
|
||||
@@ -84,8 +84,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { token as tokenColor } from '@/theme/tokens'
|
||||
|
||||
const loading = ref(false)
|
||||
const processing = ref(false)
|
||||
@@ -108,7 +110,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/appeals/pending', {}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.data || []
|
||||
loadStats()
|
||||
@@ -118,15 +120,15 @@ async function loadData() {
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/appeals/stats', {}, { Authorization: `Bearer ${token}` })
|
||||
const data = res.data || {}
|
||||
stats.value = [
|
||||
{ key: 'pending', label: '待审核', value: data.pendingCount || 0, color: '#E96B6B' },
|
||||
{ key: 'reviewing', label: '审核中', value: data.reviewingCount || 0, color: '#E59A32' },
|
||||
{ key: 'approved', label: '已通过', value: data.approvedCount || 0, color: '#67C23A' },
|
||||
{ key: 'rejected', label: '已驳回', value: data.rejectedCount || 0, color: '#909399' },
|
||||
{ key: 'escalated', label: '已升级', value: data.escalatedCount || 0, color: '#F56C6C' }
|
||||
{ key: 'pending', label: '待审核', value: data.pendingCount || 0, color: tokenColor('--color-primary') },
|
||||
{ key: 'reviewing', label: '审核中', value: data.reviewingCount || 0, color: tokenColor('--color-warning') },
|
||||
{ key: 'approved', label: '已通过', value: data.approvedCount || 0, color: tokenColor('--color-success') },
|
||||
{ key: 'rejected', label: '已驳回', value: data.rejectedCount || 0, color: tokenColor('--color-neutral') },
|
||||
{ key: 'escalated', label: '已升级', value: data.escalatedCount || 0, color: tokenColor('--color-danger') }
|
||||
]
|
||||
} catch {}
|
||||
}
|
||||
@@ -137,7 +139,7 @@ async function openDetail(id: string) {
|
||||
decision.value = 'approve'
|
||||
decisionNote.value = ''
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get(`/operator/appeals/${id}`, {}, { Authorization: `Bearer ${token}` })
|
||||
detail.value = res.data
|
||||
} catch (e) { console.error(e) }
|
||||
@@ -147,7 +149,7 @@ async function handleProcess() {
|
||||
if (!decision.value) return
|
||||
processing.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
await post(`/operator/appeals/${currentId.value}/process`,
|
||||
{ decision: decision.value, decisionNote: decisionNote.value },
|
||||
{ Authorization: `Bearer ${token}` })
|
||||
@@ -168,8 +170,8 @@ onMounted(loadData)
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-value { font-size: 32px; font-weight: bold; color: #409EFF; }
|
||||
.stat-label { font-size: 13px; color: #666; margin-top: 6px; }
|
||||
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); color: var(--admin-coral-strong); }
|
||||
.stat-label { font-size: var(--font-size-sm); color: var(--color-text-tertiary); margin-top: 6px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.text-muted { color: #BBB; font-size: 13px; }
|
||||
.text-muted { color: var(--color-text-disabled); font-size: var(--font-size-sm); }
|
||||
</style>
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get } from '@/api/client'
|
||||
|
||||
@@ -70,7 +71,7 @@ const ACTION_TEXT: Record<string, string> = {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/audit/logs', {
|
||||
resourceType: resourceFilter.value || undefined,
|
||||
page: page.value,
|
||||
|
||||
@@ -302,21 +302,21 @@ 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-stage { padding: 20px; background: var(--color-bg-subtle); border-radius: 12px; }
|
||||
.preview-card {
|
||||
padding: 20px 18px;
|
||||
border-radius: 16px;
|
||||
background: linear-gradient(135deg, #ffe9df, #fff7f2);
|
||||
background: var(--gradient-brand-soft); /* 还原消费端 Banner 预览底色 */
|
||||
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;
|
||||
background: var(--color-bg-overlay); color: var(--admin-coral-strong); font-size: var(--font-size-xs);
|
||||
}
|
||||
.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-title { margin-top: 10px; font-size: var(--font-size-xl); font-weight: var(--font-weight-bold); color: var(--color-text-primary); }
|
||||
.preview-subtitle { margin-top: 6px; font-size: var(--font-size-xs); line-height: 1.6; color: var(--color-text-tertiary); }
|
||||
.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;
|
||||
background: var(--gradient-brand-vivid); color: var(--color-text-inverse); font-size: var(--font-size-xs); font-weight: var(--font-weight-semibold); min-height: var(--tap-control); /* 还原消费端 Banner 按钮外观预览 */
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -253,5 +253,5 @@ onMounted(() => { loadCategories(); loadArticles() })
|
||||
|
||||
<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; }
|
||||
.field-hint { width: 100%; font-size: var(--font-size-xs); color: var(--el-text-color-secondary); line-height: 1.5; }
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
<el-card>
|
||||
<template #header><div class="card-header"><span>公益红娘管理</span></div></template>
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="服务请求队列" name="services">
|
||||
<!-- ZC-MM-002:服务请求队列仅分配权持有者可见(matchmaker.assign 或 matchmaker.team.assign) -->
|
||||
<el-tab-pane v-if="canAssign" 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">
|
||||
@@ -19,7 +20,7 @@
|
||||
<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>
|
||||
<el-button v-if="row.status==='assigned' || row.status==='in_service'" size="small" @click="openRecommendation(row)">创建推荐</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@@ -38,16 +39,77 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
|
||||
<!-- ZC-MM-002 新增:服务对象分配(本人名下生效中的分配 + 转分配/撤销/结束) -->
|
||||
<el-tab-pane label="服务对象分配" name="assignments">
|
||||
<el-table :data="assignments" stripe v-loading="assignmentsLoading">
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="assignmentScope" label="范围" width="90">
|
||||
<template #default="{ row }">{{ assignmentScopeLabel(row.assignmentScope) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assignmentReason" label="分配理由" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="90">
|
||||
<template #default="{ row }"><el-tag :type="assignmentStatusTag(row.status)">{{ assignmentStatusLabel(row.status) }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="assignedAt" label="分配时间" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.assignedAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="expiresAt" label="有效期至" width="170">
|
||||
<template #default="{ row }">{{ row.expiresAt ? formatDateTime(row.expiresAt) : '长期' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="primary" @click="openTransfer(row)">转分配</el-button>
|
||||
<el-button size="small" @click="openTerminate(row, 'revoke')">撤销</el-button>
|
||||
<el-button size="small" @click="openTerminate(row, 'end')">结束</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-tab-pane>
|
||||
<!-- ZC-MM-003 / B1 立项:受控联系授权(审批岗队列 + 本人授权/撤销) -->
|
||||
<el-tab-pane label="联系授权审批" name="contactAuths">
|
||||
<el-table :data="contactAuths" stripe v-loading="contactAuthsLoading">
|
||||
<el-table-column prop="operatorId" label="红娘ID" width="120" />
|
||||
<el-table-column prop="userId" label="用户ID" width="120" />
|
||||
<el-table-column prop="channel" label="渠道" width="110">
|
||||
<template #default="{ row }">{{ contactChannelLabel(row.channel) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="purpose" label="授权目的" show-overflow-tooltip />
|
||||
<el-table-column prop="status" label="状态" width="110">
|
||||
<template #default="{ row }"><el-tag :type="contactStatusTag(row.status)">{{ contactStatusLabel(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 prop="expiresAt" label="窗口/有效期至" width="170">
|
||||
<template #default="{ row }">{{ formatDateTime(row.expiresAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="220">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'pending_approval' && canApprove">
|
||||
<el-button size="small" type="primary" @click="doContactApprove(row)">批准</el-button>
|
||||
<el-button size="small" type="danger" @click="doContactReject(row)">拒绝</el-button>
|
||||
</template>
|
||||
<el-button v-if="['active','pending_approval','pending_consent'].includes(row.status)"
|
||||
size="small" @click="doContactRevoke(row)">撤销</el-button>
|
||||
</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>
|
||||
<!-- 分配弹窗(ZC-MM-002:去硬编码红娘名单,改为加载可分配红娘接口;理由必填) -->
|
||||
<el-dialog v-model="assignDialogVisible" title="分配红娘服务" width="440px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="红娘">
|
||||
<el-select v-model="assignForm.matchmakerOperatorId" placeholder="选择红娘" style="width: 100%">
|
||||
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="分配理由" required>
|
||||
<el-input v-model="assignForm.reason" type="textarea" :rows="2" placeholder="说明分配依据(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="assignDialogVisible=false">取消</el-button>
|
||||
@@ -55,6 +117,37 @@
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 转分配弹窗(ZC-MM-002:服务对象分配) -->
|
||||
<el-dialog v-model="transferDialogVisible" title="转分配服务对象" width="440px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="新红娘">
|
||||
<el-select v-model="transferForm.newMatchmakerOperatorId" placeholder="选择接手红娘" style="width: 100%">
|
||||
<el-option v-for="op in assignableMatchmakers" :key="op.id" :label="op.name" :value="op.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="转分配理由" required>
|
||||
<el-input v-model="transferForm.reason" type="textarea" :rows="2" placeholder="说明转分配依据(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="transferDialogVisible=false">取消</el-button>
|
||||
<el-button type="primary" @click="doTransfer">确认转分配</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 撤销/结束弹窗(ZC-MM-002:服务对象分配) -->
|
||||
<el-dialog v-model="terminateDialogVisible" :title="terminateAction==='revoke' ? '撤销服务对象分配' : '结束服务对象分配'" width="440px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item :label="terminateAction==='revoke' ? '撤销理由' : '结束理由'" required>
|
||||
<el-input v-model="terminateForm.reason" type="textarea" :rows="2" placeholder="说明理由(必填)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="terminateDialogVisible=false">取消</el-button>
|
||||
<el-button type="danger" @click="doTerminate">确认</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 创建推荐弹窗 -->
|
||||
<el-dialog v-model="recDialogVisible" title="创建红娘推荐" width="480px">
|
||||
<el-form label-width="100px">
|
||||
@@ -71,18 +164,35 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
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 activeTab = ref('services')
|
||||
const authStore = useAuthStore()
|
||||
// ZC-MM-002:分配权 = matchmaker.assign(平台授权)或 matchmaker.team.assign(红娘组长)
|
||||
const canAssign = computed(() => {
|
||||
const perms: string[] = authStore.permissions || []
|
||||
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
|
||||
})
|
||||
|
||||
const activeTab = ref(canAssign.value ? 'services' : 'assignments')
|
||||
const loading = ref(false)
|
||||
const services = ref<any[]>([])
|
||||
const recommendations = ref<any[]>([])
|
||||
const assignments = ref<any[]>([])
|
||||
const assignmentsLoading = ref(false)
|
||||
const assignableMatchmakers = ref<any[]>([])
|
||||
const assignDialogVisible = ref(false)
|
||||
const recDialogVisible = ref(false)
|
||||
const transferDialogVisible = ref(false)
|
||||
const terminateDialogVisible = ref(false)
|
||||
const currentServiceId = ref('')
|
||||
const assignForm = ref({ operatorId: '' })
|
||||
const currentUserId = ref('')
|
||||
const currentAssignmentId = ref('')
|
||||
const terminateAction = ref<'revoke' | 'end'>('revoke')
|
||||
const assignForm = ref({ matchmakerOperatorId: '', reason: '' })
|
||||
const transferForm = ref({ newMatchmakerOperatorId: '', reason: '' })
|
||||
const terminateForm = ref({ reason: '' })
|
||||
const recForm = ref({ userId: '', targetUserId: '', reason: '' })
|
||||
|
||||
const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
@@ -90,10 +200,12 @@ const SERVICE_TYPE_LABEL: Record<string, string> = {
|
||||
referral_activity: '转介活动', referral_station: '转介服务站'
|
||||
}
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
submitted: '待分配', assigned: '已分配', processing: '处理中', completed: '已完成', cancelled: '已取消'
|
||||
submitted: '待分配', assigned: '已分配', in_service: '服务中', waiting_user: '等待用户',
|
||||
processing: '处理中', completed: '已完成', cancelled: '已取消'
|
||||
}
|
||||
const STATUS_TAG: Record<string, string> = {
|
||||
submitted: 'warning', assigned: 'primary', processing: '', completed: 'success', cancelled: 'danger'
|
||||
submitted: 'warning', assigned: 'primary', in_service: 'primary', waiting_user: 'warning',
|
||||
processing: '', completed: 'success', cancelled: 'danger'
|
||||
}
|
||||
const REC_STATUS_LABEL: Record<string, string> = {
|
||||
pending_view: '待用户查看', viewed: '已查看', liked: '已喜欢',
|
||||
@@ -103,18 +215,79 @@ const REC_STATUS_TAG: Record<string, string> = {
|
||||
pending_view: 'warning', viewed: 'info', liked: 'primary',
|
||||
passed: 'info', confirmed_mutual: 'success', rejected: 'danger', expired: 'info'
|
||||
}
|
||||
const ASSIGNMENT_STATUS_LABEL: Record<string, string> = {
|
||||
active: '生效中', ended: '已结束', revoked: '已撤销', transferred: '已转分配', expired: '已过期'
|
||||
}
|
||||
const ASSIGNMENT_STATUS_TAG: Record<string, string> = {
|
||||
active: 'success', ended: 'info', revoked: 'danger', transferred: 'info', expired: 'info'
|
||||
}
|
||||
|
||||
// ===== ZC-MM-003 / B1:受控联系授权(枚举码不上屏,中文映射) =====
|
||||
const CONTACT_CHANNEL_LABEL: Record<string, string> = {
|
||||
phone: '电话联系', offline_meeting: '线下见面', full_contact: '完整联系方式'
|
||||
}
|
||||
const CONTACT_STATUS_LABEL: Record<string, string> = {
|
||||
pending_approval: '待审批', pending_consent: '待用户确认', active: '生效中',
|
||||
rejected: '已拒绝', expired: '已过期', revoked: '已撤销'
|
||||
}
|
||||
const CONTACT_STATUS_TAG: Record<string, string> = {
|
||||
pending_approval: 'warning', pending_consent: 'warning', active: 'success',
|
||||
rejected: 'danger', expired: 'info', revoked: '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 assignmentStatusLabel(s: string) { return ASSIGNMENT_STATUS_LABEL[s] || s }
|
||||
function assignmentStatusTag(s: string) { return ASSIGNMENT_STATUS_TAG[s] || 'info' }
|
||||
function assignmentScopeLabel(s: string) { return s === 'primary' ? '主责' : '协同' }
|
||||
function formatDateTime(d: string) { return d ? new Date(d).toLocaleString('zh-CN') : '-' }
|
||||
function contactChannelLabel(s: string) { return CONTACT_CHANNEL_LABEL[s] || s }
|
||||
function contactStatusLabel(s: string) { return CONTACT_STATUS_LABEL[s] || s }
|
||||
function contactStatusTag(s: string) { return CONTACT_STATUS_TAG[s] || 'info' }
|
||||
|
||||
// ===== ZC-MM-003 / B1:受控联系授权 =====
|
||||
// 审批岗客户端可见性提示(真实判定在 Service 层动态执行:principal 权限 ∈ approver-authorities 配置)
|
||||
const canApprove = computed(() => {
|
||||
const perms: string[] = authStore.permissions || []
|
||||
return perms.includes('matchmaker.assign') || perms.includes('matchmaker.team.assign')
|
||||
})
|
||||
const contactAuths = ref<any[]>([])
|
||||
const contactAuthsLoading = ref(false)
|
||||
|
||||
async function loadContactAuths() {
|
||||
contactAuthsLoading.value = true
|
||||
try { const res: any = await get('/operator/matchmaker/contact-authorizations/my'); contactAuths.value = res?.data?.list || [] } finally { contactAuthsLoading.value = false }
|
||||
}
|
||||
async function doContactApprove(row: any) {
|
||||
try {
|
||||
await post(`/operator/matchmaker/contact-authorizations/${row.id}/approve`, {})
|
||||
ElMessage.success('已批准,等待用户单独确认'); loadContactAuths()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function doContactReject(row: any) {
|
||||
try {
|
||||
await post(`/operator/matchmaker/contact-authorizations/${row.id}/reject`, {})
|
||||
ElMessage.success('已拒绝'); loadContactAuths()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
async function doContactRevoke(row: any) {
|
||||
try {
|
||||
const { value } = await ElMessageBox.prompt('撤销理由(必填)', '撤销联系授权', {
|
||||
confirmButtonText: '确认撤销', cancelButtonText: '取消',
|
||||
inputValidator: (v: string) => (v && v.trim() ? true : '撤销理由必填'),
|
||||
})
|
||||
await post(`/operator/matchmaker/contact-authorizations/${row.id}/revoke`, { reason: value.trim() })
|
||||
ElMessage.success('已撤销'); loadContactAuths()
|
||||
} catch { /* 用户取消 */ }
|
||||
}
|
||||
|
||||
async function loadServices() {
|
||||
loading.value = true
|
||||
// Phase 13 契约修复:原 `/matchmaker/services/operator/queue` 后端不存在(404)。
|
||||
// 后端实际为 GET /operator/matchmaker/pending(待分配队列)。
|
||||
// 后端实际为 GET /operator/matchmaker/pending(待分配队列,ZC-MM-002 后仅分配权持有者可调)。
|
||||
try { const res: any = await get('/operator/matchmaker/pending'); services.value = res?.data || [] } finally { loading.value = false }
|
||||
}
|
||||
async function loadRecommendations() {
|
||||
@@ -123,19 +296,81 @@ async function loadRecommendations() {
|
||||
// 后端新增 GET /operator/matchmaker/recommendations(限本人名下服务的推荐)。
|
||||
try { const res: any = await get('/operator/matchmaker/recommendations'); recommendations.value = res?.data || [] } finally { loading.value = false }
|
||||
}
|
||||
async function loadAssignments() {
|
||||
assignmentsLoading.value = true
|
||||
// ZC-MM-002:本人名下生效中的服务对象分配
|
||||
try { const res: any = await get('/operator/matchmaker/assignments/my'); assignments.value = res?.data || [] } finally { assignmentsLoading.value = false }
|
||||
}
|
||||
async function loadAssignableMatchmakers() {
|
||||
// ZC-MM-002:去硬编码红娘名单,改为后端可分配红娘接口(组长按组织过滤、平台授权人员全部)
|
||||
try { const res: any = await get('/operator/matchmaker/assignments/assignable-matchmakers'); assignableMatchmakers.value = res?.data || [] } catch { assignableMatchmakers.value = [] }
|
||||
}
|
||||
async function refresh() {
|
||||
if (activeTab.value === 'services') await loadServices(); else await loadRecommendations()
|
||||
if (activeTab.value === 'services') await loadServices()
|
||||
else if (activeTab.value === 'assignments') await loadAssignments()
|
||||
else if (activeTab.value === 'contactAuths') await loadContactAuths()
|
||||
else await loadRecommendations()
|
||||
}
|
||||
|
||||
async function openAssign(row: any) { currentServiceId.value = row.id; assignForm.value.operatorId = ''; assignDialogVisible.value = true }
|
||||
async function openAssign(row: any) {
|
||||
// /pending 返回字段为 serviceId(Phase 13 契约),兜底 row.id
|
||||
currentServiceId.value = row.serviceId || row.id
|
||||
currentUserId.value = row.userId
|
||||
assignForm.value = { matchmakerOperatorId: '', reason: '' }
|
||||
await loadAssignableMatchmakers()
|
||||
assignDialogVisible.value = true
|
||||
}
|
||||
async function doAssign() {
|
||||
// Phase 13 契约修复:原 PUT /matchmaker/services/{id}/assign → 后端为 POST /operator/matchmaker/services/{id}/assign
|
||||
if (!assignForm.value.matchmakerOperatorId) { ElMessage.warning('请选择红娘'); return }
|
||||
if (!assignForm.value.reason || !assignForm.value.reason.trim()) { ElMessage.warning('分配理由必填'); return }
|
||||
// ZC-MM-002:分配走统一分配接口(创建 assignment + 服务单联动置 assigned,同事务)
|
||||
try {
|
||||
await post(`/operator/matchmaker/services/${currentServiceId.value}/assign`, { operatorId: assignForm.value.operatorId })
|
||||
await post('/operator/matchmaker/assignments', {
|
||||
userId: currentUserId.value,
|
||||
matchmakerOperatorId: assignForm.value.matchmakerOperatorId,
|
||||
serviceId: currentServiceId.value,
|
||||
reason: assignForm.value.reason.trim()
|
||||
})
|
||||
ElMessage.success('分配成功'); assignDialogVisible.value = false; loadServices()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
async function openTransfer(row: any) {
|
||||
currentAssignmentId.value = row.id
|
||||
transferForm.value = { newMatchmakerOperatorId: '', reason: '' }
|
||||
await loadAssignableMatchmakers()
|
||||
transferDialogVisible.value = true
|
||||
}
|
||||
async function doTransfer() {
|
||||
if (!transferForm.value.newMatchmakerOperatorId) { ElMessage.warning('请选择接手红娘'); return }
|
||||
if (!transferForm.value.reason || !transferForm.value.reason.trim()) { ElMessage.warning('转分配理由必填'); return }
|
||||
try {
|
||||
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/transfer`, {
|
||||
newMatchmakerOperatorId: transferForm.value.newMatchmakerOperatorId,
|
||||
reason: transferForm.value.reason.trim()
|
||||
})
|
||||
ElMessage.success('转分配成功'); transferDialogVisible.value = false; loadAssignments()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
function openTerminate(row: any, action: 'revoke' | 'end') {
|
||||
currentAssignmentId.value = row.id
|
||||
terminateAction.value = action
|
||||
terminateForm.value = { reason: '' }
|
||||
terminateDialogVisible.value = true
|
||||
}
|
||||
async function doTerminate() {
|
||||
if (!terminateForm.value.reason || !terminateForm.value.reason.trim()) {
|
||||
ElMessage.warning(terminateAction.value === 'revoke' ? '撤销理由必填' : '结束理由必填'); return
|
||||
}
|
||||
try {
|
||||
await post(`/operator/matchmaker/assignments/${currentAssignmentId.value}/${terminateAction.value}`, {
|
||||
reason: terminateForm.value.reason.trim()
|
||||
})
|
||||
ElMessage.success('操作成功'); terminateDialogVisible.value = false; loadAssignments()
|
||||
} catch (e: any) { ElMessage.error(e?.response?.data?.message || '操作失败') }
|
||||
}
|
||||
|
||||
function openRecommendation(row: any) {
|
||||
currentServiceId.value = row.id
|
||||
recForm.value = { userId: row.userId, targetUserId: '', reason: '' }
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post, put } from '@/api/client'
|
||||
|
||||
@@ -79,7 +80,7 @@ const orgMap = ref<Record<string, string>>({})
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/operators', { page: page.value, pageSize: pageSize.value }, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.data?.list || []
|
||||
total.value = res.data?.total || 0
|
||||
@@ -89,7 +90,7 @@ async function loadData() {
|
||||
|
||||
async function loadOrgs() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/operators/organizations', {}, { Authorization: `Bearer ${token}` })
|
||||
orgList.value = res.data || []
|
||||
orgList.value.forEach(o => { orgMap.value[o.id] = o.name })
|
||||
@@ -105,7 +106,7 @@ async function save() {
|
||||
if (!form.value.username || !form.value.password) return
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
await post('/operator/operators', form.value, { Authorization: `Bearer ${token}` })
|
||||
formVisible.value = false
|
||||
loadData()
|
||||
@@ -115,7 +116,7 @@ async function save() {
|
||||
|
||||
async function toggleStatus(row: any) {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const action = row.status === 'normal' ? 'disable' : 'enable'
|
||||
await put(`/operator/operators/${row.id}/${action}`, {}, { Authorization: `Bearer ${token}` })
|
||||
loadData()
|
||||
@@ -126,7 +127,7 @@ async function resetPwd(id: string) {
|
||||
const pwd = prompt('请输入新密码(至少8位)')
|
||||
if (!pwd || pwd.length < 8) return
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
await put(`/operator/operators/${id}/reset-password`, { newPassword: pwd }, { Authorization: `Bearer ${token}` })
|
||||
} catch (e) { console.error(e) }
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post, put } from '@/api/client'
|
||||
|
||||
@@ -78,7 +79,7 @@ const ORG_TYPE_TEXT: Record<string, string> = {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/organizations', {}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.data || []
|
||||
} catch (e) { console.error(e) }
|
||||
@@ -101,7 +102,7 @@ async function save() {
|
||||
if (!form.value.name) return
|
||||
saving.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
if (editId.value) {
|
||||
await put(`/operator/organizations/${editId.value}`, form.value, {
|
||||
Authorization: `Bearer ${token}`
|
||||
@@ -119,7 +120,7 @@ async function save() {
|
||||
|
||||
async function toggleStatus(row: any) {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const action = row.status === 'normal' ? 'disable' : 'enable'
|
||||
await put(`/operator/organizations/${row.id}/${action}`, {}, {
|
||||
Authorization: `Bearer ${token}`
|
||||
|
||||
@@ -284,5 +284,5 @@ onMounted(loadPage)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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}}
|
||||
.page-container{padding:28px}.page-kicker{color:var(--admin-primary);font-size:var(--font-size-xs);font-weight:var(--font-weight-semibold);letter-spacing:1px}.stat-tags{display:flex;gap:8px}.review-panel{padding:20px 24px}.muted{color:var(--color-text-tertiary);font-size:var(--font-size-xs)}.drawer-loading{min-height:240px}.drawer-heading{margin:24px 0 12px;font-size:var(--font-size-lg)}.full-width{width:100%}.review-actions{display:flex;flex-wrap:wrap;gap:8px}@media(max-width:760px){.page-container{padding:18px}}
|
||||
</style>
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
<el-descriptions-item label="补充说明" :span="2">{{ detail.report?.description || '无' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 style="margin:20px 0 12px;color:#E96B6B">被举报人风险画像</h4>
|
||||
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">被举报人风险画像</h4>
|
||||
<el-descriptions :column="2" border v-if="detail.riskProfile">
|
||||
<el-descriptions-item label="用户">{{ detail.riskProfile.displayName }}</el-descriptions-item>
|
||||
<el-descriptions-item label="风险分数">{{ detail.riskProfile.totalRiskScore }}</el-descriptions-item>
|
||||
@@ -72,7 +72,7 @@
|
||||
<el-descriptions-item label="历史举报数">{{ detail.riskProfile.totalReportsCount }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<h4 style="margin:20px 0 12px;color:#E96B6B">处置操作</h4>
|
||||
<h4 style="margin:20px 0 12px;color:var(--admin-coral-strong)">处置操作</h4>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="处理结果">
|
||||
<el-select v-model="processResult" style="width:100%">
|
||||
@@ -98,8 +98,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getAccessToken } from '@/utils/token'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post } from '@/api/client'
|
||||
import { token as tokenColor } from '@/theme/tokens'
|
||||
|
||||
const loading = ref(false)
|
||||
const processing = ref(false)
|
||||
@@ -123,7 +125,7 @@ const STATUS_MAP: Record<string, { text: string; type: string }> = {
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get(`/operator/risk/reports/${statusFilter.value}`, {}, { Authorization: `Bearer ${token}` })
|
||||
tableData.value = res.data || []
|
||||
loadStats()
|
||||
@@ -133,14 +135,14 @@ async function loadData() {
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get('/operator/risk/reports/stats', {}, { Authorization: `Bearer ${token}` })
|
||||
const data = res.data || {}
|
||||
stats.value = [
|
||||
{ key: 'pending', label: '待处理', value: data.pendingCount || 0, color: '#E96B6B' },
|
||||
{ key: 'processing', label: '处理中', value: data.processingCount || 0, color: '#E59A32' },
|
||||
{ key: 'resolved', label: '已处理', value: data.resolvedCount || 0, color: '#67C23A' },
|
||||
{ key: 'dismissed', label: '已驳回', value: data.dismissedCount || 0, color: '#909399' }
|
||||
{ key: 'pending', label: '待处理', value: data.pendingCount || 0, color: tokenColor('--color-primary') },
|
||||
{ key: 'processing', label: '处理中', value: data.processingCount || 0, color: tokenColor('--color-warning') },
|
||||
{ key: 'resolved', label: '已处理', value: data.resolvedCount || 0, color: tokenColor('--color-success') },
|
||||
{ key: 'dismissed', label: '已驳回', value: data.dismissedCount || 0, color: tokenColor('--color-neutral') }
|
||||
]
|
||||
} catch {}
|
||||
}
|
||||
@@ -151,7 +153,7 @@ async function openDetail(reportId: string) {
|
||||
processResult.value = 'no_action'
|
||||
processNote.value = ''
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
const res: any = await get(`/operator/risk/reports/${reportId}`, {}, { Authorization: `Bearer ${token}` })
|
||||
detail.value = res.data
|
||||
} catch (e) { console.error(e) }
|
||||
@@ -161,7 +163,7 @@ async function handleProcess() {
|
||||
if (!processResult.value) return
|
||||
processing.value = true
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken')
|
||||
const token = getAccessToken()
|
||||
await post(`/operator/risk/reports/${currentReportId.value}/process`,
|
||||
{ processResult: processResult.value, processNote: processNote.value },
|
||||
{ Authorization: `Bearer ${token}` })
|
||||
@@ -188,7 +190,7 @@ onMounted(loadData)
|
||||
<style scoped>
|
||||
.page-container { padding: 24px; }
|
||||
.stat-card { text-align: center; }
|
||||
.stat-value { font-size: 36px; font-weight: bold; }
|
||||
.stat-label { font-size: 14px; color: #666; margin-top: 8px; }
|
||||
.stat-value { font-size: var(--font-size-5xl); font-weight: var(--font-weight-bold); }
|
||||
.stat-label { font-size: var(--font-size-base); color: var(--color-text-tertiary); margin-top: 8px; }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
</style>
|
||||
|
||||
@@ -348,6 +348,6 @@ onMounted(loadStations)
|
||||
.form-hint {
|
||||
margin-left: 10px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<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" />
|
||||
<el-progress class="completion" type="dashboard" :percentage="user.profileCompletion || 0" :width="72" :stroke-width="7" color="var(--admin-coral-solid)" />
|
||||
</div>
|
||||
<el-tabs v-model="activeTab" class="detail-tabs">
|
||||
<el-tab-pane label="基础信息" name="basic">
|
||||
@@ -129,5 +129,5 @@ function formatTime(time?: string) { return time ? new Date(time).toLocaleString
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
<style scoped>
|
||||
.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}}
|
||||
.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:var(--color-text-inverse);background:var(--admin-coral-solid)}.identity-summary h2{font-size:var(--font-size-2xl)}.identity-summary span{display:block;margin-top:6px;color:var(--color-text-tertiary);font-size:var(--font-size-xs)}.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:var(--font-size-4xl)}.danger-zone{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-top:20px;padding:18px;border:1px solid var(--color-border-danger);border-radius:12px}.danger-zone h3{display:flex;align-items:center;gap:7px;color:var(--color-danger-strong)}.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>
|
||||
|
||||
@@ -206,8 +206,8 @@ onMounted(loadData)
|
||||
|
||||
.page-kicker {
|
||||
color: var(--admin-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
|
||||
@@ -37,19 +37,24 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'service-appointment', name: 'ServiceAppointment', component: () => import('@/modules/service-appointment/ServiceAppointmentView.vue'), meta: { title: '服务预约', permissions: ['service_appointment.view'] } },
|
||||
{ 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'] } },
|
||||
{ path: 'referral', name: 'ReferralManagement', component: () => import('@/modules/referral/ReferralManagementView.vue'), meta: { title: '转介管理', permissions: ['matchmaker.service'] } },
|
||||
{ path: 'matchmaker', name: 'MatchmakerManagement', component: () => import('@/modules/matchmaker/MatchmakerManagementView.vue'), meta: { title: '红娘管理', permissions: ['matchmaker.view'] } },
|
||||
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({ history: createWebHistory(), routes })
|
||||
// history 的 base 必须与 vite 的 base 保持一致(生产构建为 /admin/)。
|
||||
// 否则部署在 /admin/ 子路径下时,刷新或直达 /admin/xxx 会因路由匹配基准不符而白屏。
|
||||
const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes })
|
||||
|
||||
router.beforeEach((to) => {
|
||||
router.beforeEach(async (to) => {
|
||||
const authStore = useAuthStore()
|
||||
const requiredPerms = (to.meta.permissions as string[]) || []
|
||||
|
||||
// 0. 页面刷新后内存态清空,先凭 HttpOnly refresh Cookie 静默恢复会话(无则跳登录)
|
||||
await authStore.initializeSession()
|
||||
|
||||
// 1. 登录检查
|
||||
if (to.path !== '/login' && !authStore.isLoggedIn) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { post } from '@/api/client'
|
||||
import { setAccessToken, clearAccessToken } from '@/utils/token'
|
||||
|
||||
interface OperatorInfo {
|
||||
operatorId: string
|
||||
@@ -12,9 +13,9 @@ interface OperatorInfo {
|
||||
|
||||
const previewRolePermissions: Record<string, string[]> = {
|
||||
reviewer: ['review.view', 'user.view'],
|
||||
counselor: ['counseling.service.view', 'referral.manage'],
|
||||
counselor: ['counseling.service.view', 'matchmaker.service'],
|
||||
station: ['service_location.view', 'service_appointment.view'],
|
||||
operator: ['review.view', 'user.view', 'organization.manage', 'permission.manage', 'activity.review_registration', 'risk.report.view', 'risk.report.process', 'analytics.view', 'banner.view', 'content.view', 'service_location.view', 'service_appointment.view', 'service_program.manage', 'counseling.service.view', 'referral.manage'],
|
||||
operator: ['review.view', 'user.view', 'organization.manage', 'permission.manage', 'activity.review_registration', 'risk.report.view', 'risk.report.process', 'analytics.view', 'banner.view', 'content.view', 'service_location.view', 'service_appointment.view', 'service_program.manage', 'counseling.service.view', 'matchmaker.view', 'matchmaker.service'],
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
@@ -23,38 +24,70 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const previewOperator: OperatorInfo | null = previewRolePermissions[previewRole]
|
||||
? { operatorId: 'ui-preview', name: '界面验收员', username: 'preview', permissions: previewRolePermissions[previewRole], organizationId: null }
|
||||
: null
|
||||
const token = ref<string>(previewOperator ? 'ui-preview-only' : (localStorage.getItem('accessToken') || ''))
|
||||
const operator = ref<OperatorInfo | null>(
|
||||
previewOperator || JSON.parse(localStorage.getItem('operator') || 'null')
|
||||
)
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value)
|
||||
const token = ref<string>(previewOperator ? 'ui-preview-only' : '')
|
||||
const operator = ref<OperatorInfo | null>(previewOperator)
|
||||
let sessionInitialized = false
|
||||
|
||||
const isLoggedIn = computed(() => !!token.value && !!operator.value)
|
||||
const permissions = computed(() => operator.value?.permissions || [])
|
||||
// ZC-MM-002:最小只读 helper —— 组件按权限码做 UI 显隐(与后端 @PreAuthorize 同码)
|
||||
const hasPermission = (code: string) => permissions.value.includes(code)
|
||||
|
||||
function applyAuthData(data: any) {
|
||||
token.value = data.accessToken || ''
|
||||
operator.value = {
|
||||
operatorId: data.operatorId,
|
||||
name: data.name,
|
||||
username: data.username,
|
||||
permissions: data.permissions || [],
|
||||
organizationId: data.organizationId ?? null,
|
||||
}
|
||||
// 同步内存态令牌(供 axios 拦截器注入 Authorization 头;生产同源场景 Cookie 也会自动携带)
|
||||
setAccessToken(token.value)
|
||||
}
|
||||
|
||||
async function login(username: string, password: string) {
|
||||
const res: any = await post('/auth/operator/login', { username, password })
|
||||
if (res.success) {
|
||||
token.value = res.data.accessToken
|
||||
operator.value = {
|
||||
operatorId: res.data.operatorId,
|
||||
name: res.data.name,
|
||||
username: res.data.username,
|
||||
permissions: res.data.permissions || [],
|
||||
organizationId: res.data.organizationId ?? null,
|
||||
}
|
||||
localStorage.setItem('accessToken', res.data.accessToken)
|
||||
localStorage.setItem('operator', JSON.stringify(operator.value))
|
||||
applyAuthData(res.data)
|
||||
return res.data
|
||||
}
|
||||
throw new Error(res.message || '登录失败')
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token.value = ''
|
||||
operator.value = null
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('operator')
|
||||
/**
|
||||
* 静默恢复会话:页面刷新后内存态清空,凭 HttpOnly refresh Cookie(dy_op_rt)刷新令牌,
|
||||
* 无需用户重新登录。refresh token 不落地、不可被 JS 读取,抗 XSS。
|
||||
* 开发跨端口代理下 Cookie 不共享,刷新将失败并回落到登录页(符合预期)。
|
||||
*/
|
||||
async function initializeSession(): Promise<boolean> {
|
||||
if (sessionInitialized) return isLoggedIn.value
|
||||
sessionInitialized = true
|
||||
if (token.value) return true
|
||||
try {
|
||||
const res: any = await post('/auth/operator/refresh', {})
|
||||
if (res.success) {
|
||||
applyAuthData(res.data)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// 未登录或 refresh 失效,交由路由守卫跳转登录页
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return { token, operator, isLoggedIn, permissions, login, logout }
|
||||
function logout() {
|
||||
// 尽力通知后端注销并清除 Cookie(best-effort)
|
||||
try {
|
||||
post('/auth/operator/logout', {}).catch(() => {})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
token.value = ''
|
||||
operator.value = null
|
||||
clearAccessToken()
|
||||
}
|
||||
|
||||
return { token, operator, isLoggedIn, permissions, hasPermission, login, logout, initializeSession }
|
||||
})
|
||||
|
||||
@@ -1,93 +1,412 @@
|
||||
/* ============================================================================
|
||||
* 管理端设计系统 · v2
|
||||
*
|
||||
* 本文件是管理端唯一的设计 token 事实源。24 个 .vue 的 <style> 只允许通过
|
||||
* var(--token) 取色 / 取字号 / 取间距,不应出现字面色值(由门控脚本
|
||||
* scripts/lint-admin-tokens.mjs 拦截)。
|
||||
*
|
||||
* 立此版本的原因(2026-09-22 审计实测,不是主观判断):
|
||||
* · 97 处硬编码颜色 / 67 种色值:6 种近似粉底(#FFF0F1 #FFF3F2 #FFF6F6
|
||||
* #FFF7F7 #FFF8F7 #FFFAFA)、6 种近似浅灰边框、大量灰阶碎片。
|
||||
* · 88 处硬编码字号 / 23 种档位,其中 20 处 ≤11px(9px 见于 .metric-copy em、
|
||||
* .activity-status、.todo-copy small),低于可读下限。
|
||||
* · --color-text-tertiary(#888888) 白底 3.54:1、--color-warning(#E59A32) 2.33:1、
|
||||
* --admin-coral(#F45F68) 3.14:1 —— 在各自用法下均不达 WCAG AA 的 4.5:1。
|
||||
* · Element Plus 的 --el-color-*-light-N / -dark-2 / -rgb 是**编译期静态生成**的,
|
||||
* 项目只覆盖了 --el-color-primary,于是开关、滑块、焦点环、分页、标签等
|
||||
* 组件仍是默认蓝 —— 品牌色与 EP 蓝混用。
|
||||
*
|
||||
* 三条硬规则:
|
||||
* 1) 品牌色三级分化:基础色只做色块/渐变/图形(不承载文字);
|
||||
* -strong 做白底文字;-solid 做实心底压白字;两者均实算 ≥4.5:1。
|
||||
* 2) 字重只用 400 / 500 / 600 / 700 四档(跨平台缺字重会回退,导致渲染不一致)。
|
||||
* 3) 字号有下限:正文最小 12px,角标/计数不得低于 11px。
|
||||
*
|
||||
* 所有对比度数值均由脚本按 WCAG 相对亮度公式实算(非估算),标注在各 token 后。
|
||||
* ========================================================================== */
|
||||
|
||||
:root {
|
||||
--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;
|
||||
/* ---------------- 品牌色 ---------------- */
|
||||
/* 基础色:仅用于色块 / 渐变 / 图标 / 边框氛围。白底作文字只有 3.14:1,禁止承载文字。 */
|
||||
--admin-coral: #F45F68;
|
||||
--admin-coral-hover: #E5535D;
|
||||
--admin-coral-strong: #C0414A; /* 白底文字 5.13:1 */
|
||||
--admin-coral-solid: #C84A53; /* 实心底压白字 4.60:1 */
|
||||
--admin-coral-solid-hover: #B8434A; /* hover 反而更深更清晰 5.33:1 */
|
||||
--admin-coral-on-dark: #FF969C; /* 侧栏深底文字 6.76:1 */
|
||||
--admin-coral-soft: #FFF0F1; /* 品牌浅底(角标 / 选中态底) */
|
||||
--admin-coral-lighter: #FFF8F7; /* 更浅一档,hover 底 */
|
||||
--admin-coral-border: rgba(244, 95, 104, 0.22);
|
||||
/* 承载白字的品牌渐变(两端均 ≥4.5:1)。基础色渐变见 --gradient-brand-vivid。 */
|
||||
--gradient-brand-solid: linear-gradient(135deg, #C84A53 0%, #B8434A 100%);
|
||||
/* 鲜亮品牌渐变:只允许用在「无文字覆盖」的装饰,或预览态还原(如 Banner 预览)。 */
|
||||
--gradient-brand-vivid: linear-gradient(135deg, #FF7D6E 0%, #FF5C82 100%);
|
||||
--gradient-brand-soft: linear-gradient(135deg, #FFE9DF 0%, #FFF7F2 100%);
|
||||
/* 水平进度条填充(对轨道 --color-primary-light):原 #F56B75→#EF5A65 仅 2.53:1,
|
||||
条与轨道难以区分;改达标渐变后 4.01:1。 */
|
||||
--gradient-brand-bar: linear-gradient(90deg, #C84A53 0%, #B8434A 100%);
|
||||
|
||||
/* 兼容既有引用名(全站仍在用) */
|
||||
--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;
|
||||
--color-warning: #E59A32;
|
||||
--color-danger: #D94A4A;
|
||||
--color-info: #4A7CD4;
|
||||
--color-text-primary: #222222;
|
||||
--color-text-secondary: #555555;
|
||||
--color-text-tertiary: #888888;
|
||||
--admin-primary-strong: var(--admin-coral-strong);
|
||||
--admin-primary-solid: var(--admin-coral-solid);
|
||||
--color-primary: var(--admin-coral);
|
||||
--color-primary-hover: var(--admin-coral-hover);
|
||||
--color-primary-strong: var(--admin-coral-strong);
|
||||
--color-primary-solid: var(--admin-coral-solid);
|
||||
--color-primary-light: var(--admin-coral-soft);
|
||||
--color-primary-lighter: var(--admin-coral-lighter);
|
||||
|
||||
/* ---------------- 语义色五组 ----------------
|
||||
基色做图形/色块;-strong 做文字(实算 ≥4.5:1);-soft 为配套浅底。 */
|
||||
--color-success: #2E9B68; /* 白底 3.50:1 —— 仅图形 */
|
||||
--color-success-strong: #1F7A4F; /* 白底 5.31:1 */
|
||||
--color-success-soft: #EDF8F1;
|
||||
--color-warning: #E59A32; /* 白底 2.33:1 —— 仅图形 */
|
||||
--color-warning-strong: #8F5D0F; /* 白底 5.61:1 */
|
||||
--color-warning-soft: #FFF6E6;
|
||||
--color-danger: #D94A4A; /* 白底 4.18:1 —— 图形 / ≥24px 大字 */
|
||||
--color-danger-strong: #B8353A; /* 白底 5.82:1 */
|
||||
--color-danger-soft: #FFF6F6;
|
||||
--color-info: #4A7CD4; /* 白底 4.10:1 —— 图形 */
|
||||
--color-info-strong: #3A63B0; /* 白底 5.85:1 */
|
||||
--color-info-soft: #EEF5FF;
|
||||
--color-purple: #8B63BC;
|
||||
--color-purple-strong: #6B47A0; /* 白底 6.92:1 */
|
||||
--color-purple-soft: #F4EFFF;
|
||||
--color-neutral: #8A9099;
|
||||
--color-neutral-strong: #656C79; /* 白底 5.28:1 */
|
||||
--color-neutral-soft: #F4F5F7;
|
||||
|
||||
/* ---------------- 文字 ---------------- */
|
||||
--color-text-primary: #1F2328; /* 白底 15.80:1 */
|
||||
--color-text-secondary: #555555; /* 白底 7.46:1 */
|
||||
--color-text-tertiary: #676E7B; /* 白底 5.13:1;浅底 #F5F6F8 上 4.74:1(原 #888 仅 3.54:1) */
|
||||
--color-text-placeholder: #8A9099; /* 3.25:1:占位符按惯例弱于正文一档(EP 默认 2.5:1) */
|
||||
--color-text-disabled: #B9BEC7;
|
||||
--color-text-inverse: #FFFFFF;
|
||||
--color-text-on-dark: #D9E1E6; /* 侧栏深底 10.62:1 */
|
||||
--color-text-on-dark-muted: #8FA1AC; /* 侧栏深底 5.26:1 */
|
||||
--color-icon-muted: #D7DCE0; /* 空态大图标等纯装饰 */
|
||||
|
||||
/* ---------------- 背景 ---------------- */
|
||||
--color-bg-primary: #FFFFFF;
|
||||
--color-bg-secondary: #FFF8F7;
|
||||
--color-bg-page: #FAFAF8;
|
||||
--color-border: #E8E5E2;
|
||||
--color-border-light: #F0EDE9;
|
||||
--color-bg-page: #F7F9FB;
|
||||
--color-bg-secondary: #FAFAFB;
|
||||
--color-bg-subtle: #F5F6F8;
|
||||
--color-bg-input: #F6F7F8;
|
||||
--color-bg-overlay: rgba(255, 255, 255, 0.86);
|
||||
--color-bg-overlay-strong: rgba(255, 255, 255, 0.96);
|
||||
|
||||
/* ---------------- 边框 ---------------- */
|
||||
--color-border: #E4E7EB;
|
||||
--color-border-light: #EDF0F2;
|
||||
--color-border-hairline: #F2F3F4;
|
||||
--color-border-strong: #D7DCE0;
|
||||
--color-border-danger: #F3CCCC;
|
||||
|
||||
/* ---------------- 侧栏(深底) ---------------- */
|
||||
--admin-sidebar-bg: #202D38;
|
||||
--admin-sidebar-bg-deep: #192733;
|
||||
--admin-sidebar-gradient: linear-gradient(180deg, #202D38 0%, #192733 100%);
|
||||
--admin-sidebar-surface: #2B3A46;
|
||||
--admin-sidebar-text: #D9E1E6;
|
||||
--admin-sidebar-muted: #8FA1AC;
|
||||
--admin-sidebar-hairline: rgba(255, 255, 255, 0.08);
|
||||
--admin-sidebar-hairline-soft: rgba(255, 255, 255, 0.06);
|
||||
|
||||
/* ---------------- 壳与栅格 ---------------- */
|
||||
--admin-shell-bg: rgba(255, 255, 255, 0.96);
|
||||
--admin-header-bg: #FFFFFF;
|
||||
--admin-shell-radius: 22px;
|
||||
--admin-card-radius: 14px;
|
||||
--admin-shell-max-width: 1840px;
|
||||
--admin-page-max-width: 1440px;
|
||||
--admin-content-max-width: 1500px;
|
||||
--sidebar-width: 220px;
|
||||
--sidebar-width-collapsed: 76px;
|
||||
--header-height: 64px;
|
||||
|
||||
/* ---------------- 圆角 ---------------- */
|
||||
--border-radius-xs: 6px;
|
||||
--border-radius-sm: 8px;
|
||||
--border-radius-md: var(--admin-card-radius);
|
||||
--border-radius-md: 14px;
|
||||
--border-radius-lg: 16px;
|
||||
--border-radius-xl: 18px;
|
||||
--border-radius-pill: 999px;
|
||||
|
||||
/* ---------------- 阴影 ---------------- */
|
||||
--shadow-panel: 0 8px 20px rgba(48, 58, 67, 0.035);
|
||||
--shadow-card: 0 8px 24px rgba(75, 55, 46, 0.06);
|
||||
--el-color-primary: var(--color-primary);
|
||||
--shadow-raise: 0 10px 24px rgba(68, 48, 55, 0.1);
|
||||
--shadow-brand: 0 8px 18px rgba(200, 74, 83, 0.26);
|
||||
--admin-shadow: 0 18px 48px rgba(57, 40, 45, 0.14);
|
||||
|
||||
/* ---------------- 排版刻度 ----------------
|
||||
正文 14px 起;角标/计数下限 11px;元信息下限 12px。 */
|
||||
--font-family-base: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
--font-family-brand: KaiTi, STKaiti, serif; /* 品牌书法体:仅装饰性标语 */
|
||||
--font-size-micro: 11px;
|
||||
--font-size-xs: 12px;
|
||||
--font-size-sm: 13px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-md: 15px;
|
||||
--font-size-lg: 16px;
|
||||
--font-size-xl: 18px;
|
||||
--font-size-2xl: 20px;
|
||||
--font-size-3xl: 24px;
|
||||
--font-size-4xl: 28px;
|
||||
--font-size-5xl: 32px;
|
||||
--font-size-display: 38px;
|
||||
--font-line-tight: 1.2;
|
||||
--font-line-snug: 1.35;
|
||||
--font-line-base: 1.6;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
|
||||
/* ---------------- 间距 ---------------- */
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--space-8: 32px;
|
||||
--space-10: 40px;
|
||||
|
||||
/* ---------------- 动效 ---------------- */
|
||||
--motion-fast: 140ms;
|
||||
--motion-normal: 200ms;
|
||||
--motion-slow: 320ms;
|
||||
--ease-standard: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--press-scale: 0.985;
|
||||
--press-opacity: 0.88;
|
||||
|
||||
/* ---------------- 交互热区 ----------------
|
||||
桌面端按下限为 WCAG 2.5.8(AA) 的 24×24px;本项目常规控件取 32px(舒适),
|
||||
主导航行取 44px(兼顾触屏笔记本)。不套用移动端 44px 全量规则,
|
||||
否则会把管理端的单屏信息密度牺牲掉。 */
|
||||
--tap-min: 44px;
|
||||
--tap-control: 32px;
|
||||
--tap-icon: 36px;
|
||||
|
||||
/* ---------------- tone 色调组(工作台指标卡 / 待办图标) ----------------
|
||||
每组三档:--tone-x 用于图标与图形;--tone-x-ink 用于文字(压 --tone-x-soft
|
||||
实算均 ≥4.6:1);--tone-x-soft 为浅底。 */
|
||||
--tone-rose: #F45F68; --tone-rose-ink: var(--admin-coral-strong); --tone-rose-soft: #FFF0F1;
|
||||
--tone-amber: #E59A32; --tone-amber-ink: var(--color-warning-strong); --tone-amber-soft: #FFF6E6;
|
||||
--tone-blue: #4A7CD4; --tone-blue-ink: var(--color-info-strong); --tone-blue-soft: #EEF5FF;
|
||||
--tone-green: #2E9B68; --tone-green-ink: var(--color-success-strong); --tone-green-soft: #EDF8F1;
|
||||
--tone-purple: #8B63BC; --tone-purple-ink: var(--color-purple-strong); --tone-purple-soft: #F4EFFF;
|
||||
--tone-slate: #8A9099; --tone-slate-ink: var(--color-neutral-strong); --tone-slate-soft: #F4F5F7;
|
||||
--tone-red: #D94A4A; --tone-red-ink: var(--color-danger-strong); --tone-red-soft: #FFF6F6;
|
||||
|
||||
/* 兼容既有引用名 */
|
||||
--admin-soft-pink: var(--admin-coral-soft);
|
||||
--admin-soft-blue: var(--color-info-soft);
|
||||
--admin-soft-green: var(--color-success-soft);
|
||||
--admin-soft-purple: var(--color-purple-soft);
|
||||
|
||||
/* ==========================================================================
|
||||
Element Plus 联动
|
||||
EP 的 --el-color-{t}-light-{i} / -dark-2 / -rgb 由 SCSS 在**编译期**按默认色
|
||||
静态生成,运行时改 --el-color-primary 不会带动它们(开关、滑块、焦点环、
|
||||
分页、标签因此仍是默认蓝)。这里按 EP 的派生公式补全:
|
||||
light-{i} = mix(#FFFFFF, base, i*10%) i ∈ 1..9
|
||||
dark-2 = mix(#000000, base, 20%)
|
||||
基色取 -strong / -solid 档 —— 既能当文字色也能做实心底,使 EP 全组件与品牌一致。
|
||||
info 保持中性灰:EP 的 info 语义是「中性/未知」,不应变成品牌红。
|
||||
========================================================================== */
|
||||
--el-color-primary: #C84A53;
|
||||
--el-color-primary-rgb: 200, 74, 83;
|
||||
--el-color-primary-light-3: #d98087;
|
||||
--el-color-primary-light-5: #e4a5a9;
|
||||
--el-color-primary-light-7: #efc9cb;
|
||||
--el-color-primary-light-8: #f4dbdd;
|
||||
--el-color-primary-light-9: #faedee;
|
||||
--el-color-primary-dark-2: #a03b42;
|
||||
|
||||
--el-color-success: #1F7A4F;
|
||||
--el-color-success-rgb: 31, 122, 79;
|
||||
--el-color-success-light-3: #62a284;
|
||||
--el-color-success-light-5: #8fbda7;
|
||||
--el-color-success-light-7: #bcd7ca;
|
||||
--el-color-success-light-8: #d2e4dc;
|
||||
--el-color-success-light-9: #e9f2ed;
|
||||
--el-color-success-dark-2: #19623f;
|
||||
|
||||
--el-color-warning: #8F5D0F;
|
||||
--el-color-warning-rgb: 143, 93, 15;
|
||||
--el-color-warning-light-3: #b18e57;
|
||||
--el-color-warning-light-5: #c7ae87;
|
||||
--el-color-warning-light-7: #ddceb7;
|
||||
--el-color-warning-light-8: #e9dfcf;
|
||||
--el-color-warning-light-9: #f4efe7;
|
||||
--el-color-warning-dark-2: #724a0c;
|
||||
|
||||
--el-color-danger: #B8353A;
|
||||
--el-color-danger-rgb: 184, 53, 58;
|
||||
--el-color-danger-light-3: #cd7275;
|
||||
--el-color-danger-light-5: #dc9a9d;
|
||||
--el-color-danger-light-7: #eac2c4;
|
||||
--el-color-danger-light-8: #f1d7d8;
|
||||
--el-color-danger-light-9: #f8ebeb;
|
||||
--el-color-danger-dark-2: #932a2e;
|
||||
|
||||
--el-color-info: #656C79;
|
||||
--el-color-info-rgb: 101, 108, 121;
|
||||
--el-color-info-light-3: #9398a1;
|
||||
--el-color-info-light-5: #b2b6bc;
|
||||
--el-color-info-light-7: #d1d3d7;
|
||||
--el-color-info-light-8: #e0e2e4;
|
||||
--el-color-info-light-9: #f0f0f2;
|
||||
--el-color-info-dark-2: #515661;
|
||||
|
||||
/* EP 中性色(文字 / 边框 / 填充)接到同一套 token,避免表格、输入框另成一套灰 */
|
||||
--el-text-color-primary: var(--color-text-primary);
|
||||
--el-text-color-regular: var(--color-text-secondary);
|
||||
--el-text-color-secondary: var(--color-text-tertiary);
|
||||
--el-text-color-placeholder: var(--color-text-placeholder);
|
||||
--el-text-color-disabled: var(--color-text-disabled);
|
||||
--el-border-color: var(--color-border);
|
||||
--el-border-color-light: var(--color-border-light);
|
||||
--el-border-color-lighter: var(--color-border-light);
|
||||
--el-border-color-extra-light: var(--color-border-hairline);
|
||||
--el-fill-color: var(--color-bg-subtle);
|
||||
--el-fill-color-light: var(--color-bg-secondary);
|
||||
--el-fill-color-lighter: var(--color-bg-secondary);
|
||||
--el-bg-color: var(--color-bg-primary);
|
||||
--el-bg-color-page: var(--color-bg-page);
|
||||
--el-border-radius-base: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
font-size: 14px;
|
||||
font-family: var(--font-family-base);
|
||||
font-size: var(--font-size-base);
|
||||
color: var(--color-text-primary);
|
||||
background-color: var(--color-bg-page);
|
||||
}
|
||||
|
||||
/* 管理端是桌面密度布局,1100px 以下由 MainLayout 的 .admin-scene 承担横向滚动。 */
|
||||
body { min-width: 1100px; }
|
||||
|
||||
/* <small> 的浏览器默认样式是 font-size: smaller(0.833em),会按父级字号再缩一档:
|
||||
父级 12px 时实际渲染 10px、11px 时渲染 9.17px —— 静默跌破 11px 下限,
|
||||
且源码里看不到任何非法字号,静态检查抓不到(2026-09-22 由渲染态实测发现)。
|
||||
这里统一改为继承父级字号,需要更小的场景必须显式声明 token 字号(守卫会校验)。 */
|
||||
small { font-size: inherit; }
|
||||
|
||||
button:focus-visible,
|
||||
a:focus-visible,
|
||||
input:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 3px solid rgba(238, 102, 107, 0.32);
|
||||
outline: 3px solid var(--admin-coral-border);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---------------- Element Plus 组件级覆盖 ---------------- */
|
||||
/* EP 默认 hover 是把主色「加白」(白字对比度随之掉到 2.2:1)。这里改成更深一档,
|
||||
既保住白字可读性(5.33:1),hover 反馈也更明确。 */
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: var(--color-primary);
|
||||
--el-button-border-color: var(--color-primary);
|
||||
--el-button-hover-bg-color: var(--color-primary-hover);
|
||||
--el-button-hover-border-color: var(--color-primary-hover);
|
||||
--el-button-bg-color: var(--admin-coral-solid);
|
||||
--el-button-border-color: var(--admin-coral-solid);
|
||||
--el-button-hover-bg-color: var(--admin-coral-solid-hover);
|
||||
--el-button-hover-border-color: var(--admin-coral-solid-hover);
|
||||
--el-button-active-bg-color: var(--el-color-primary-dark-2);
|
||||
--el-button-active-border-color: var(--el-color-primary-dark-2);
|
||||
}
|
||||
|
||||
.el-radio-group .el-radio-button__inner:checked {
|
||||
background-color: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
background-color: var(--admin-coral-solid);
|
||||
border-color: var(--admin-coral-solid);
|
||||
}
|
||||
.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: #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; }
|
||||
.page-heading { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
|
||||
.page-heading h1 { font-size: 24px; line-height: 1.35; font-weight: 650; }
|
||||
|
||||
.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: var(--color-bg-secondary);
|
||||
--el-table-border-color: var(--color-border-light);
|
||||
}
|
||||
|
||||
.el-tag { border-radius: var(--border-radius-pill); }
|
||||
|
||||
/* ---------------- 通用版式 ---------------- */
|
||||
.page-container { padding: var(--space-6); }
|
||||
|
||||
.page-shell { max-width: var(--admin-page-max-width); margin: 0 auto; }
|
||||
|
||||
.page-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
|
||||
.page-heading h1 {
|
||||
font-size: var(--font-size-3xl);
|
||||
line-height: var(--font-line-snug);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.page-heading p { margin-top: 6px; color: var(--color-text-tertiary); }
|
||||
.surface-card { background: var(--color-bg-primary); border: 1px solid var(--color-border-light); border-radius: var(--border-radius-md); box-shadow: var(--shadow-card); }
|
||||
|
||||
.surface-card {
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--border-radius-md);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
/* 语义文字色工具类(比在页面里各写一遍 color: #xxx 更不容易漂移) */
|
||||
.text-primary { color: var(--color-text-primary); }
|
||||
.text-secondary { color: var(--color-text-secondary); }
|
||||
.text-tertiary { color: var(--color-text-tertiary); }
|
||||
.mt-sm { margin-top: 8px; }
|
||||
.mt-md { margin-top: 16px; }
|
||||
.mt-lg { margin-top: 24px; }
|
||||
.mb-sm { margin-bottom: 8px; }
|
||||
.mb-md { margin-bottom: 16px; }
|
||||
.mb-lg { margin-bottom: 24px; }
|
||||
.text-muted { color: var(--color-text-tertiary); }
|
||||
.text-brand { color: var(--admin-coral-strong); }
|
||||
.text-success { color: var(--color-success-strong); }
|
||||
.text-warning { color: var(--color-warning-strong); }
|
||||
.text-danger { color: var(--color-danger-strong); }
|
||||
|
||||
/* 语义底色工具类 */
|
||||
.bg-card { background: var(--color-bg-primary); }
|
||||
.bg-subtle { background: var(--color-bg-subtle); }
|
||||
.bg-brand-soft { background: var(--admin-coral-soft); }
|
||||
|
||||
/* 间距工具类 */
|
||||
.mt-sm { margin-top: var(--space-2); }
|
||||
.mt-md { margin-top: var(--space-4); }
|
||||
.mt-lg { margin-top: var(--space-6); }
|
||||
.mb-sm { margin-bottom: var(--space-2); }
|
||||
.mb-md { margin-bottom: var(--space-4); }
|
||||
.mb-lg { margin-bottom: var(--space-6); }
|
||||
|
||||
.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; } }
|
||||
|
||||
/* ---------------- 交互反馈 ---------------- */
|
||||
/* 桌面端控件热区下限:32px(WCAG 2.5.8 AA 为 24px,留出舒适余量) */
|
||||
.tap-control { min-height: var(--tap-control); min-width: var(--tap-control); }
|
||||
|
||||
/* 按压反馈:统一走 token,避免各页面各写一套 scale/opacity */
|
||||
.pressable { transition: transform var(--motion-fast) var(--ease-standard), opacity var(--motion-fast) var(--ease-standard); }
|
||||
.pressable:active { transform: scale(var(--press-scale)); opacity: var(--press-opacity); }
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
body { min-width: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.page-container { padding: var(--space-4); }
|
||||
.page-heading { align-items: flex-start; flex-direction: column; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 管理端调色板常量(TS 侧)。
|
||||
*
|
||||
* 为什么需要这个文件:
|
||||
* 部分模板用 `:style="{ backgroundColor: stage.color + '15' }"` 把 6 位 hex 拼成
|
||||
* 8 位带 alpha 的颜色('#409EFF' + '15' → '#409EFF15',即 8% 不透明度)。
|
||||
* 这类位置**不能**用 CSS var —— `'var(--x)' + '15'` 是无效值,浏览器会整条丢弃。
|
||||
* 于是「色值只有一处事实源」这条规则在 TS 侧会漏。本文件补上这个缺口。
|
||||
*
|
||||
* 单一事实源约定:
|
||||
* 键名就是 src/theme/index.css 里的 var 名,值必须与之一致。
|
||||
* 门控脚本 scripts/lint-admin-tokens.mjs 会逐项比对,任一侧改了而另一侧没改即失败。
|
||||
*/
|
||||
|
||||
export const TOKEN_HEX: Record<string, string> = {
|
||||
'--color-primary': '#F45F68',
|
||||
'--color-primary-strong': '#C0414A',
|
||||
'--color-primary-solid': '#C84A53',
|
||||
'--color-success': '#2E9B68',
|
||||
'--color-success-strong': '#1F7A4F',
|
||||
'--color-warning': '#E59A32',
|
||||
'--color-warning-strong': '#8F5D0F',
|
||||
'--color-danger': '#D94A4A',
|
||||
'--color-danger-strong': '#B8353A',
|
||||
'--color-info': '#4A7CD4',
|
||||
'--color-info-strong': '#3A63B0',
|
||||
'--color-purple': '#8B63BC',
|
||||
'--color-purple-strong': '#6B47A0',
|
||||
'--color-neutral': '#8A9099',
|
||||
'--color-neutral-strong': '#656C79',
|
||||
'--color-text-primary': '#1F2328',
|
||||
'--color-text-secondary': '#555555',
|
||||
'--color-text-tertiary': '#676E7B',
|
||||
'--color-bg-page': '#F7F9FB',
|
||||
'--color-border': '#E4E7EB',
|
||||
}
|
||||
|
||||
/**
|
||||
* 取 token 的 hex 字面量。用于 :style 绑定(含 hex 拼接透明度的场景)。
|
||||
* @example token('--color-success') // '#2E9B68'
|
||||
*/
|
||||
export function token(name: string): string {
|
||||
const value = TOKEN_HEX[name]
|
||||
if (!value) throw new Error(`未登记的 token: ${name}(请先加入 TOKEN_HEX 并同步 theme/index.css)`)
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成带 alpha 的 8 位 hex。alpha 传 2 位十六进制('15' = 21/255 ≈ 8%)。
|
||||
* 与 `color + '15'` 等价,但把「必须 6 位 hex」这个约束显式化,避免误把 var() 传进来。
|
||||
*/
|
||||
export function tint(hex: string, alphaHex: string): string {
|
||||
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) {
|
||||
throw new Error(`tint() 需要 6 位 hex(不能是 var() 或 3/8 位 hex):${hex}`)
|
||||
}
|
||||
return `${hex}${alphaHex}`
|
||||
}
|
||||
|
||||
/** 指标卡 / 待办项的色调组,与 theme/index.css 的 --tone-* 一一对应。 */
|
||||
export interface Tone {
|
||||
/** 图标与图形用的鲜亮色 */
|
||||
vivid: string
|
||||
/** 文字用的可读色(压 soft 底 ≥4.5:1) */
|
||||
ink: string
|
||||
/** 浅底 */
|
||||
soft: string
|
||||
}
|
||||
|
||||
export const TONES: Record<string, Tone> = {
|
||||
rose: { vivid: '#F45F68', ink: '#C0414A', soft: '#FFF0F1' },
|
||||
amber: { vivid: '#E59A32', ink: '#8F5D0F', soft: '#FFF6E6' },
|
||||
blue: { vivid: '#4A7CD4', ink: '#3A63B0', soft: '#EEF5FF' },
|
||||
green: { vivid: '#2E9B68', ink: '#1F7A4F', soft: '#EDF8F1' },
|
||||
purple: { vivid: '#8B63BC', ink: '#6B47A0', soft: '#F4EFFF' },
|
||||
slate: { vivid: '#8A9099', ink: '#656C79', soft: '#F4F5F7' },
|
||||
red: { vivid: '#D94A4A', ink: '#B8353A', soft: '#FFF6F6' },
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 内存态访问令牌。
|
||||
//
|
||||
// 不再落地 localStorage:XSS 无法读取/外泄令牌,也无法改写伪造权限。
|
||||
// 生产环境由 HttpOnly Cookie(dy_op_at)抗 XSS 承载令牌;开发跨端口代理场景由本内存态经
|
||||
// Authorization 头承载。两者并存,互不冲突。
|
||||
|
||||
let currentToken = ''
|
||||
|
||||
export function getAccessToken(): string {
|
||||
return currentToken
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string): void {
|
||||
currentToken = token || ''
|
||||
}
|
||||
|
||||
export function clearAccessToken(): void {
|
||||
currentToken = ''
|
||||
}
|
||||
@@ -157,11 +157,12 @@ import {
|
||||
} from '@element-plus/icons-vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { createEmptyDashboard, loadDashboard } from '@/services/dashboard'
|
||||
import { BRAND_ACTIVITY_FALLBACK } from '@/brand'
|
||||
import type { DashboardActivity, DashboardModel } from '@/types/dashboard'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const activityFallback = '/brand/activity-default.jpg'
|
||||
const activityFallback = BRAND_ACTIVITY_FALLBACK
|
||||
const dashboard = ref<DashboardModel>(createEmptyDashboard())
|
||||
const loading = ref(true)
|
||||
const fatalError = ref('')
|
||||
@@ -282,9 +283,9 @@ onMounted(retryLoad)
|
||||
<style scoped>
|
||||
.dashboard-page {
|
||||
min-width: 0;
|
||||
max-width: 1500px;
|
||||
max-width: var(--admin-content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 20px 26px 28px;
|
||||
padding: var(--space-5) 26px 28px;
|
||||
}
|
||||
|
||||
.dashboard-welcome {
|
||||
@@ -292,33 +293,34 @@ onMounted(retryLoad)
|
||||
min-height: 66px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.dashboard-welcome h1 {
|
||||
font-size: 24px;
|
||||
line-height: 1.25;
|
||||
font-size: var(--font-size-3xl);
|
||||
line-height: var(--font-line-snug);
|
||||
}
|
||||
|
||||
.dashboard-welcome p {
|
||||
margin-top: 6px;
|
||||
color: #78828b;
|
||||
font-size: 13px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.dashboard-date {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
color: #7b858e;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.dashboard-date small {
|
||||
padding: 6px 10px;
|
||||
color: #d75a63;
|
||||
background: #fff0f1;
|
||||
border-radius: 999px;
|
||||
color: var(--admin-coral-strong);
|
||||
background: var(--admin-coral-soft);
|
||||
border-radius: var(--border-radius-pill);
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
|
||||
.dashboard-loading,
|
||||
@@ -334,7 +336,7 @@ onMounted(retryLoad)
|
||||
|
||||
.metric-skeleton {
|
||||
height: 105px;
|
||||
border-radius: 12px;
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
.dashboard-error,
|
||||
@@ -342,14 +344,14 @@ onMounted(retryLoad)
|
||||
.dashboard-notice {
|
||||
display: flex;
|
||||
min-height: 48px;
|
||||
margin: 8px 0 12px;
|
||||
margin: var(--space-2) 0 var(--space-3);
|
||||
padding: 10px 14px;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #6f5355;
|
||||
background: #fff6f6;
|
||||
border: 1px solid #f7dfe1;
|
||||
border-radius: 11px;
|
||||
color: var(--color-danger-strong);
|
||||
background: var(--color-danger-soft);
|
||||
border: 1px solid var(--color-border-danger);
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.dashboard-error > span,
|
||||
@@ -362,7 +364,8 @@ onMounted(retryLoad)
|
||||
.dashboard-error small,
|
||||
.dashboard-forbidden small {
|
||||
margin-top: 3px;
|
||||
color: #8c7d7e;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
|
||||
.dashboard-metrics {
|
||||
@@ -374,33 +377,40 @@ onMounted(retryLoad)
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 108px;
|
||||
padding: 14px 12px;
|
||||
padding: 14px var(--space-3);
|
||||
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;
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
transition: transform var(--motion-normal) var(--ease-standard),
|
||||
box-shadow var(--motion-normal) var(--ease-standard);
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
box-shadow: 0 10px 24px rgba(68, 48, 55, 0.1);
|
||||
box-shadow: var(--shadow-raise);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.metric-card:active {
|
||||
transform: translateY(0) scale(var(--press-scale));
|
||||
}
|
||||
|
||||
.metric-card.unavailable {
|
||||
cursor: default;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
/* 图标字形属「有意义的非文本内容」,按 WCAG 需 ≥3:1。
|
||||
原先用鲜亮色调压同色系浅底(amber 仅 2.2:1),统一改用 -ink 档(≥4.6:1)。 */
|
||||
.metric-icon,
|
||||
.todo-icon {
|
||||
display: grid;
|
||||
color: var(--tone-color);
|
||||
color: var(--tone-ink);
|
||||
background: var(--tone-soft);
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
@@ -409,7 +419,7 @@ onMounted(retryLoad)
|
||||
.metric-icon {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
font-size: 19px;
|
||||
font-size: var(--font-size-xl);
|
||||
}
|
||||
|
||||
.metric-copy {
|
||||
@@ -420,24 +430,25 @@ onMounted(retryLoad)
|
||||
|
||||
.metric-copy small {
|
||||
overflow: hidden;
|
||||
color: #55616a;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.metric-copy strong {
|
||||
margin-top: 3px;
|
||||
color: #171a1d;
|
||||
font-size: 25px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-3xl);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 原为 9px(低于可读下限),提到 11px */
|
||||
.metric-copy em {
|
||||
margin-top: 6px;
|
||||
overflow: hidden;
|
||||
color: var(--tone-color);
|
||||
font-size: 9px;
|
||||
color: var(--tone-ink);
|
||||
font-size: var(--font-size-micro);
|
||||
font-style: normal;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@@ -445,18 +456,20 @@ onMounted(retryLoad)
|
||||
|
||||
.metric-arrow {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
color: var(--tone-color);
|
||||
font-size: 11px;
|
||||
right: var(--space-2);
|
||||
bottom: var(--space-2);
|
||||
color: var(--tone-ink);
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
|
||||
.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; }
|
||||
/* 色调组改为引用 theme/index.css 的 --tone-*-ink / --tone-*-soft。
|
||||
--tone-ink 是本组件内的局部变量(由下面各 tone-* 类赋值),供图标与文字统一取用。 */
|
||||
.tone-rose { --tone-ink: var(--tone-rose-ink); --tone-soft: var(--tone-rose-soft); }
|
||||
.tone-amber { --tone-ink: var(--tone-amber-ink); --tone-soft: var(--tone-amber-soft); }
|
||||
.tone-blue { --tone-ink: var(--tone-blue-ink); --tone-soft: var(--tone-blue-soft); }
|
||||
.tone-green { --tone-ink: var(--tone-green-ink); --tone-soft: var(--tone-green-soft); }
|
||||
.tone-purple { --tone-ink: var(--tone-purple-ink); --tone-soft: var(--tone-purple-soft); }
|
||||
.tone-red { --tone-ink: var(--tone-red-ink); --tone-soft: var(--tone-red-soft); }
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
@@ -467,11 +480,11 @@ onMounted(retryLoad)
|
||||
|
||||
.dashboard-panel {
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
background: #fff;
|
||||
border: 1px solid #e9edf0;
|
||||
padding: 14px var(--space-4);
|
||||
background: var(--color-bg-primary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--admin-card-radius);
|
||||
box-shadow: 0 8px 20px rgba(48, 58, 67, 0.035);
|
||||
box-shadow: var(--shadow-panel);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@@ -479,42 +492,49 @@ onMounted(retryLoad)
|
||||
min-height: 30px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid #f0f1f2;
|
||||
border-bottom: 1px solid var(--color-border-hairline);
|
||||
}
|
||||
|
||||
.panel-header h2 {
|
||||
padding-bottom: 10px;
|
||||
font-size: 16px;
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
.panel-header h2 span {
|
||||
display: inline-grid;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
min-width: var(--space-5);
|
||||
height: var(--space-5);
|
||||
margin-left: 6px;
|
||||
padding: 0 5px;
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 20px;
|
||||
background: var(--admin-coral);
|
||||
border-radius: 999px;
|
||||
color: var(--color-text-inverse);
|
||||
font-size: var(--font-size-micro);
|
||||
line-height: var(--space-5);
|
||||
background: var(--admin-coral-solid);
|
||||
border-radius: var(--border-radius-pill);
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.panel-header button {
|
||||
display: flex;
|
||||
min-height: var(--tap-control);
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: #89929a;
|
||||
font-size: 11px;
|
||||
gap: var(--space-1);
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: var(--border-radius-xs);
|
||||
transition: color var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.panel-header button:hover {
|
||||
color: var(--admin-coral-strong);
|
||||
}
|
||||
|
||||
.panel-header > span {
|
||||
color: #98a0a7;
|
||||
font-size: 10px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
|
||||
.todo-list,
|
||||
@@ -534,12 +554,13 @@ onMounted(retryLoad)
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid #f2f3f4;
|
||||
border-bottom: 1px solid var(--color-border-hairline);
|
||||
transition: background var(--motion-fast) var(--ease-standard);
|
||||
}
|
||||
|
||||
.todo-row:hover,
|
||||
.activity-row:hover {
|
||||
background: #fffafa;
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
|
||||
.todo-icon {
|
||||
@@ -559,8 +580,8 @@ onMounted(retryLoad)
|
||||
.todo-copy strong,
|
||||
.activity-copy strong {
|
||||
overflow: hidden;
|
||||
color: #30373d;
|
||||
font-size: 12px;
|
||||
color: var(--color-text-primary);
|
||||
font-size: var(--font-size-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -569,22 +590,22 @@ onMounted(retryLoad)
|
||||
.activity-copy small {
|
||||
margin-top: 3px;
|
||||
overflow: hidden;
|
||||
color: #939ba2;
|
||||
font-size: 9px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.todo-row > b {
|
||||
min-width: 29px;
|
||||
color: var(--admin-coral);
|
||||
font-size: 13px;
|
||||
color: var(--admin-coral-strong);
|
||||
font-size: var(--font-size-sm);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.todo-row > em {
|
||||
color: var(--admin-coral);
|
||||
font-size: 10px;
|
||||
color: var(--admin-coral-strong);
|
||||
font-size: var(--font-size-micro);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@@ -593,21 +614,21 @@ onMounted(retryLoad)
|
||||
height: 38px;
|
||||
flex: none;
|
||||
object-fit: cover;
|
||||
border-radius: 7px;
|
||||
border-radius: var(--border-radius-xs);
|
||||
}
|
||||
|
||||
.activity-status {
|
||||
padding: 3px 6px;
|
||||
color: #318b5a;
|
||||
font-size: 9px;
|
||||
background: #eaf7ef;
|
||||
border-radius: 999px;
|
||||
color: var(--color-success-strong);
|
||||
font-size: var(--font-size-micro);
|
||||
background: var(--color-success-soft);
|
||||
border-radius: var(--border-radius-pill);
|
||||
}
|
||||
|
||||
.activity-row > em {
|
||||
min-width: 76px;
|
||||
color: #7d878f;
|
||||
font-size: 9px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
font-style: normal;
|
||||
text-align: right;
|
||||
}
|
||||
@@ -619,47 +640,47 @@ onMounted(retryLoad)
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
color: #a0a7ad;
|
||||
font-size: 11px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.dashboard-empty .el-icon {
|
||||
color: #d7dce0;
|
||||
font-size: 34px;
|
||||
color: var(--color-icon-muted);
|
||||
font-size: var(--font-size-5xl);
|
||||
}
|
||||
|
||||
.overview-grid {
|
||||
display: grid;
|
||||
min-height: 118px;
|
||||
padding-top: 12px;
|
||||
padding-top: var(--space-3);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.overview-grid > div {
|
||||
display: flex;
|
||||
padding: 12px 10px;
|
||||
padding: var(--space-3) 10px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
background: var(--tone-soft);
|
||||
border-radius: 8px;
|
||||
border-radius: var(--border-radius-sm);
|
||||
}
|
||||
|
||||
.overview-grid small {
|
||||
color: var(--tone-color);
|
||||
font-size: 10px;
|
||||
color: var(--tone-ink);
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
|
||||
.overview-grid strong {
|
||||
margin-top: 7px;
|
||||
font-size: 24px;
|
||||
font-size: var(--font-size-3xl);
|
||||
}
|
||||
|
||||
.overview-grid em {
|
||||
margin-top: 7px;
|
||||
color: #8a9298;
|
||||
font-size: 9px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@@ -674,52 +695,53 @@ onMounted(retryLoad)
|
||||
grid-template-columns: 92px 1fr 76px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 10px;
|
||||
font-size: var(--font-size-micro);
|
||||
}
|
||||
|
||||
.effectiveness-row > span {
|
||||
color: #747e87;
|
||||
color: var(--color-text-tertiary);
|
||||
}
|
||||
|
||||
/* 轨道与填充的对比原为 2.53:1(条几乎看不出进度),改后 4.01:1 */
|
||||
.effectiveness-row > div {
|
||||
height: 8px;
|
||||
height: var(--space-2);
|
||||
overflow: hidden;
|
||||
background: #fdebee;
|
||||
border-radius: 999px;
|
||||
background: var(--color-primary-light);
|
||||
border-radius: var(--border-radius-pill);
|
||||
}
|
||||
|
||||
.effectiveness-row i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #f56b75, #ef5a65);
|
||||
background: var(--gradient-brand-bar);
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
.effectiveness-row strong {
|
||||
color: #75808a;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: var(--font-size-micro);
|
||||
font-weight: var(--font-weight-medium);
|
||||
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-welcome h1 { font-size: var(--font-size-2xl); }
|
||||
.dashboard-welcome p { margin-top: 3px; font-size: var(--font-size-xs); }
|
||||
.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; }
|
||||
.metric-card { min-height: 90px; padding: 10px var(--space-2); grid-template-columns: var(--space-8) 1fr; gap: 6px; }
|
||||
.metric-icon { width: var(--space-8); height: var(--space-8); font-size: var(--font-size-lg); }
|
||||
.metric-copy strong { font-size: var(--font-size-2xl); }
|
||||
.dashboard-grid { margin-top: 9px; gap: 9px; }
|
||||
.dashboard-panel { padding: 10px 12px; }
|
||||
.dashboard-panel { padding: 10px var(--space-3); }
|
||||
.todo-list, .activity-list { min-height: 152px; }
|
||||
.todo-row, .activity-row { min-height: 38px; padding: 4px; }
|
||||
.todo-row, .activity-row { min-height: 38px; padding: var(--space-1); }
|
||||
.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; }
|
||||
.overview-grid > div { padding: var(--space-2) 6px; }
|
||||
.overview-grid strong { font-size: var(--font-size-2xl); }
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<main class="login-shell">
|
||||
<section class="brand-panel" aria-label="善缘丹阳公益婚恋服务平台">
|
||||
<img class="brand-scene" src="/brand/login-illustration.jpg" alt="" />
|
||||
<img class="brand-scene" :src="BRAND_LOGIN_SCENE" alt="" />
|
||||
<div class="brand-overlay">
|
||||
<div class="brand-title">
|
||||
<img src="/brand/logo-mark.png" alt="" />
|
||||
<img :src="BRAND_LOGO" alt="" />
|
||||
<div>
|
||||
<strong>善缘丹阳</strong>
|
||||
<span>公益婚恋服务平台</span>
|
||||
@@ -62,6 +62,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { BRAND_LOGIN_SCENE, BRAND_LOGO } from '@/brand'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
@@ -100,7 +101,7 @@ async function handleLogin() {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.25fr) minmax(420px, 0.75fr);
|
||||
min-height: 100vh;
|
||||
background: #FFFFFF;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.brand-panel {
|
||||
@@ -143,28 +144,28 @@ async function handleLogin() {
|
||||
|
||||
.brand-title strong {
|
||||
color: var(--admin-primary);
|
||||
font-size: 25px;
|
||||
font-size: var(--font-size-3xl);
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.brand-title span {
|
||||
margin-top: 3px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.brand-message {
|
||||
max-width: 590px;
|
||||
padding: 26px 30px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
border: 1px solid rgba(255, 255, 255, 0.94);
|
||||
background: var(--color-bg-overlay);
|
||||
border: 1px solid var(--color-bg-overlay-strong);
|
||||
border-radius: 18px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.brand-message p {
|
||||
color: var(--admin-primary);
|
||||
font-weight: 650;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 3px;
|
||||
}
|
||||
|
||||
@@ -187,7 +188,7 @@ async function handleLogin() {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px;
|
||||
background: #FFFFFF;
|
||||
background: var(--color-bg-primary);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
@@ -200,14 +201,14 @@ async function handleLogin() {
|
||||
|
||||
.eyebrow {
|
||||
color: var(--admin-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
margin-top: 10px;
|
||||
font-size: 30px;
|
||||
font-size: var(--font-size-5xl);
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
@@ -227,7 +228,7 @@ async function handleLogin() {
|
||||
margin-top: 24px;
|
||||
padding: 14px 16px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.6;
|
||||
background: var(--admin-soft-green);
|
||||
border-radius: 10px;
|
||||
@@ -242,7 +243,7 @@ async function handleLogin() {
|
||||
footer {
|
||||
margin-top: 40px;
|
||||
color: var(--color-text-tertiary);
|
||||
font-size: 12px;
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
|
||||
@@ -2,15 +2,25 @@ import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
export default defineConfig(({ command }) => ({
|
||||
plugins: [vue()],
|
||||
// 管理端产物部署在 nginx 的 /admin/ 子路径下(见 infrastructure/nginx-production.conf
|
||||
// 与 admin-web/nginx-admin.conf)。构建时必须带 /admin/ 前缀,否则 index.html 引用的
|
||||
// /assets/*.js 会落到站点根路径,被生产 nginx 的其它 location 兜成 404 或错误页面。
|
||||
// 开发服务器保持根路径:http://localhost:13520/ 的既有开发与排查习惯不变
|
||||
// (vite 的 base 对 dev 与 build 各自生效,故此处按 command 分叉)。
|
||||
base: command === 'build' ? '/admin/' : '/',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
// 本机开发端口。避开 3000/8080/8081/8085 等被其他项目占用的常用端口。
|
||||
port: 13520,
|
||||
// 端口被占用时直接失败,而不是静默 +1 退到 13521。
|
||||
// 静默漂移会让「文档写 13520、实际跑在别的端口」这种不一致无法被发现。
|
||||
strictPort: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8085',
|
||||
@@ -18,4 +28,4 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>善缘丹阳 · 小程序设计系统 v2 改造对照</title>
|
||||
<style>
|
||||
:root{
|
||||
/* ── 与 miniapp/app.wxss 完全一致的 token(此处 1rpx = 0.5px,按 375pt 机型还原) ── */
|
||||
--primary:#E96B6B; --primary-strong:#B8434A; --primary-solid:#C24C50; --primary-light:#FFF3F2;
|
||||
--night-start:#172A72; --night-end:#25104F; --night-soft:#3445A1;
|
||||
--accent-pink:#FF4F91; --accent-coral:#FF6A78;
|
||||
--text-strong:#1A1F36; --text-primary:#2A3050; --text-secondary:#4A5169; --text-tertiary:#646C85;
|
||||
--text-placeholder:#9AA1B4; --text-inverse:#FFFFFF;
|
||||
--bg-page:#F6F5F3; --bg-card:#FFFFFF; --bg-subtle:#F5F6FA;
|
||||
--border:#E6E8EF; --divider:#EFF0F5;
|
||||
--accent-purple-strong:#7C60BC; --accent-purple-light:#F3EEFF;
|
||||
--success-strong:#17805A; --success-light:#ECF8F2; --success-on-night:#A6F5D7;
|
||||
--warning-strong:#9C5F14; --warning-light:#FFF7E9; --danger-strong:#C62828; --danger-light:#FFF0F0;
|
||||
--info-strong:#2A5DB0; --info-light:#EEF4FF;
|
||||
--glow-coral:rgba(255,79,145,.40); --glow-indigo:rgba(94,124,255,.32);
|
||||
--star:rgba(255,255,255,.70); --glass:rgba(255,255,255,.10); --hairline:rgba(255,255,255,.16);
|
||||
--glass-success:rgba(90,226,184,.16);
|
||||
--shadow-card:0 4px 14px rgba(35,35,95,.07); --shadow-raised:0 6px 18px rgba(35,35,95,.10);
|
||||
--shadow-night:0 8px 20px rgba(28,30,88,.18);
|
||||
--grad-smoke:linear-gradient(135deg,#FF6A78 0%,#E96B6B 100%);
|
||||
--grad-solid:linear-gradient(135deg,#C24C50 0%,#B8434A 100%);
|
||||
--scrim:linear-gradient(90deg,rgba(23,42,114,.84) 0%,rgba(37,16,79,.40) 56%,rgba(255,79,145,.12) 100%);
|
||||
/* 文档层配色 */
|
||||
--doc-bg:#F1F3F8; --doc-panel:#FFFFFF; --doc-line:#E2E6EF; --doc-ink:#1A1F36; --doc-mute:#5C6480;
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{
|
||||
margin:0;background:var(--doc-bg);color:var(--doc-ink);
|
||||
font-family:-apple-system,"PingFang SC","Noto Sans SC","Microsoft YaHei",system-ui,sans-serif;
|
||||
line-height:1.6;-webkit-font-smoothing:antialiased;
|
||||
}
|
||||
.wrap{max-width:1180px;margin:0 auto;padding:40px 24px 80px;}
|
||||
h1{font-size:28px;margin:0 0 8px;letter-spacing:-.2px;}
|
||||
h2{font-size:20px;margin:0 0 4px;}
|
||||
h3{font-size:15px;margin:0 0 10px;}
|
||||
.sub{color:var(--doc-mute);font-size:13.5px;margin:0;}
|
||||
.meta{display:flex;flex-wrap:wrap;gap:8px;margin:18px 0 0;}
|
||||
.chip{
|
||||
display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;
|
||||
background:#fff;border:1px solid var(--doc-line);font-size:12.5px;color:var(--doc-mute);
|
||||
}
|
||||
.chip b{color:var(--doc-ink);font-weight:600;}
|
||||
.panel{background:var(--doc-panel);border:1px solid var(--doc-line);border-radius:14px;padding:22px 24px;margin-top:22px;}
|
||||
.panel > p.lead{margin:6px 0 18px;color:var(--doc-mute);font-size:13.5px;}
|
||||
|
||||
/* 指标卡 */
|
||||
.metrics{display:grid;grid-template-columns:repeat(auto-fit,minmax(178px,1fr));gap:14px;}
|
||||
.metric{background:#fff;border:1px solid var(--doc-line);border-radius:12px;padding:16px 18px;}
|
||||
.metric .k{font-size:12.5px;color:var(--doc-mute);}
|
||||
.metric .v{font-size:24px;font-weight:700;margin:6px 0 2px;font-variant-numeric:tabular-nums;}
|
||||
.metric .d{font-size:12.5px;color:var(--doc-mute);}
|
||||
.up{color:var(--success-strong);} .flat{color:var(--doc-mute);}
|
||||
.metric .from{color:var(--doc-mute);text-decoration:line-through;font-weight:500;font-size:16px;margin-left:6px;}
|
||||
|
||||
/* 手机模拟 */
|
||||
.phones{display:flex;flex-wrap:wrap;gap:34px;justify-content:center;margin-top:8px;}
|
||||
.phone-col{display:flex;flex-direction:column;align-items:center;gap:12px;}
|
||||
.phone-tag{font-size:12.5px;padding:4px 12px;border-radius:999px;border:1px solid var(--doc-line);background:#fff;color:var(--doc-mute);}
|
||||
.phone-tag.new{border-color:#C9E6D8;background:var(--success-light);color:var(--success-strong);font-weight:600;}
|
||||
.phone{
|
||||
width:375px;border-radius:30px;padding:9px;background:#15182B;
|
||||
box-shadow:0 18px 44px rgba(20,24,50,.22),0 2px 6px rgba(20,24,50,.14);
|
||||
}
|
||||
.screen{width:100%;border-radius:22px;overflow:hidden;background:var(--bg-page);position:relative;}
|
||||
.statusbar{height:22px;display:flex;align-items:center;justify-content:center;font-size:10px;color:rgba(255,255,255,.86);letter-spacing:.4px;}
|
||||
|
||||
/* ── 夜空头部:改造前(纯渐变) ── */
|
||||
.night-a{
|
||||
padding:0 12px 14px;
|
||||
background:linear-gradient(160deg,var(--night-start),var(--night-end));
|
||||
border-radius:0 0 21px 21px;box-shadow:var(--shadow-night);
|
||||
}
|
||||
/* ── 夜空头部:改造后(星点 + 双光晕 + 叠层) ── */
|
||||
.night-b{
|
||||
position:relative;overflow:hidden;padding:0 12px 16px;
|
||||
background:
|
||||
radial-gradient(circle at 12% 18%,rgba(255,255,255,.70) 0,transparent 1.25px),
|
||||
radial-gradient(circle at 31% 66%,rgba(255,255,255,.70) 0,transparent 1px),
|
||||
radial-gradient(circle at 57% 12%,rgba(255,255,255,.70) 0,transparent 1px),
|
||||
radial-gradient(circle at 79% 52%,rgba(255,255,255,.70) 0,transparent 1.5px),
|
||||
radial-gradient(circle at 93% 84%,rgba(255,255,255,.70) 0,transparent 1px),
|
||||
linear-gradient(160deg,var(--night-start),var(--night-end));
|
||||
border-radius:0 0 24px 24px;box-shadow:var(--shadow-night);
|
||||
}
|
||||
.night-b::before,.night-b::after{content:'';position:absolute;border-radius:50%;pointer-events:none;}
|
||||
.night-b::before{top:-100px;right:-70px;width:280px;height:280px;
|
||||
background:radial-gradient(circle,var(--glow-coral) 0%,transparent 68%);}
|
||||
.night-b::after{bottom:-110px;left:-80px;width:310px;height:310px;
|
||||
background:radial-gradient(circle,var(--glow-indigo) 0%,transparent 70%);}
|
||||
.night-b > *{position:relative;z-index:1;}
|
||||
|
||||
.brand{min-height:56px;display:flex;align-items:center;justify-content:space-between;color:#fff;}
|
||||
.brand-l{display:flex;align-items:center;}
|
||||
.logo{width:33px;height:33px;border-radius:9px;background:linear-gradient(135deg,#FF7A88,#E96B6B);
|
||||
display:flex;align-items:center;justify-content:center;font-size:13px;font-weight:700;color:#fff;flex:none;}
|
||||
.logo.b{width:34px;height:34px;}
|
||||
.brand-name-a{font-size:17px;font-weight:700;letter-spacing:1px;line-height:1.25;}
|
||||
.brand-name-b{font-size:18px;font-weight:700;letter-spacing:2px;line-height:1.25;}
|
||||
.brand-slo-a{font-size:12px;letter-spacing:2px;color:rgba(255,255,255,.72);}
|
||||
.brand-slo-b{font-size:12px;letter-spacing:3px;color:rgba(255,255,255,.72);}
|
||||
.msg{font-size:12.5px;}
|
||||
|
||||
/* 状态条 */
|
||||
.strip-a{margin:2px 0 8px;padding:9px 10px;border:1px solid rgba(255,255,255,.16);
|
||||
background:rgba(255,255,255,.09);border-radius:10px;color:#fff;display:flex;align-items:center;gap:8px;}
|
||||
.strip-b{margin:4px 0 8px;padding:12px;border:1px solid var(--hairline);
|
||||
background:var(--glass);border-radius:12px;color:#fff;display:flex;align-items:center;gap:8px;}
|
||||
.st-copy{flex:1;min-width:0;}
|
||||
.st-title-a{font-size:12.5px;font-weight:600;}
|
||||
.st-title-b{font-size:14px;font-weight:600;line-height:1.25;}
|
||||
.st-desc{font-size:12px;color:rgba(255,255,255,.72);line-height:1.4;margin-top:2px;}
|
||||
.cta-a{flex:none;min-width:54px;padding:0 9px;height:24px;display:flex;align-items:center;justify-content:center;
|
||||
border-radius:14px;background:var(--grad-smoke);font-size:12px;font-weight:600;}
|
||||
.cta-b{flex:none;min-width:58px;padding:0 12px;height:44px;display:flex;align-items:center;justify-content:center;
|
||||
border-radius:999px;background:var(--grad-solid);font-size:13px;font-weight:600;}
|
||||
.checks{display:flex;gap:5px;overflow:hidden;}
|
||||
.check-a{padding:4px 6px;border-radius:10px;background:rgba(255,255,255,.08);font-size:12px;color:rgba(255,255,255,.72);white-space:nowrap;}
|
||||
.check-b{padding:4px 7px;border-radius:999px;background:var(--glass);font-size:12px;color:rgba(255,255,255,.72);white-space:nowrap;}
|
||||
.check-b.done{background:var(--glass-success);color:var(--success-on-night);font-weight:600;}
|
||||
|
||||
/* 横幅 */
|
||||
.banner-a{height:136px;border-radius:13px;overflow:hidden;position:relative;
|
||||
background:linear-gradient(120deg,#2C3A82,#5A2C6E 62%,#B3487A);}
|
||||
.banner-b{height:150px;border-radius:16px;overflow:hidden;position:relative;
|
||||
background:linear-gradient(120deg,#2C3A82,#5A2C6E 62%,#B3487A);}
|
||||
.banner-a .scrim,.banner-b .scrim{position:absolute;inset:0;}
|
||||
.banner-a .scrim{background:linear-gradient(90deg,rgba(25,22,86,.78) 0%,rgba(43,22,101,.25) 58%,rgba(255,79,145,.08) 100%);}
|
||||
.banner-b .scrim{background:var(--scrim);}
|
||||
.banner-copy{position:absolute;left:12px;width:68%;display:flex;flex-direction:column;color:#fff;}
|
||||
.banner-a .banner-copy{bottom:13px;}
|
||||
.banner-b .banner-copy{bottom:20px;}
|
||||
.kick{font-size:12px;letter-spacing:1.5px;color:rgba(255,255,255,.78);}
|
||||
.bt-a{margin-top:4px;font-size:17.5px;font-weight:700;line-height:1.2;text-shadow:0 1.5px 5px rgba(0,0,0,.2);}
|
||||
.bt-b{margin-top:5px;font-size:24px;font-weight:700;line-height:1.25;letter-spacing:.5px;text-shadow:0 2px 7px rgba(0,0,0,.22);}
|
||||
.bs-a,.bs-b{margin-top:4px;font-size:12px;color:rgba(255,255,255,.84);}
|
||||
.bs-b{font-size:13px;color:rgba(255,255,255,.72);margin-top:5px;}
|
||||
|
||||
/* 快捷入口 */
|
||||
.entries{position:relative;z-index:2;background:#fff;margin:-7px 12px 0;}
|
||||
.entries.a{border-radius:15px;padding:11px 5px 9px;box-shadow:0 4px 14px rgba(35,35,95,.07);}
|
||||
.entries.b{border-radius:16px;padding:12px 4px 8px;box-shadow:var(--shadow-raised);}
|
||||
.egrid{display:flex;flex-wrap:wrap;row-gap:8px;}
|
||||
.eitem{width:25%;display:flex;flex-direction:column;align-items:center;justify-content:center;}
|
||||
.eitem.a{min-height:52px;} .eitem.b{min-height:58px;}
|
||||
.eicon{display:flex;align-items:center;justify-content:center;overflow:hidden;}
|
||||
.eicon.a{width:33px;height:33px;border-radius:10px;background:var(--primary-light);font-size:15px;}
|
||||
.eicon.b{width:36px;height:36px;border-radius:12px;background:var(--primary-light);font-size:16px;}
|
||||
.elabel{margin-top:4px;color:var(--text-strong);}
|
||||
.elabel.a{font-size:12px;font-weight:500;}
|
||||
.elabel.b{font-size:13px;font-weight:500;}
|
||||
|
||||
/* 区块标题 */
|
||||
.sec{padding:0 12px;}
|
||||
.sec.a{margin-top:16px;} .sec.b{margin-top:20px;}
|
||||
.shead{display:flex;align-items:center;justify-content:space-between;}
|
||||
.shead.a{padding:11px 12px 11px 0;} .shead.b{padding:8px 0;}
|
||||
.st-a{font-size:18px;font-weight:600;color:var(--text-primary);}
|
||||
.st-b{font-size:20px;font-weight:600;color:var(--text-strong);}
|
||||
.ssub{margin-left:8px;color:var(--text-tertiary);}
|
||||
.ssub.a{font-size:13px;} .ssub.b{font-size:12px;}
|
||||
.sact{display:flex;align-items:center;}
|
||||
.sact.a{font-size:13px;color:var(--primary);height:18px;}
|
||||
.sact.b{font-size:13px;color:var(--primary-strong);min-height:44px;padding-left:12px;font-weight:500;}
|
||||
.caret{font-size:11px;margin-left:2px;}
|
||||
|
||||
/* 卡片 */
|
||||
.card{background:#fff;display:flex;align-items:center;overflow:hidden;}
|
||||
.card.a{border-radius:13px;box-shadow:0 4px 14px rgba(35,35,95,.07);}
|
||||
.card.b{border-radius:16px;box-shadow:var(--shadow-card);}
|
||||
.rec{display:flex;align-items:center;gap:8px;padding:7px 8px;}
|
||||
.rec .av{width:44px;height:44px;border-radius:50%;background:linear-gradient(135deg,#D9D3F0,#BFC8EE);flex:none;}
|
||||
.rec .info{flex:1;min-width:0;}
|
||||
.rec .nm-a{font-size:13px;font-weight:700;color:var(--text-strong);}
|
||||
.rec .nm-b{font-size:14px;font-weight:700;color:var(--text-strong);}
|
||||
.rec .meta{font-size:12px;color:var(--text-tertiary);margin-top:2px;}
|
||||
.rec .tags{display:flex;gap:4px;margin-top:4px;}
|
||||
.rec .tag{padding:2px 5px;border-radius:6px;background:var(--accent-purple-light);color:var(--accent-purple-strong);font-size:12px;}
|
||||
.heart-a{width:31px;height:31px;border-radius:50%;background:var(--primary-light);display:flex;align-items:center;justify-content:center;font-size:14px;}
|
||||
.heart-b{width:44px;height:44px;border-radius:50%;background:var(--primary-light);display:flex;align-items:center;justify-content:center;font-size:16px;}
|
||||
.divider{height:1px;background:var(--divider);}
|
||||
|
||||
/* 空态 */
|
||||
.empty{background:#fff;display:flex;align-items:center;gap:8px;}
|
||||
.empty.a{border-radius:13px;padding:11px;box-shadow:0 4px 14px rgba(35,35,95,.07);}
|
||||
.empty.b{border-radius:16px;padding:12px;box-shadow:var(--shadow-card);}
|
||||
.logo-s{width:38px;height:38px;border-radius:10px;background:var(--grad-smoke);flex:none;
|
||||
display:flex;align-items:center;justify-content:center;color:#fff;font-size:13px;font-weight:700;}
|
||||
.et-a{font-size:12.5px;font-weight:600;color:var(--text-strong);}
|
||||
.et-b{font-size:14px;font-weight:600;color:var(--text-strong);}
|
||||
.ed{font-size:12px;color:var(--text-tertiary);line-height:1.4;margin-top:2px;}
|
||||
.ed.b{font-size:13px;}
|
||||
.eacts{display:flex;flex-direction:column;gap:4px;}
|
||||
.ebtn{display:flex;align-items:center;justify-content:center;border-radius:12px;}
|
||||
.ebtn.a{min-width:56px;height:24px;padding:0 6px;font-size:12px;}
|
||||
.ebtn.b{min-width:60px;height:44px;padding:0 8px;font-size:13px;font-weight:500;border-radius:999px;}
|
||||
.ebtn.sec-a{color:var(--accent-purple-strong);background:var(--accent-purple-light);}
|
||||
.ebtn.sec-b{color:var(--accent-purple-strong);background:var(--accent-purple-light);}
|
||||
.ebtn.pri-a{color:#fff;background:var(--grad-smoke);}
|
||||
.ebtn.pri-b{color:#fff;background:var(--grad-solid);}
|
||||
.hidden-note{margin-top:8px;font-size:11.5px;color:var(--danger-strong);
|
||||
background:var(--danger-light);border-radius:8px;padding:6px 8px;}
|
||||
.fixed-note{margin-top:8px;font-size:11.5px;color:var(--success-strong);
|
||||
background:var(--success-light);border-radius:8px;padding:6px 8px;}
|
||||
|
||||
/* 注释气泡 */
|
||||
.callout{display:flex;gap:10px;align-items:flex-start;margin-top:14px;max-width:375px;}
|
||||
.callout .bullet{flex:none;width:20px;height:20px;border-radius:50%;background:var(--primary-solid);color:#fff;
|
||||
font-size:11.5px;display:flex;align-items:center;justify-content:center;font-weight:700;margin-top:1px;}
|
||||
.callout .txt{font-size:12.5px;color:var(--doc-mute);}
|
||||
.callout .txt b{color:var(--doc-ink);}
|
||||
|
||||
table{width:100%;border-collapse:collapse;font-size:13px;}
|
||||
th,td{text-align:left;padding:9px 10px;border-bottom:1px solid var(--divider);vertical-align:top;}
|
||||
th{color:var(--doc-mute);font-weight:600;font-size:12.5px;background:var(--bg-subtle);}
|
||||
td code{background:var(--bg-subtle);border:1px solid var(--divider);border-radius:5px;padding:1px 5px;font-size:12px;}
|
||||
.bad{color:var(--danger-strong);font-weight:600;}
|
||||
.good{color:var(--success-strong);font-weight:600;}
|
||||
.sw{display:inline-flex;align-items:center;gap:7px;}
|
||||
.dot{width:14px;height:14px;border-radius:4px;border:1px solid rgba(0,0,0,.08);flex:none;}
|
||||
ul.clean{margin:8px 0 0;padding-left:18px;font-size:13.5px;color:var(--text-secondary);}
|
||||
ul.clean li{margin:5px 0;}
|
||||
ul.clean code{background:var(--bg-subtle);border:1px solid var(--divider);border-radius:5px;padding:1px 5px;font-size:12px;}
|
||||
.gate{display:flex;flex-wrap:wrap;gap:10px;}
|
||||
.gate .g{flex:1 1 210px;border:1px solid #CFE9DC;background:var(--success-light);border-radius:11px;padding:12px 14px;}
|
||||
.gate .g .n{font-size:19px;font-weight:700;color:var(--success-strong);}
|
||||
.gate .g .l{font-size:12.5px;color:var(--text-secondary);margin-top:2px;}
|
||||
.warnbox{border:1px solid #F2E3C4;background:var(--warning-light);border-radius:11px;padding:14px 16px;font-size:13px;color:#6B4E12;}
|
||||
.warnbox b{color:var(--warning-strong);}
|
||||
footer{margin-top:34px;font-size:12.5px;color:var(--doc-mute);text-align:center;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<h1>善缘丹阳 · 小程序设计系统 v2 改造对照</h1>
|
||||
<p class="sub">以首页为样板页,展示「设计底座落地 + 氛围层 + 排版层级 + 可访问性」四类改动的实际效果。</p>
|
||||
<div class="meta">
|
||||
<span class="chip">基线 <b>main @ 825fd41</b></span>
|
||||
<span class="chip">改动文件 <b>80</b></span>
|
||||
<span class="chip">wxss 覆盖 <b>88 / 88</b></span>
|
||||
<span class="chip">UI 契约门禁 <b>18 / 18 PASS</b></span>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>一、量化指标</h2>
|
||||
<p class="lead">改动前的问题不是「不够好看」,而是设计底座没落地 —— 88 个样式文件里 78% 绕过了 token。</p>
|
||||
<div class="metrics">
|
||||
<div class="metric">
|
||||
<div class="k">页面层硬编码颜色</div>
|
||||
<div class="v flat">0<span class="from">770</span></div>
|
||||
<div class="d">仅 app.wxss 保留 56 个 token 定义色</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="k">不可读字号(<12px 非装饰)</div>
|
||||
<div class="v flat">0<span class="from">140</span></div>
|
||||
<div class="d">元信息下限统一到 24rpx(12px)</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="k">非法字重(550/650/750)</div>
|
||||
<div class="v flat">0<span class="from">30</span></div>
|
||||
<div class="d">只保留 400/500/600/700 四档</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="k">未定义的 token 引用</div>
|
||||
<div class="v flat">0<span class="from">2</span></div>
|
||||
<div class="d">此前静默失效(背景/过渡丢失)</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="k">未达标文字对比度</div>
|
||||
<div class="v flat">0<span class="from">41</span></div>
|
||||
<div class="d">3.10:1 → 5.33:1</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="k">白字压在浅渐变上的 CTA</div>
|
||||
<div class="v flat">0<span class="from">10</span></div>
|
||||
<div class="d">2.77:1 → 4.73:1</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="k">品牌色实心底 + 白字</div>
|
||||
<div class="v flat">0<span class="from">32</span></div>
|
||||
<div class="d">按钮/标签/徽标统一换 solid 变体</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="k">低于 44px 的触摸目标</div>
|
||||
<div class="v flat">0<span class="from">42</span></div>
|
||||
<div class="d">组件库按钮 40px、喜欢键 31px</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>二、首页对照</h2>
|
||||
<p class="lead">左侧是改造前(HEAD 版本的真实取值),右侧是改造后。两侧均用 app.wxss 里的真实 token 渲染,1rpx = 0.5px。</p>
|
||||
<div class="phones">
|
||||
|
||||
<!-- ══════════ 改造前 ══════════ -->
|
||||
<div class="phone-col">
|
||||
<span class="phone-tag">改造前</span>
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="night-a">
|
||||
<div class="statusbar">9:41</div>
|
||||
<div class="brand">
|
||||
<div class="brand-l">
|
||||
<div class="logo">善</div>
|
||||
<div style="margin-left:6px">
|
||||
<div class="brand-name-a">善缘丹阳</div>
|
||||
<div class="brand-slo-a">公益婚恋服务平台</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg">消息</div>
|
||||
</div>
|
||||
<div class="strip-a">
|
||||
<div class="st-copy">
|
||||
<div class="st-title-a">完善资料,开启安心相遇</div>
|
||||
<div class="st-desc">补全 3 项信息即可进入推荐</div>
|
||||
</div>
|
||||
<div class="cta-a">去完善</div>
|
||||
</div>
|
||||
<div class="banner-a">
|
||||
<div class="scrim"></div>
|
||||
<div class="banner-copy">
|
||||
<div class="kick">公益 · 本地 · 真实 · 安全</div>
|
||||
<div class="bt-a">在丹阳,遇见更好的你</div>
|
||||
<div class="bs-a">认真认识,温暖相伴</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entries a">
|
||||
<div class="egrid">
|
||||
<div class="eitem a"><div class="eicon a">❤</div><div class="elabel a">找对象</div></div>
|
||||
<div class="eitem a"><div class="eicon a">🎈</div><div class="elabel a">活动</div></div>
|
||||
<div class="eitem a"><div class="eicon a">👤</div><div class="elabel a">红娘</div></div>
|
||||
<div class="eitem a"><div class="eicon a">📖</div><div class="elabel a">课堂</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec a">
|
||||
<div class="shead a">
|
||||
<div><span class="st-a">今日推荐</span><span class="ssub a">为你认真筛选</span></div>
|
||||
<div class="sact a">查看更多<span class="caret">›</span></div>
|
||||
</div>
|
||||
<div class="card a">
|
||||
<div class="rec">
|
||||
<div class="av"></div>
|
||||
<div class="info">
|
||||
<div class="nm-a">林晓 · 28</div>
|
||||
<div class="meta">丹阳 · 教师 · 本科</div>
|
||||
<div class="tags"><span class="tag">同城</span><span class="tag">年龄相近</span></div>
|
||||
</div>
|
||||
<div class="heart-a">❤</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="rec">
|
||||
<div class="av"></div>
|
||||
<div class="info">
|
||||
<div class="nm-a">周敏 · 30</div>
|
||||
<div class="meta">丹阳 · 医护 · 硕士</div>
|
||||
<div class="tags"><span class="tag">同城</span></div>
|
||||
</div>
|
||||
<div class="heart-a">❤</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<div class="callout"><div class="bullet">1</div><div class="txt">
|
||||
<b>夜空只有一条平渐变</b>:参考图里的暖粉光晕、星点、玻璃质感全部缺失,头部是「一块紫布」。
|
||||
</div></div>
|
||||
<div class="callout"><div class="bullet">2</div><div class="txt">
|
||||
<b>层级读不出来</b>:hero 标题 17.5px、区块标题 18px、卡片标题 12.5px —— hero 与区块标题几乎同号。
|
||||
</div></div>
|
||||
<div class="callout"><div class="bullet">3</div><div class="txt">
|
||||
<b>触摸目标不足</b>:「去完善」按钮高 24px、「查看更多」18px,远低于 44px 无障碍下限。
|
||||
</div></div>
|
||||
<div class="callout"><div class="bullet">4</div><div class="txt">
|
||||
<b>对比度不达标</b>:CTA 白字压在珊瑚粉渐变上仅 <span class="bad">2.77:1</span>;文字链接 <code>#FF4F91</code> 仅 <span class="bad">3.10:1</span>。
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
<!-- ══════════ 改造后 ══════════ -->
|
||||
<div class="phone-col">
|
||||
<span class="phone-tag new">改造后</span>
|
||||
<div class="phone"><div class="screen">
|
||||
<div class="night-b">
|
||||
<div class="statusbar">9:41</div>
|
||||
<div class="brand">
|
||||
<div class="brand-l">
|
||||
<div class="logo b">善</div>
|
||||
<div style="margin-left:8px">
|
||||
<div class="brand-name-b">善缘丹阳</div>
|
||||
<div class="brand-slo-b">公益婚恋服务平台</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="msg">消息</div>
|
||||
</div>
|
||||
<div class="strip-b">
|
||||
<div class="st-copy">
|
||||
<div class="st-title-b">完善资料,开启安心相遇</div>
|
||||
<div class="st-desc">补全 3 项信息即可进入推荐</div>
|
||||
</div>
|
||||
<div class="cta-b">去完善</div>
|
||||
</div>
|
||||
<div class="banner-b">
|
||||
<div class="scrim"></div>
|
||||
<div class="banner-copy">
|
||||
<div class="kick">公益 · 本地 · 真实 · 安全</div>
|
||||
<div class="bt-b">在丹阳,遇见更好的你</div>
|
||||
<div class="bs-b">认真认识,温暖相伴</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entries b">
|
||||
<div class="egrid">
|
||||
<div class="eitem b"><div class="eicon b">❤</div><div class="elabel b">找对象</div></div>
|
||||
<div class="eitem b"><div class="eicon b">🎈</div><div class="elabel b">活动</div></div>
|
||||
<div class="eitem b"><div class="eicon b">👤</div><div class="elabel b">红娘</div></div>
|
||||
<div class="eitem b"><div class="eicon b">📖</div><div class="elabel b">课堂</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sec b">
|
||||
<div class="shead b">
|
||||
<div><span class="st-b">今日推荐</span><span class="ssub b">为你认真筛选</span></div>
|
||||
<div class="sact b">查看更多<span class="caret">›</span></div>
|
||||
</div>
|
||||
<div class="card b">
|
||||
<div class="rec">
|
||||
<div class="av"></div>
|
||||
<div class="info">
|
||||
<div class="nm-b">林晓 · 28</div>
|
||||
<div class="meta">丹阳 · 教师 · 本科</div>
|
||||
<div class="tags"><span class="tag">同城</span><span class="tag">年龄相近</span></div>
|
||||
</div>
|
||||
<div class="heart-b">❤</div>
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<div class="rec">
|
||||
<div class="av"></div>
|
||||
<div class="info">
|
||||
<div class="nm-b">周敏 · 30</div>
|
||||
<div class="meta">丹阳 · 医护 · 硕士</div>
|
||||
<div class="tags"><span class="tag">同城</span></div>
|
||||
</div>
|
||||
<div class="heart-b">❤</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></div>
|
||||
|
||||
<div class="callout"><div class="bullet">1</div><div class="txt">
|
||||
<b>三层叠加做成氛围</b>:<code>--mini-starfield</code> 星点 + 夜空渐变 + 两个定位光晕(右上珊瑚粉、左下靛蓝)。全部用 CSS 渐变,零图片、零额外请求。
|
||||
</div></div>
|
||||
<div class="callout"><div class="bullet">2</div><div class="txt">
|
||||
<b>四级排版阶梯</b>:hero <b>24px/700</b> > 区块标题 <b>20px/600</b> > 卡片标题 <b>14px/600</b> > 元信息 12px。
|
||||
</div></div>
|
||||
<div class="callout"><div class="bullet">3</div><div class="txt">
|
||||
<b>触摸目标全部达标</b>:CTA 44px;区块标题右侧「查看更多」由 18px 提到 44px 热区,且热区向左补白不挤压标题。
|
||||
</div></div>
|
||||
<div class="callout"><div class="bullet">4</div><div class="txt">
|
||||
<b>对比度达标</b>:CTA 换 <code>--gradient-brand-solid</code> → <span class="good">4.73:1</span>;文字链接换 <code>--color-primary-strong</code> → <span class="good">5.33:1</span>。
|
||||
</div></div>
|
||||
<div class="callout"><div class="bullet">5</div><div class="txt">
|
||||
<b>标题与卡片对齐</b>:旧版标题比卡片多缩进 32rpx(.section 的 24rpx 与组件自带 32rpx 叠加),安全提醒卡还因 <code>padding:0</code> 顶到屏幕边缘。
|
||||
</div></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>三、小屏(≤340px)的行为差异</h2>
|
||||
<p class="lead">这是本轮改动里唯一一处「功能级」缺陷修复,值得单独看。</p>
|
||||
<div style="display:flex;gap:34px;flex-wrap:wrap;justify-content:center">
|
||||
<div class="phone-col">
|
||||
<span class="phone-tag">改造前</span>
|
||||
<div class="phone" style="width:320px"><div class="screen" style="background:var(--bg-page);padding-bottom:12px">
|
||||
<div style="background:linear-gradient(160deg,var(--night-start),var(--night-end));height:70px;border-radius:0 0 21px 21px"></div>
|
||||
<div class="entries a" style="margin-top:-7px">
|
||||
<div class="egrid">
|
||||
<div class="eitem a"><div class="eicon a">❤</div><div class="elabel a">找对象</div></div>
|
||||
<div class="eitem a"><div class="eicon a">🎈</div><div class="elabel a">活动</div></div>
|
||||
<div class="eitem a"><div class="eicon a">👤</div><div class="elabel a">红娘</div></div>
|
||||
<div class="eitem a"><div class="eicon a">📖</div><div class="elabel a">课堂</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sec a">
|
||||
<div class="empty a">
|
||||
<div class="logo-s">善</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="et-a">暂时没有完全符合的人</div>
|
||||
<div class="ed">我们坚持 Required 条件</div>
|
||||
</div>
|
||||
<div style="width:56px"></div>
|
||||
</div>
|
||||
<div class="hidden-note">@media (max-width:340px){ .empty-actions{display:none} } —— 两个操作入口整块消失</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
<div class="phone-col">
|
||||
<span class="phone-tag new">改造后</span>
|
||||
<div class="phone" style="width:320px"><div class="screen" style="background:var(--bg-page);padding-bottom:12px">
|
||||
<div style="background:linear-gradient(160deg,var(--night-start),var(--night-end));height:70px;border-radius:0 0 24px 24px"></div>
|
||||
<div class="entries b" style="margin-top:-7px">
|
||||
<div class="egrid">
|
||||
<div class="eitem b"><div class="eicon b">❤</div><div class="elabel b">找对象</div></div>
|
||||
<div class="eitem b"><div class="eicon b">🎈</div><div class="elabel b">活动</div></div>
|
||||
<div class="eitem b"><div class="eicon b">👤</div><div class="elabel b">红娘</div></div>
|
||||
<div class="eitem b"><div class="eicon b">📖</div><div class="elabel b">课堂</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sec b">
|
||||
<div class="empty b">
|
||||
<div class="logo-s">善</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div class="et-b">暂时没有完全符合的人</div>
|
||||
<div class="ed b">我们坚持 Required 条件</div>
|
||||
</div>
|
||||
<div class="eacts">
|
||||
<div class="ebtn sec-b">调整条件</div>
|
||||
<div class="ebtn pri-b">公益红娘</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fixed-note">小屏只收紧尺寸与字号,不再砍掉任何入口</div>
|
||||
</div>
|
||||
</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>四、设计 token 变更要点</h2>
|
||||
<p class="lead">品牌色分级是这一版最重要的结构性决定:基础品牌色 <code>#E96B6B</code> 无论做背景还是做文字都不满足 4.5:1,因此拆成三个语义层。</p>
|
||||
<table>
|
||||
<thead><tr><th style="width:230px">Token</th><th style="width:130px">取值</th><th style="width:110px">对比度</th><th>用途限制</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><span class="sw"><span class="dot" style="background:#E96B6B"></span><code>--color-primary</code></span></td>
|
||||
<td><code>#E96B6B</code></td><td class="bad">3.10:1</td>
|
||||
<td>仅大色块、渐变、光晕、图标底。禁止承载文字。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="sw"><span class="dot" style="background:#B8434A"></span><code>--color-primary-strong</code></span></td>
|
||||
<td><code>#B8434A</code></td><td class="good">5.33:1</td>
|
||||
<td>所有品牌色文字、小图标、纯文字链接。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><span class="sw"><span class="dot" style="background:#C24C50"></span><code>--color-primary-solid</code></span></td>
|
||||
<td><code>#C24C50</code></td><td class="good">4.73:1</td>
|
||||
<td>实心按钮底(配白字)、小徽标底色。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--gradient-brand</code></td>
|
||||
<td>#FF6A78 → #E96B6B</td><td class="bad">2.77:1</td>
|
||||
<td>明亮活泼,仅装饰填充,不得压白字。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--gradient-brand-solid</code></td>
|
||||
<td>#C24C50 → #B8434A</td><td class="good">4.73 ~ 5.33</td>
|
||||
<td>所有承载白字的 CTA 渐变(本轮新增)。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--color-text-tertiary</code></td>
|
||||
<td>#777 → <code>#646C85</code></td><td class="good">5.22:1</td>
|
||||
<td>说明文字。旧值 4.48:1 卡在阈值下沿。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--mini-glow-coral</code> / <code>--mini-glow-indigo</code></td>
|
||||
<td>rgba 粉 / 蓝</td><td class="flat">—</td>
|
||||
<td>夜空光晕层(本轮新增,参考图关键特征)。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--mini-starfield</code></td>
|
||||
<td>5 个径向渐变</td><td class="flat">—</td>
|
||||
<td>星点层(本轮新增),叠在夜空渐变之上。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--mini-glass</code> / <code>--mini-hairline</code></td>
|
||||
<td>rgba(255,255,255,.10/.16)</td><td class="flat">—</td>
|
||||
<td>深底玻璃面与分隔线(本轮新增)。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--tap-min</code> / <code>--tap-gap</code></td>
|
||||
<td>88rpx / 16rpx</td><td class="flat">44px</td>
|
||||
<td>触摸目标下限与最小间距(本轮新增)。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>--ease-spring</code> / <code>--press-scale</code></td>
|
||||
<td>cubic-bezier(.34,1.4,.64,1) / 0.97</td><td class="flat">—</td>
|
||||
<td>按压回弹与缩放(本轮新增)。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>五、本轮修掉的实质缺陷</h2>
|
||||
<p class="lead">不只是样式替换 —— 其中三条是会被静默吞掉的语法级错误。</p>
|
||||
<ul class="clean">
|
||||
<li><b>3 处声明粘连(脚本副作用)</b>:<code>recommend-card</code>、<code>discover</code>、<code>service-station</code> 里出现
|
||||
<code>box-shadow:…rgba(255,79,145,.18)min-width:var(--tap-min);</code> 这类<b>缺少分号</b>的合并声明。
|
||||
浏览器会把整条声明判为非法并丢弃 —— 于是「喜欢」按钮的 44px 热区与阴影双双静默失效,
|
||||
而花括号平衡、hex 配额等所有静态检查仍然全绿。</li>
|
||||
<li><b>2 处引用了不存在的 token</b>:<code>--button-press-feedback</code>、<code>--color-bg-input</code> 此前从未定义,
|
||||
导致过渡动画与输入框背景直接失效(无报错、无告警)。</li>
|
||||
<li><b>小屏砍掉操作入口</b>:<code>@media (max-width:340px)</code> 里 <code>.empty-actions{display:none}</code>,
|
||||
在窄屏机型上把「调整条件 / 公益红娘」两个入口整块移除,属功能级缺陷。</li>
|
||||
<li><b>安全提醒卡错位</b>:旧版 <code>.safety-card{padding:0}</code> 覆盖了 <code>.section</code> 的左右内边距,
|
||||
该卡片比其它区块宽 48rpx、直接贴到屏幕边缘。</li>
|
||||
<li><b>触摸目标普遍不足</b>:组件库主按钮 40px、小号 28px、纯文字变体 <code>min-height:auto</code>、
|
||||
「喜欢」键 31px、区块标题右侧 action 18px —— 全站已统一到 44px 下限。</li>
|
||||
<li><b>对比度系统性不达标</b>:41 处把品牌基础色当文字用(3.10:1)、32 处把基础色做实心底压白字、
|
||||
10 处白字压在明亮渐变上(2.77:1)。已按「文字用 strong / 实心底用 solid / 渐变按是否压字分流」三类语义收敛。</li>
|
||||
<li><b>字重机型不一致</b>:30 处 550/650/750 在 Android 无对应字重会回退,同一设计在不同机型上粗细不一致。</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2>六、验证结果</h2>
|
||||
<p class="lead">以下为实际执行结果,非声明性描述。</p>
|
||||
<div class="gate">
|
||||
<div class="g"><div class="n">18 / 18</div><div class="l">UI 契约门禁全部通过(源码结构 + 关键行为断言)</div></div>
|
||||
<div class="g"><div class="n">0</div><div class="l">未定义的 token 引用(扫描 88 个 wxss / 131 个 token)</div></div>
|
||||
<div class="g"><div class="n">88 / 88</div><div class="l">wxss 文件通过声明级体检(无粘连、无空规则、括号平衡)</div></div>
|
||||
<div class="g"><div class="n">0</div><div class="l">app.wxss 之外的硬编码颜色(56 个 hex 全部是 token 定义)</div></div>
|
||||
<div class="g"><div class="n">0</div><div class="l">非百位字重 / 非装饰类不可读字号</div></div>
|
||||
<div class="g"><div class="n">88 / 88</div><div class="l">文件编码统一为 UTF-8 无 BOM(与仓库既有约定一致)</div></div>
|
||||
</div>
|
||||
<div class="warnbox" style="margin-top:16px">
|
||||
<b>尚未验证的部分(不作已验收)</b><br />
|
||||
· 本页是<b>静态还原</b>,用真实 token 值复刻布局,不是小程序真机截图;动态横幅轮播、真实数据、字号在真实设备上的渲染需要在小程序开发者工具里目视确认。<br />
|
||||
· 氛围层用到的 <code>background</code> 多层渐变与 <code>radial-gradient</code> 需要在真机(iOS / Android 各一台)复验,尤其确认低端机上不出现色带。<br />
|
||||
· 布局类改动(首页重做、标题热区加高、触摸目标统一为 44px)尚无自动化视觉回归,需要人工过一遍主要页面。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
善缘丹阳公益婚恋服务平台 · 小程序设计系统 v2 · 生成于 2026-09-22 · 基线 main @ 825fd41
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 467 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 187 KiB |
@@ -1,11 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<settings xmlns="http://maven.apache.org/SETTINGS/1.0.0">
|
||||
<mirrors>
|
||||
<mirror>
|
||||
<id>central</id>
|
||||
<mirrorOf>central</mirrorOf>
|
||||
<name>Maven Central</name>
|
||||
<url>https://repo1.maven.org/maven2</url>
|
||||
</mirror>
|
||||
</mirrors>
|
||||
<!--
|
||||
本地构建必须走 aliyun 镜像:本机 Maven Central(repo1.maven.org)被 DNS 投毒,
|
||||
TLS 握手被中断("Remote host terminated the handshake"),直连 central 必然失败。
|
||||
aliyun 作为主源、central 作为兜底(仅当 aliyun 缺失某构件时才回退,正常构件不会触达 central)。
|
||||
CI runner 能直连 central,但 aliyun 同样可达,故该配置对 CI 也无害。
|
||||
-->
|
||||
<profiles>
|
||||
<profile>
|
||||
<id>aliyun-with-central-fallback</id>
|
||||
<repositories>
|
||||
<repository>
|
||||
<id>aliyun-public</id>
|
||||
<name>Aliyun Public</name>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
<repository>
|
||||
<id>central</id>
|
||||
<name>Maven Central</name>
|
||||
<url>https://repo.maven.apache.org/maven2</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</repository>
|
||||
</repositories>
|
||||
<pluginRepositories>
|
||||
<pluginRepository>
|
||||
<id>aliyun-plugin-public</id>
|
||||
<name>Aliyun Plugin Public</name>
|
||||
<url>https://maven.aliyun.com/repository/public</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</pluginRepository>
|
||||
<pluginRepository>
|
||||
<id>central-plugin</id>
|
||||
<name>Maven Central Plugins</name>
|
||||
<url>https://repo.maven.apache.org/maven2</url>
|
||||
<releases><enabled>true</enabled></releases>
|
||||
<snapshots><enabled>false</enabled></snapshots>
|
||||
</pluginRepository>
|
||||
</pluginRepositories>
|
||||
</profile>
|
||||
</profiles>
|
||||
<activeProfiles>
|
||||
<activeProfile>aliyun-with-central-fallback</activeProfile>
|
||||
</activeProfiles>
|
||||
</settings>
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
|
||||
<groupId>com.danyang.match</groupId>
|
||||
<artifactId>danyang-match-platform</artifactId>
|
||||
<version>1.0.0-RC.1</version>
|
||||
<version>1.2.0</version>
|
||||
<name>Danyang Match Platform</name>
|
||||
<description>丹阳公益婚恋服务平台 V1.0 - Backend</description>
|
||||
<description>丹阳公益婚恋服务平台 V1.1 - Backend</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.danyang.match.platform.shared.dto;
|
||||
|
||||
import com.danyang.match.platform.shared.policy.MatchingPolicy;
|
||||
import com.danyang.match.platform.shared.policy.ProfilePolicy;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.HashMap;
|
||||
@@ -11,8 +14,12 @@ import java.util.Map;
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/app")
|
||||
@RequiredArgsConstructor
|
||||
public class AppController {
|
||||
|
||||
private final ProfilePolicy profilePolicy;
|
||||
private final MatchingPolicy matchingPolicy;
|
||||
|
||||
/**
|
||||
* 获取应用配置
|
||||
* GET /api/v1/app/config
|
||||
@@ -36,13 +43,17 @@ public class AppController {
|
||||
config.put("features", features);
|
||||
|
||||
// 业务限制参数
|
||||
// 注:maxProfilePhotos / matchScoreThreshold 改由统一配置源(ProfilePolicy / MatchingPolicy)
|
||||
// 下发,禁止在此写死(PROFILE-001 / MATCH-001)。其余项(dailyRecommendationLimit、
|
||||
// maxInterestTags、selfIntroMaxLength、recommendationCooldownDays)本轮未纳入,
|
||||
// 仍为硬编码,已在 config-drift-audit 中登记为「待统一」未来项。
|
||||
Map<String, Object> businessLimits = new HashMap<>();
|
||||
businessLimits.put("dailyRecommendationLimit", 6);
|
||||
businessLimits.put("maxProfilePhotos", 6);
|
||||
businessLimits.put("maxProfilePhotos", profilePolicy.getMaxPhotoCount());
|
||||
businessLimits.put("maxInterestTags", 8);
|
||||
businessLimits.put("selfIntroMaxLength", 500);
|
||||
businessLimits.put("recommendationCooldownDays", 180);
|
||||
businessLimits.put("matchScoreThreshold", 60);
|
||||
businessLimits.put("matchScoreThreshold", matchingPolicy.getRecommendationMinScore());
|
||||
config.put("businessLimits", businessLimits);
|
||||
|
||||
// 审核相关
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.danyang.match.platform.account.controller;
|
||||
|
||||
import com.danyang.match.platform.account.service.DataRetentionService;
|
||||
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.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 注销数据到期处置(Operator 端,2026-09-19 数据保留决策落地)。
|
||||
*
|
||||
* <p>权限 {@code account.retention.purge}(V049)仅授予 super_admin ——
|
||||
* 处置不可逆,不做批量授权。流程为「人工审核」模式:先查到期清单,
|
||||
* 运营确认后逐个执行;保留期内(产品确认下限 36 个月)的账号服务端拒绝处置。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/operator/account/retention")
|
||||
@RequiredArgsConstructor
|
||||
public class AccountRetentionOperatorController {
|
||||
|
||||
private final DataRetentionService dataRetentionService;
|
||||
|
||||
/**
|
||||
* 保留期已满的注销账号清单
|
||||
* GET /api/v1/operator/account/retention/due
|
||||
*/
|
||||
@GetMapping("/due")
|
||||
@PreAuthorize("hasAuthority('account.retention.purge')")
|
||||
public ApiResponse<List<Map<String, Object>>> dueAccounts() {
|
||||
return ApiResponse.ok(dataRetentionService.listDueAccounts());
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行到期匿名化清除(幂等)
|
||||
* POST /api/v1/operator/account/retention/{userId}/purge
|
||||
*/
|
||||
@PostMapping("/{userId}/purge")
|
||||
@PreAuthorize("hasAuthority('account.retention.purge')")
|
||||
public ApiResponse<Map<String, Object>> purge(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String userId) {
|
||||
if (principal == null) {
|
||||
return ApiResponse.unauthorized();
|
||||
}
|
||||
return ApiResponse.ok(dataRetentionService.purgeExpired(userId, principal.id()));
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.danyang.match.platform.activity.mapper.ActivityRegistrationMapper;
|
||||
import com.danyang.match.platform.match.mapper.RecommendationRecordMapper;
|
||||
import com.danyang.match.platform.notification.service.NotificationService;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.profile.service.PrivacyService;
|
||||
import com.danyang.match.platform.risk.mapper.ReportMapper;
|
||||
import com.danyang.match.platform.risk.mapper.RiskActionMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
@@ -38,6 +39,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
|
||||
/**
|
||||
* 账号生命周期服务
|
||||
@@ -68,6 +71,7 @@ public class AccountLifecycleService {
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final PrivacyService privacyService;
|
||||
private final ActivityRegistrationMapper registrationMapper;
|
||||
private final RecommendationRecordMapper recommendationRecordMapper;
|
||||
private final ReportMapper reportMapper;
|
||||
@@ -83,6 +87,7 @@ public class AccountLifecycleService {
|
||||
private final ChatService chatService;
|
||||
private final UserIdentityMapper userIdentityMapper;
|
||||
private final CounselingFollowupMapper counselingFollowupMapper;
|
||||
private final com.danyang.match.platform.matchmaker.mapper.MatchmakerServiceMapper matchmakerServiceMapper;
|
||||
|
||||
// ==================== 退出婚恋服务(暂停推荐) ====================
|
||||
|
||||
@@ -94,7 +99,7 @@ public class AccountLifecycleService {
|
||||
public Map<String, Object> withdrawDatingService(String userId, String operatorId) {
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
|
||||
}
|
||||
|
||||
// 检查是否有进行中的Match(不能直接退出)
|
||||
@@ -132,7 +137,7 @@ public class AccountLifecycleService {
|
||||
public Map<String, Object> resumeDatingService(String userId) {
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
|
||||
}
|
||||
user.setRecommendationEnabled(true);
|
||||
user.setRelationshipStatus(Constants.RELATIONSHIP_STATUS_SEARCHING);
|
||||
@@ -159,13 +164,14 @@ public class AccountLifecycleService {
|
||||
public Map<String, Object> checkCanWithdraw(String userId) {
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
|
||||
}
|
||||
|
||||
Map<String, Object> blockers = new HashMap<>();
|
||||
|
||||
// 检查是否有进行中的红娘服务
|
||||
long activeMatchmakerServices = 0; // 需要 MatchmakerServiceMapper
|
||||
// 检查是否有进行中的红娘服务(2e 接线:原硬编码 0,blocker 从未生效)。
|
||||
// 状态口径 = 生产 chk_matchmaker_status 白名单的非终态值(submitted/assigned/in_service/waiting_user)。
|
||||
long activeMatchmakerServices = matchmakerServiceMapper.countActiveByUserId(userId);
|
||||
if (activeMatchmakerServices > 0) {
|
||||
blockers.put("matchmakerService", "您有进行中的红娘服务,请先完成或取消");
|
||||
}
|
||||
@@ -243,7 +249,7 @@ public class AccountLifecycleService {
|
||||
public Map<String, Object> closeAccount(String userId, String operatorId, String reason) {
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new RuntimeException("用户不存在");
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在");
|
||||
}
|
||||
|
||||
// 1. 注销前检查(信息性,不阻断):让 GET /can-withdraw 能把待处理业务展示给用户/运营。
|
||||
@@ -268,8 +274,8 @@ public class AccountLifecycleService {
|
||||
// 2b. 通知所有活跃 Match 对方
|
||||
notifyActiveMatches(user.getId(), "account_closed");
|
||||
|
||||
// 2c. 通知红娘(如果有)
|
||||
notifyMatchmaker(user.getId());
|
||||
// 2c. 终止残留红娘服务(竞态兜底,见方法注释;不能向 operator 发通知——Phase 18 D2 FK 教训)
|
||||
terminateActiveMatchmakerServices(user.getId());
|
||||
|
||||
// 2d. 取消所有未开始的活动报名
|
||||
cancelPendingActivityRegistrations(userId);
|
||||
@@ -294,6 +300,9 @@ public class AccountLifecycleService {
|
||||
|
||||
// 4. 记录退出时间
|
||||
Instant now = Instant.now();
|
||||
// V049:注销时间落库 —— 数据保留到期(closed_at + account.data-retention-months,
|
||||
// 产品 2026-09-19 确认下限 36 个月)由此起算;此前只存在于日志,保留期无法计算。
|
||||
user.setClosedAt(now);
|
||||
user.setUpdatedAt(now);
|
||||
userMapper.updateById(user);
|
||||
|
||||
@@ -325,7 +334,7 @@ public class AccountLifecycleService {
|
||||
public Map<String, Object> withdrawConsent(String userId, String consentType) {
|
||||
ConsentRecord record = consentRecordMapper.findByUserAndType(userId, consentType).orElse(null);
|
||||
if (record == null) {
|
||||
throw new RuntimeException("未找到该同意记录");
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "未找到该同意记录");
|
||||
}
|
||||
record.setAgreed(false);
|
||||
consentRecordMapper.updateById(record);
|
||||
@@ -342,26 +351,57 @@ public class AccountLifecycleService {
|
||||
// ==================== 隐私设置 ====================
|
||||
|
||||
/**
|
||||
* 获取隐私设置
|
||||
* 小程序 privacy-settings 页的契约键集(产品裁定:隐私设置键集<b>以小程序为准</b>)。
|
||||
* 用于入参校验:未知键宽容忽略,但<b>已知键类型错误必须显式报错</b> ——
|
||||
* 若静默丢弃,用户会看到"保存成功"而实际未生效,这类缺陷最难排查。
|
||||
*/
|
||||
private static final Set<String> PRIVACY_KEYS =
|
||||
Set.of("visibleInRecommendations", "allowActivityInvitation", "showLastOnline");
|
||||
|
||||
/**
|
||||
* 获取隐私设置。
|
||||
*
|
||||
* <p>原为 TODO 桩:永远返回写死的默认值、<b>不读库</b>,用户改过的设置读不回来。
|
||||
* 现委托 {@link PrivacyService} 读真实 {@code user_privacy_settings}。
|
||||
*/
|
||||
public Map<String, Object> getPrivacySettings(String userId) {
|
||||
// TODO: Phase 21 实现 user_privacy_settings 查询
|
||||
Map<String, Object> settings = new HashMap<>();
|
||||
settings.put("visibleInRecommendations", true);
|
||||
settings.put("allowActivityInvitation", true);
|
||||
settings.put("showLastOnline", false);
|
||||
return settings;
|
||||
return new HashMap<>(privacyService.getPrivacySettings(userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新隐私设置
|
||||
* 更新隐私设置。
|
||||
*
|
||||
* <p>原为 TODO 桩:只回 {@code {updated:true}}、<b>不落库</b>,
|
||||
* 用户看到"保存成功"后刷新即丢失。现委托 {@link PrivacyService} 真实 upsert。
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> updatePrivacySettings(String userId, Map<String, Object> settings) {
|
||||
// TODO: Phase 21 实现 user_privacy_settings 更新
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("updated", true);
|
||||
return result;
|
||||
return new HashMap<>(privacyService.updatePrivacySettings(userId, toBooleanMap(settings)));
|
||||
}
|
||||
|
||||
private Map<String, Boolean> toBooleanMap(Map<String, Object> settings) {
|
||||
Map<String, Boolean> typed = new HashMap<>();
|
||||
if (settings == null) {
|
||||
return typed;
|
||||
}
|
||||
for (Map.Entry<String, Object> e : settings.entrySet()) {
|
||||
String key = e.getKey();
|
||||
Object value = e.getValue();
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
if (value instanceof Boolean b) {
|
||||
typed.put(key, b);
|
||||
} else if (value instanceof String s
|
||||
&& ("true".equalsIgnoreCase(s) || "false".equalsIgnoreCase(s))) {
|
||||
// 容错:部分客户端会把 boolean 序列化成字符串
|
||||
typed.put(key, Boolean.valueOf(s));
|
||||
} else if (PRIVACY_KEYS.contains(key)) {
|
||||
throw new IllegalArgumentException("隐私设置字段类型错误: " + key);
|
||||
}
|
||||
// 未知键:宽容忽略
|
||||
}
|
||||
return typed;
|
||||
}
|
||||
|
||||
// ==================== 私有辅助方法 ====================
|
||||
@@ -392,11 +432,34 @@ public class AccountLifecycleService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知红娘服务已终止
|
||||
* 注销收尾(2e 接线,原 {@code notifyMatchmaker} 为 TODO 空转):把该用户残留的非终态
|
||||
* 红娘服务置为 cancelled。
|
||||
*
|
||||
* <p>正常路径下 {@code checkCanWithdraw} 的 blocker 会要求用户先完成/取消红娘服务,
|
||||
* 走到注销时不应再有非终态记录 —— 本方法是<b>竞态兜底</b>(blocker 检查与注销事务之间,
|
||||
* 红娘管理员恰好受理了新服务)。
|
||||
*
|
||||
* <p><b>为什么不是「发通知」</b>:Phase 18 D2 的教训 —— {@code notifications.user_id → users(id)}
|
||||
* 外键下,operator 不在 users 表,向红娘发用户通知在真实 PG 上必然使注销事务回滚。
|
||||
* 因此以状态收尾 + 日志代替「通知」,效果更实质且不会炸注销事务。
|
||||
*/
|
||||
private void notifyMatchmaker(String userId) {
|
||||
// TODO: 查询该用户的红娘服务,通知相关红娘
|
||||
log.info("通知红娘服务终止: userId={}", userId);
|
||||
private void terminateActiveMatchmakerServices(String userId) {
|
||||
List<com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord> active =
|
||||
matchmakerServiceMapper.selectList(
|
||||
new LambdaQueryWrapper<com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord>()
|
||||
.eq(com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord::getUserId, userId)
|
||||
.in(com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord::getStatus,
|
||||
Constants.MATCHMAKER_STATUS_SUBMITTED,
|
||||
Constants.MATCHMAKER_STATUS_ASSIGNED,
|
||||
Constants.MATCHMAKER_STATUS_IN_SERVICE,
|
||||
Constants.MATCHMAKER_STATUS_WAITING_USER)
|
||||
.isNull(com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord::getDeletedAt));
|
||||
for (com.danyang.match.platform.matchmaker.entity.MatchmakerServiceRecord svc : active) {
|
||||
svc.setStatus(Constants.MATCHMAKER_STATUS_CANCELLED);
|
||||
svc.setUpdatedAt(Instant.now());
|
||||
matchmakerServiceMapper.updateById(svc);
|
||||
log.info("注销收尾: 残留红娘服务已终止 userId={}, serviceId={}", userId, svc.getId());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
package com.danyang.match.platform.account.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibilityEvidence;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityEvidenceMapper;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.photo.entity.UserPhoto;
|
||||
import com.danyang.match.platform.photo.mapper.UserPhotoMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.danyang.match.platform.profile.mapper.UserProfileMapper;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import com.danyang.match.platform.shared.config.AccountConfig;
|
||||
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 lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 注销数据到期处置(批 3 收尾:数据保留流程,2026-09-19 产品决策落地)。
|
||||
*
|
||||
* <h3>决策与口径</h3>
|
||||
* 产品 2026-09-19 确认:注销后用户数据保留<b>不得低于 36 个月</b>(隐私政策 §3.3 原文
|
||||
* 「30 天内删除」与本决策冲突,已按用户最新明确指令同步修订政策文本)。
|
||||
* 保留期经 {@link AccountConfig#getDataRetentionMonths()} 配置化(AGENTS.md #12),
|
||||
* 到期 = {@code closed_at + retentionMonths}。
|
||||
*
|
||||
* <h3>「配置 + 人工审核」而非自动作业</h3>
|
||||
* 计划 Phase 21 的既定要求是「数据保留期限未确认的敏感材料采用配置和人工审核,
|
||||
* 不擅自永久保存或删除」。因此本服务只提供<b>人工审核的处置工具</b>:
|
||||
* {@code listDueAccounts()} 列出保留期已满的注销账号,由持
|
||||
* {@code account.retention.purge} 权限(V049,仅授 super_admin)的运营确认后
|
||||
* 调用 {@code purgeExpired()} 执行 —— <b>不引入定时自动删除</b>。
|
||||
*
|
||||
* <h3>处置内容(断开 PII 关联,保留统计与审计所需最小信息)</h3>
|
||||
* <ol>
|
||||
* <li>{@code users.wechat_openid} → {@code purged-<userId>}(不可再登录、不可回连微信身份;
|
||||
* phone 清空。列有 UNIQUE 约束,purged 前缀 + userId 保证唯一);</li>
|
||||
* <li>{@code user_profiles}:displayName → 「已注销用户」、selfIntro 清空
|
||||
* (性别/年龄段等非直接标识符保留,供匿名统计);</li>
|
||||
* <li>{@code user_photos} / {@code local_eligibility_evidences}:全部软删;</li>
|
||||
* <li>审计:{@code audit_logs} 记 {@code DATA_RETENTION_PURGE}。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p><b>如实登记的边界</b>:佐证/照片的<b>对象存储文件本体</b>不在此处删除
|
||||
* (需要对象存储客户端与逐文件凭证),由对象存储生命周期规则兜底 —— 登记于
|
||||
* {@code docs/superpowers/plans/2026-09-18-phase23-closeout-and-uat-readiness.md} 批 3。
|
||||
* 咨询摘要/正文已在<b>注销时</b>脱敏({@code anonymizeServiceHistory}),无需重复处理。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DataRetentionService {
|
||||
|
||||
static final String PURGED_OPENID_PREFIX = "purged-";
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final UserPhotoMapper userPhotoMapper;
|
||||
private final LocalEligibilityMapper localEligibilityMapper;
|
||||
private final LocalEligibilityEvidenceMapper evidenceMapper;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final AccountConfig accountConfig;
|
||||
|
||||
/** 保留到期时点:closed_at + retentionMonths(按 UTC 日历月计算,非 30 天近似) */
|
||||
public Instant retentionDueAt(Instant closedAt) {
|
||||
return closedAt.atZone(ZoneOffset.UTC).plusMonths(accountConfig.getDataRetentionMonths())
|
||||
.toInstant();
|
||||
}
|
||||
|
||||
/** 保留期已满、待人工审核处置的注销账号清单 */
|
||||
public List<Map<String, Object>> listDueAccounts() {
|
||||
Instant now = Instant.now();
|
||||
List<User> closed = userMapper.selectList(
|
||||
new LambdaQueryWrapper<User>()
|
||||
.eq(User::getAccountStatus, Constants.ACCOUNT_STATUS_CLOSED)
|
||||
.isNotNull(User::getClosedAt)
|
||||
.isNull(BaseEntity::getDeletedAt));
|
||||
List<Map<String, Object>> due = new ArrayList<>();
|
||||
for (User user : closed) {
|
||||
Instant dueAt = retentionDueAt(user.getClosedAt());
|
||||
if (dueAt.isAfter(now)) {
|
||||
continue; // 仍在保留期内 —— 产品确认的下限,不得提前处置
|
||||
}
|
||||
boolean alreadyPurged = user.getWechatOpenid() != null
|
||||
&& user.getWechatOpenid().startsWith(PURGED_OPENID_PREFIX);
|
||||
Map<String, Object> row = new HashMap<>();
|
||||
row.put("userId", user.getId());
|
||||
row.put("closedAt", user.getClosedAt());
|
||||
row.put("retentionMonths", accountConfig.getDataRetentionMonths());
|
||||
row.put("retentionDueAt", dueAt);
|
||||
row.put("purged", alreadyPurged);
|
||||
due.add(row);
|
||||
}
|
||||
return due;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行到期匿名化清除(人工审核后调用)。
|
||||
*
|
||||
* @return 处置结果;{@code purged=true} 表示此前已处置过(幂等重放)
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> purgeExpired(String userId, String operatorId) {
|
||||
User user = userMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new IllegalArgumentException("用户不存在");
|
||||
}
|
||||
if (!Constants.ACCOUNT_STATUS_CLOSED.equals(user.getAccountStatus())) {
|
||||
throw new IllegalArgumentException("仅限已注销账号执行到期处置");
|
||||
}
|
||||
if (user.getClosedAt() == null || retentionDueAt(user.getClosedAt()).isAfter(Instant.now())) {
|
||||
throw new IllegalArgumentException("该账号仍在保留期内(下限 "
|
||||
+ accountConfig.getDataRetentionMonths() + " 个月),不得提前处置");
|
||||
}
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
if (user.getWechatOpenid() != null
|
||||
&& user.getWechatOpenid().startsWith(PURGED_OPENID_PREFIX)) {
|
||||
result.put("purged", true);
|
||||
result.put("alreadyPurged", true);
|
||||
return result; // 幂等:重复调用不产生第二次审计与写入
|
||||
}
|
||||
|
||||
// 1. 断开账号级直接标识符。
|
||||
// ⚠️ 必须用 LambdaUpdateWrapper.set(..., null):MyBatis-Plus 默认 FieldStrategy=NOT_NULL,
|
||||
// updateById 时 phone=null 不会进 SQL(守卫实测:清 phone 断言失败,旧值仍在库)。
|
||||
userMapper.update(null, new LambdaUpdateWrapper<User>()
|
||||
.eq(User::getId, userId)
|
||||
.set(User::getWechatOpenid, PURGED_OPENID_PREFIX + userId)
|
||||
.set(User::getPhone, null));
|
||||
|
||||
// 2. 资料直接标识字段匿名化(selfIntro 置 null 同理须显式 set)
|
||||
userProfileMapper.findByUserId(userId).ifPresent(profile ->
|
||||
userProfileMapper.update(null, new LambdaUpdateWrapper<UserProfile>()
|
||||
.eq(UserProfile::getUserId, userId)
|
||||
.set(UserProfile::getDisplayName, "已注销用户")
|
||||
.set(UserProfile::getSelfIntro, null)));
|
||||
|
||||
// 3. 照片与本地资格佐证软删(文件本体由对象存储生命周期规则兜底,见类注释边界)。
|
||||
// 佐证表无 user_id 直连列,须经 user_local_eligibilities 关联。
|
||||
Instant now = Instant.now();
|
||||
for (UserPhoto photo : userPhotoMapper.selectList(
|
||||
new LambdaQueryWrapper<UserPhoto>().eq(UserPhoto::getUserId, userId))) {
|
||||
photo.setDeletedAt(now);
|
||||
userPhotoMapper.updateById(photo);
|
||||
}
|
||||
localEligibilityMapper.findByUserId(userId).ifPresent(el -> {
|
||||
for (LocalEligibilityEvidence evidence : evidenceMapper.selectList(
|
||||
new LambdaQueryWrapper<LocalEligibilityEvidence>()
|
||||
.eq(LocalEligibilityEvidence::getLocalEligibilityId, el.getId()))) {
|
||||
evidence.setDeletedAt(now);
|
||||
evidenceMapper.updateById(evidence);
|
||||
}
|
||||
});
|
||||
|
||||
// 4. 审计(不可逆操作必须留痕)
|
||||
AuditLog auditLog = new AuditLog();
|
||||
auditLog.setOperatorId(operatorId);
|
||||
auditLog.setAction("DATA_RETENTION_PURGE");
|
||||
auditLog.setResourceType("user");
|
||||
auditLog.setResourceId(userId);
|
||||
auditLogMapper.insert(auditLog);
|
||||
|
||||
log.info("注销数据到期处置完成: userId={}, operatorId={}, retentionMonths={}",
|
||||
userId, operatorId, accountConfig.getDataRetentionMonths());
|
||||
result.put("purged", true);
|
||||
result.put("alreadyPurged", false);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ 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.activity_mutual.service.ActivityMutualConsentService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -30,7 +30,8 @@ import java.util.Map;
|
||||
public class ActivityInterestController {
|
||||
|
||||
private final ActivityInterestService interestService;
|
||||
private final RelationshipService relationshipService;
|
||||
/** 二阶段同意(INV-014/015):`interests/confirm` 与 `/mutual/*` 共用同一实现,避免越权旁路。 */
|
||||
private final ActivityMutualConsentService consentService;
|
||||
private final ActivityMapper activityMapper;
|
||||
|
||||
@GetMapping("/operator/activity-interest-summary")
|
||||
@@ -105,7 +106,12 @@ public class ActivityInterestController {
|
||||
/**
|
||||
* 确认开启线上沟通(V1.1 Phase 08)
|
||||
* POST /api/v1/activities/{id}/interests/confirm
|
||||
* 双方确认后创建 Match Episode,不自动创建 Conversation
|
||||
*
|
||||
* <p><b>2026-09-23 UAT 走查修复(INV-014/015)</b>:此前本端点仅校验「双向互选」便直调
|
||||
* {@code RelationshipService.confirmActivityInterest} —— 那是「建 Match」的底层原语
|
||||
* (不做任何同意校验),于是**单方调用即可建 Match/会话**,绕过「双方各自显式确认」
|
||||
* 的二阶段同意。现改为与 {@code /activities/{id}/mutual/*} 走**同一条**二阶段链路:
|
||||
* 本端点只登记「我方确认」,只有双方都 accepted 时才建 Match/会话。
|
||||
*/
|
||||
@Idempotent
|
||||
@PostMapping("/{id}/interests/confirm")
|
||||
@@ -120,13 +126,16 @@ public class ActivityInterestController {
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST,
|
||||
"partnerId不能为空");
|
||||
}
|
||||
// 验证互选状态
|
||||
// 验证互选状态(保留原前置条件:无双向互选不进入确认环节)
|
||||
if (!interestService.hasMutualInterest(userId, partnerId, id)) {
|
||||
return ApiResponse.error(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.CONFLICT,
|
||||
"您与该用户未形成活动双向互选");
|
||||
}
|
||||
return ApiResponse.ok(relationshipService.confirmActivityInterest(userId, partnerId));
|
||||
// 二阶段同意:先幂等确保同意记录存在,再仅提交「我方确认」。
|
||||
// 单方调用不再可能创建 Match(INV-014/015)。
|
||||
consentService.createMutualConsent(id, userId, partnerId);
|
||||
return ApiResponse.ok(consentService.confirmConnection(userId, id, partnerId));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,9 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||
|
||||
/**
|
||||
* 活动互选服务
|
||||
@@ -59,17 +62,17 @@ public class ActivityInterestService {
|
||||
// 1. 验证活动已结束
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
if (activity == null) {
|
||||
throw new RuntimeException("活动不存在");
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
|
||||
}
|
||||
if (!Constants.ACTIVITY_STATUS_ENDED.equals(activity.getStatus())) {
|
||||
throw new RuntimeException("活动尚未结束,暂不支持反馈");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "活动尚未结束,暂不支持反馈");
|
||||
}
|
||||
|
||||
// 2. 验证用户已报名且通过审核
|
||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("您未报名该活动"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您未报名该活动"));
|
||||
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("报名未通过审核,无法提交反馈");
|
||||
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名未通过审核,无法提交反馈");
|
||||
}
|
||||
|
||||
// 3. 过滤有效的目标用户(必须是同活动的approved用户,排除自己)
|
||||
|
||||
@@ -30,6 +30,9 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -110,9 +113,9 @@ public class ActivityService {
|
||||
@Transactional
|
||||
public Activity publishActivity(String activityId, String operatorId) {
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
if (activity == null) throw new RuntimeException("活动不存在");
|
||||
if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
|
||||
if (!Constants.ACTIVITY_STATUS_DRAFT.equals(activity.getStatus())) {
|
||||
throw new RuntimeException("当前状态不允许发布");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许发布");
|
||||
}
|
||||
activity.setStatus(Constants.ACTIVITY_STATUS_REGISTRATION_OPEN);
|
||||
if (activity.getRegistrationStartAt() == null) {
|
||||
@@ -129,10 +132,10 @@ public class ActivityService {
|
||||
@Transactional
|
||||
public Activity cancelActivity(String activityId, String operatorId) {
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
if (activity == null) throw new RuntimeException("活动不存在");
|
||||
if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
|
||||
if (!Constants.ACTIVITY_STATUS_DRAFT.equals(activity.getStatus()) &&
|
||||
!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
|
||||
throw new RuntimeException("当前状态不允许取消");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许取消");
|
||||
}
|
||||
activity.setStatus(Constants.ACTIVITY_STATUS_CANCELLED);
|
||||
activityMapper.updateById(activity);
|
||||
@@ -220,9 +223,9 @@ public class ActivityService {
|
||||
String emergencyContactName,
|
||||
String emergencyContactPhoneEnc) {
|
||||
Activity activity = activityMapper.selectById(activityId);
|
||||
if (activity == null) throw new RuntimeException("活动不存在");
|
||||
if (activity == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "活动不存在");
|
||||
if (!Constants.ACTIVITY_STATUS_REGISTRATION_OPEN.equals(activity.getStatus())) {
|
||||
throw new RuntimeException("活动未开放报名或已结束");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "活动未开放报名或已结束");
|
||||
}
|
||||
// Phase 15 修复:报名窗口此前完全不校验 —— registration_start_at / registration_end_at
|
||||
// 形同虚设,活动一旦发布就永远可报名(运营设的截止时间无效)。
|
||||
@@ -237,7 +240,7 @@ public class ActivityService {
|
||||
if (existing.isPresent()
|
||||
&& !Constants.REGISTRATION_STATUS_REJECTED.equals(existing.get().getStatus())
|
||||
&& !Constants.REGISTRATION_STATUS_CANCELLED.equals(existing.get().getStatus())) {
|
||||
throw new RuntimeException("已经报名过该活动");
|
||||
throw new BusinessException(ApiResponseCode.ACTIVITY_ALREADY_REGISTERED, "已经报名过该活动");
|
||||
}
|
||||
|
||||
// V1.1 Phase 14: 按活动类型执行差异化资格策略(Gate 要求 matchmaking 与课堂/反诈类不同)
|
||||
@@ -265,7 +268,7 @@ public class ActivityService {
|
||||
if (registrationMapper.reuseIfCapacityAvailable(
|
||||
old.getId(), activityId, status,
|
||||
emergencyContactName, emergencyContactPhoneEnc, gender) == 0) {
|
||||
throw new RuntimeException("活动名额已满");
|
||||
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满");
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
|
||||
@@ -294,7 +297,7 @@ public class ActivityService {
|
||||
if (registrationMapper.insertIfCapacityAvailable(
|
||||
newId, activityId, userId, status,
|
||||
emergencyContactName, emergencyContactPhoneEnc, gender) == 0) {
|
||||
throw new RuntimeException("活动名额已满");
|
||||
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满");
|
||||
}
|
||||
} catch (RuntimeException e) {
|
||||
if (groupCode != null) capacityGroupMapper.decrementIfPositive(activityId, groupCode);
|
||||
@@ -334,9 +337,9 @@ public class ActivityService {
|
||||
@Transactional
|
||||
public ActivityRegistration approveRegistration(String registrationId, String operatorId) {
|
||||
ActivityRegistration registration = registrationMapper.selectById(registrationId);
|
||||
if (registration == null) throw new RuntimeException("报名记录不存在");
|
||||
if (registration == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "报名记录不存在");
|
||||
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("当前状态不允许审核通过");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许审核通过");
|
||||
}
|
||||
|
||||
// Phase 15(计划项③):服务站操作员只能审核被指派给自己的活动
|
||||
@@ -355,7 +358,7 @@ public class ActivityService {
|
||||
}
|
||||
if (affected == 0) {
|
||||
if (groupCode != null) capacityGroupMapper.decrementIfPositive(registration.getActivityId(), groupCode);
|
||||
throw new RuntimeException("活动名额已满或状态已变更,无法通过审核");
|
||||
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "活动名额已满或状态已变更,无法通过审核");
|
||||
}
|
||||
// 回读事务内最新状态(条件更新不回填实体)
|
||||
registration = registrationMapper.selectById(registrationId);
|
||||
@@ -376,16 +379,16 @@ public class ActivityService {
|
||||
@Transactional
|
||||
public ActivityRegistration rejectRegistration(String registrationId, String operatorId, String reason) {
|
||||
ActivityRegistration registration = registrationMapper.selectById(registrationId);
|
||||
if (registration == null) throw new RuntimeException("报名记录不存在");
|
||||
if (registration == null) throw new BusinessException(ApiResponseCode.NOT_FOUND, "报名记录不存在");
|
||||
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("当前状态不允许审核");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不允许审核");
|
||||
}
|
||||
assertActivityScope(registration.getActivityId(), operatorId, "只能审核被指派给自己的活动");
|
||||
|
||||
// Phase 15 修复:原实现为「读 pending → 全量 updateById」,两人同时审核时
|
||||
// 一个通过、一个拒绝会互相覆盖。改为条件更新,后到者 affectedRows=0。
|
||||
if (registrationMapper.rejectIfPending(registrationId, operatorId, reason) == 0) {
|
||||
throw new RuntimeException("该报名状态已被他人变更,无法拒绝");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "该报名状态已被他人变更,无法拒绝");
|
||||
}
|
||||
registration = registrationMapper.selectById(registrationId);
|
||||
|
||||
@@ -405,14 +408,14 @@ public class ActivityService {
|
||||
@Transactional
|
||||
public ActivityRegistration cancelRegistration(String userId, String activityId) {
|
||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("没有找到报名记录"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到报名记录"));
|
||||
if (!Constants.REGISTRATION_STATUS_PENDING.equals(registration.getStatus()) &&
|
||||
!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("当前状态不能取消");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "当前状态不能取消");
|
||||
}
|
||||
// 原子取消:状态已被他人变更(如已被拒绝)时 affectedRows=0,不得重复回补名额
|
||||
if (registrationMapper.cancelIfActive(registration.getId()) == 0) {
|
||||
throw new RuntimeException("该报名状态已被他人变更,无法取消");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "该报名状态已被他人变更,无法取消");
|
||||
}
|
||||
// 已通过的名额释放(总名额/性别名额由 SQL 实时统计自动释放;分组计数需显式回退)
|
||||
if (Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
@@ -721,7 +724,7 @@ public class ActivityService {
|
||||
log.warn("实名状态查询失败,按未实名处理: userId={}, err={}", userId, e.getMessage());
|
||||
}
|
||||
if (!verified) {
|
||||
throw new RuntimeException("该活动需完成实名认证后方可报名");
|
||||
throw new BusinessException(ApiResponseCode.FORBIDDEN, "该活动需完成实名认证后方可报名");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,7 +736,7 @@ public class ActivityService {
|
||||
log.warn("本地资格状态查询失败,按未核验处理: userId={}, err={}", userId, e.getMessage());
|
||||
}
|
||||
if (!Constants.VERIFICATION_STATUS_VERIFIED.equals(st)) {
|
||||
throw new RuntimeException("该活动需通过本地资格核验后方可报名");
|
||||
throw new BusinessException(ApiResponseCode.FORBIDDEN, "该活动需通过本地资格核验后方可报名");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -746,10 +749,10 @@ public class ActivityService {
|
||||
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() + " 周岁");
|
||||
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名年龄未满 " + activity.getMinAge() + " 周岁");
|
||||
}
|
||||
if (activity.getMaxAge() != null && age > activity.getMaxAge()) {
|
||||
throw new RuntimeException("报名年龄超出 " + activity.getMaxAge() + " 周岁上限");
|
||||
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名年龄超出 " + activity.getMaxAge() + " 周岁上限");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -782,10 +785,10 @@ public class ActivityService {
|
||||
private void assertWithinRegistrationWindow(Activity activity) {
|
||||
Instant now = Instant.now();
|
||||
if (activity.getRegistrationStartAt() != null && now.isBefore(activity.getRegistrationStartAt())) {
|
||||
throw new RuntimeException("活动报名尚未开始");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "活动报名尚未开始");
|
||||
}
|
||||
if (activity.getRegistrationEndAt() != null && now.isAfter(activity.getRegistrationEndAt())) {
|
||||
throw new RuntimeException("活动报名已截止");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "活动报名已截止");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -835,7 +838,7 @@ public class ActivityService {
|
||||
String groupCode = resolveCapacityGroupCode(activityId, gender);
|
||||
if (groupCode == null) return null;
|
||||
if (capacityGroupMapper.incrementIfAvailable(activityId, groupCode) == 0) {
|
||||
throw new RuntimeException("该分组名额已满");
|
||||
throw new BusinessException(ApiResponseCode.ACTIVITY_FULL, "该分组名额已满");
|
||||
}
|
||||
return groupCode;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,10 @@ import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.danyang.match.platform.shared.exception.ServerErrorException;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||
|
||||
/**
|
||||
* 签到服务
|
||||
@@ -61,21 +65,21 @@ public class CheckinService {
|
||||
*/
|
||||
public Map<String, Object> generateCheckinToken(String userId, String activityId) {
|
||||
ActivityRegistration registration = registrationMapper.findByActivityAndUser(activityId, userId)
|
||||
.orElseThrow(() -> new RuntimeException("您没有报名该活动"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.FORBIDDEN, "您没有报名该活动"));
|
||||
|
||||
if (!Constants.REGISTRATION_STATUS_APPROVED.equals(registration.getStatus())) {
|
||||
throw new RuntimeException("报名尚未审核通过,无法签到");
|
||||
throw new BusinessException(ApiResponseCode.FORBIDDEN, "报名尚未审核通过,无法签到");
|
||||
}
|
||||
|
||||
if (Constants.CHECKIN_STATUS_CHECKED_IN.equals(registration.getCheckinStatus())) {
|
||||
throw new RuntimeException("您已经签到过了");
|
||||
throw new BusinessException(ApiResponseCode.ALREADY_CHECKED_IN, "您已经签到过了");
|
||||
}
|
||||
|
||||
Activity activity = requireCheckableActivity(activityId);
|
||||
|
||||
Instant now = Instant.now();
|
||||
if (now.isBefore(activity.getStartAt()) || now.isAfter(activity.getEndAt())) {
|
||||
throw new RuntimeException("活动未开始或已结束,无法签到");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "活动未开始或已结束,无法签到");
|
||||
}
|
||||
|
||||
String payload = userId + ":" + activityId + ":" + now.toEpochMilli();
|
||||
@@ -290,7 +294,7 @@ public class CheckinService {
|
||||
byte[] hash = hmac.doFinal(payload.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(hash);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("签名生成失败", e);
|
||||
throw new ServerErrorException("签名生成失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
package com.danyang.match.platform.activity_mutual.service;
|
||||
|
||||
import com.danyang.match.platform.activity.entity.Activity;
|
||||
import com.danyang.match.platform.activity.mapper.ActivityMapper;
|
||||
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.config.ActivityConfig;
|
||||
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.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@@ -20,6 +26,8 @@ public class ActivityMutualConsentService {
|
||||
|
||||
private final ActivityMutualConsentMapper consentMapper;
|
||||
private final RelationshipService relationshipService;
|
||||
private final ActivityMapper activityMapper;
|
||||
private final ActivityConfig activityConfig;
|
||||
|
||||
public List<MutualInterestResultDTO> detectMutualResults(String activityId) {
|
||||
List<ActivityMutualConsent> bothConfirmed = consentMapper.findBothConfirmedByActivity(activityId);
|
||||
@@ -44,6 +52,15 @@ public class ActivityMutualConsentService {
|
||||
}
|
||||
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_PENDING);
|
||||
consent.setUserBStatus(Constants.MUTUAL_CONSENT_STATUS_PENDING);
|
||||
// P16-T13 接线:写入 expires_at,否则 confirmSideIfPending 的
|
||||
// `(expires_at IS NULL OR expires_at > NOW())` 过滤因恒 NULL 而永不生效,
|
||||
// 「截止后 48 小时」窗口(PENDING_DECISIONS 已确认口径)形同虚设。
|
||||
// 基准点 = 活动结束时间(ActivityConfig.mutualDeadlineHours 注释的既定语义),
|
||||
// 活动不存在或无结束时间时回退为创建时刻。
|
||||
Instant deadlineBase = Optional.ofNullable(activityMapper.selectById(activityId))
|
||||
.map(Activity::getEndAt)
|
||||
.orElseGet(Instant::now);
|
||||
consent.setExpiresAt(deadlineBase.plus(Duration.ofHours(activityConfig.getMutualDeadlineHours())));
|
||||
consentMapper.insert(consent);
|
||||
}
|
||||
return consent;
|
||||
@@ -52,7 +69,7 @@ public class ActivityMutualConsentService {
|
||||
@Transactional
|
||||
public Map<String, Object> confirmConnection(String userId, String activityId, String partnerId) {
|
||||
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
|
||||
|
||||
// 原子确认本方(Phase 16 核心):仅当本方 pending、未过期、未软删时写入 accepted,
|
||||
// 并借助 both_confirmed_at 从 NULL→非NULL 的原子 UPDATE 实现 exactly-once。
|
||||
@@ -61,7 +78,7 @@ public class ActivityMutualConsentService {
|
||||
|
||||
// 回读权威状态(可能受并发另一方提交影响)
|
||||
consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||
.orElseThrow(() -> new RuntimeException("互选记录已不存在"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "互选记录已不存在"));
|
||||
|
||||
boolean aAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserAStatus());
|
||||
boolean bAccepted = Constants.MUTUAL_CONSENT_STATUS_ACCEPTED.equals(consent.getUserBStatus());
|
||||
@@ -105,7 +122,7 @@ public class ActivityMutualConsentService {
|
||||
|
||||
public Map<String, Object> declineConnection(String userId, String activityId, String partnerId) {
|
||||
ActivityMutualConsent consent = consentMapper.findByActivityPair(activityId, userId, partnerId)
|
||||
.orElseThrow(() -> new RuntimeException("没有找到该活动的互选记录"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "没有找到该活动的互选记录"));
|
||||
if (userId.equals(consent.getUserAId())) {
|
||||
consent.setUserAStatus(Constants.MUTUAL_CONSENT_STATUS_DECLINED);
|
||||
} else {
|
||||
|
||||
@@ -21,7 +21,9 @@ 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;
|
||||
// 必须是 GlobalExceptionHandler.BusinessException —— shared.exception.BusinessException
|
||||
// 此前是另一个未映射的类,抛出来直落 500(days 越界本该 400)。见该类 javadoc 的修复记录。
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.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;
|
||||
@@ -252,7 +254,7 @@ public class AnalyticsService {
|
||||
|
||||
public List<Map<String, Object>> getDailySummary(int days) {
|
||||
if (days < 1 || days > 365) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR.getCode(), "days 必须在 1~365 之间");
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "days 必须在 1~365 之间");
|
||||
}
|
||||
List<Map<String, Object>> summary = new ArrayList<>();
|
||||
LocalDate today = LocalDate.now();
|
||||
|
||||
@@ -24,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
|
||||
/**
|
||||
* 申诉服务
|
||||
@@ -97,29 +99,29 @@ public class AppealService {
|
||||
public Map<String, Object> submitAppeal(String userId, AppealSubmitDTO dto) {
|
||||
// FeatureFlag 检查
|
||||
if (!featureFlagConfig.isAppealEnabled()) {
|
||||
throw new RuntimeException("申诉功能暂未开放");
|
||||
throw new BusinessException(ApiResponseCode.OPERATION_NOT_ALLOWED, "申诉功能暂未开放");
|
||||
}
|
||||
|
||||
if (dto == null || dto.getRelatedActionId() == null || dto.getRelatedActionId().isBlank()) {
|
||||
throw new RuntimeException("缺少关联的处置记录");
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "缺少关联的处置记录");
|
||||
}
|
||||
|
||||
// 验证关联的 risk_action 存在且仍有效
|
||||
RiskAction targetAction = riskActionMapper.selectById(dto.getRelatedActionId());
|
||||
if (targetAction == null) {
|
||||
throw new RuntimeException("关联的处置记录不存在");
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "关联的处置记录不存在");
|
||||
}
|
||||
if (!Constants.RISK_ACTION_STATUS_ACTIVE.equals(targetAction.getStatus())) {
|
||||
throw new RuntimeException("该处置已失效,无法申诉");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "该处置已失效,无法申诉");
|
||||
}
|
||||
if (!userId.equals(targetAction.getUserId())) {
|
||||
throw new RuntimeException("无权对此处置提交申诉");
|
||||
throw new BusinessException(ApiResponseCode.FORBIDDEN, "无权对此处置提交申诉");
|
||||
}
|
||||
|
||||
// 必须是 ban 或 suspend 类型
|
||||
if (!Constants.RISK_ACTION_BAN.equals(targetAction.getActionType()) &&
|
||||
!Constants.RISK_ACTION_TEMPORARY_SUSPEND.equals(targetAction.getActionType())) {
|
||||
throw new RuntimeException("当前处置类型不支持申诉");
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "当前处置类型不支持申诉");
|
||||
}
|
||||
|
||||
// Phase 18:校验 appealType 白名单(生产 chk_appeal_type),并必须与处置类型一致。
|
||||
@@ -132,16 +134,16 @@ public class AppealService {
|
||||
}
|
||||
if (!Constants.APPEAL_TYPE_BAN.equals(appealType) &&
|
||||
!Constants.APPEAL_TYPE_SUSPEND.equals(appealType)) {
|
||||
throw new RuntimeException("无效的申诉类型");
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉类型");
|
||||
}
|
||||
if (!expectedType.equals(appealType)) {
|
||||
throw new RuntimeException("申诉类型与处置类型不匹配");
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "申诉类型与处置类型不匹配");
|
||||
}
|
||||
|
||||
// 防重:同一用户对同一 action 只能有一个 pending/reviewing 申诉
|
||||
Optional<Appeal> existing = appealMapper.findActiveByUserAndAction(userId, dto.getRelatedActionId());
|
||||
if (existing.isPresent()) {
|
||||
throw new RuntimeException("您已就此处置提交过申诉,请等待处理结果");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "您已就此处置提交过申诉,请等待处理结果");
|
||||
}
|
||||
|
||||
Appeal appeal = new Appeal();
|
||||
@@ -190,7 +192,7 @@ public class AppealService {
|
||||
public AppealItemDTO getAppealDetail(String appealId) {
|
||||
Appeal appeal = appealMapper.selectById(appealId);
|
||||
if (appeal == null || appeal.getDeletedAt() != null) {
|
||||
throw new RuntimeException("申诉不存在");
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "申诉不存在");
|
||||
}
|
||||
return toDTO(appeal);
|
||||
}
|
||||
@@ -202,10 +204,10 @@ public class AppealService {
|
||||
public Map<String, Object> processAppeal(String appealId, AppealProcessDTO dto, String operatorId) {
|
||||
Appeal appeal = appealMapper.selectById(appealId);
|
||||
if (appeal == null || appeal.getDeletedAt() != null) {
|
||||
throw new RuntimeException("申诉不存在");
|
||||
throw new BusinessException(ApiResponseCode.NOT_FOUND, "申诉不存在");
|
||||
}
|
||||
if (dto == null || dto.getDecision() == null) {
|
||||
throw new RuntimeException("无效的申诉决策");
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉决策");
|
||||
}
|
||||
|
||||
// Phase 18:原子「认领」——只有把申诉从可处理态(pending/reviewing/escalated)
|
||||
@@ -213,7 +215,7 @@ public class AppealService {
|
||||
// 同一申诉时,只有一个赢家能继续,另一个直接失败,避免 approve/reject 相互覆盖。
|
||||
int claimed = appealMapper.markReviewingIfProcessable(appealId);
|
||||
if (claimed != 1) {
|
||||
throw new RuntimeException("该申诉已处理完毕");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "该申诉已处理完毕");
|
||||
}
|
||||
|
||||
// 回读最新状态(status 已为 reviewing)
|
||||
@@ -224,7 +226,7 @@ public class AppealService {
|
||||
case "approve" -> approveAppeal(fresh, dto.getDecisionNote(), operatorId);
|
||||
case "reject" -> rejectAppeal(fresh, dto.getDecisionNote(), operatorId);
|
||||
case "escalate" -> escalateAppeal(fresh, dto.getDecisionNote(), operatorId);
|
||||
default -> throw new RuntimeException("无效的申诉决策");
|
||||
default -> throw new BusinessException(ApiResponseCode.BAD_REQUEST, "无效的申诉决策");
|
||||
}
|
||||
|
||||
saveAuditLog(operatorId, "PROCESS_APPEAL", "appeal", appealId, null, null);
|
||||
|
||||
@@ -19,4 +19,15 @@ public class AuditLog extends BaseEntity {
|
||||
private String afterData;
|
||||
private String ip;
|
||||
private String userAgent;
|
||||
|
||||
// ==================== ZC-MM-006 / T01 扩展审计字段(V055,全可空) ====================
|
||||
|
||||
/** 业务目的码,规范:matchmaker.service.assign/recommend/followup/complete/view。 */
|
||||
private String purposeCode;
|
||||
/** 关联的 matchmaker_assignments.id(非分配场景为 null)。 */
|
||||
private String serviceAssignmentId;
|
||||
/** 落库时操作者权限快照(permissions 逗号拼接,截断 128)。 */
|
||||
private String actorRoleSnapshot;
|
||||
/** 请求追踪 ID(RequestIdFilter 写入 MDC 的 requestId)。 */
|
||||
private String requestTraceId;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,11 @@ public class AuditLogService {
|
||||
m.put("ip", log.getIp());
|
||||
m.put("userAgent", log.getUserAgent());
|
||||
m.put("createdAt", log.getCreatedAt());
|
||||
// ZC-MM-006 / T01:扩展审计字段输出(可空键,存量行输出 null,读取兼容)
|
||||
m.put("purposeCode", log.getPurposeCode());
|
||||
m.put("serviceAssignmentId", log.getServiceAssignmentId());
|
||||
m.put("actorRoleSnapshot", log.getActorRoleSnapshot());
|
||||
m.put("requestTraceId", log.getRequestTraceId());
|
||||
return m;
|
||||
}).collect(java.util.stream.Collectors.toList());
|
||||
|
||||
@@ -73,11 +78,23 @@ public class AuditLogService {
|
||||
*/
|
||||
@Transactional
|
||||
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId) {
|
||||
recordSensitiveAccess(operatorId, resourceType, resourceId, null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* ZC-MM-006 / T01 扩展版敏感访问记录:附加目的码与关联分配 ID。
|
||||
* 旧 3 参签名委托本方法(purposeCode/serviceAssignmentId 为 null),存量调用兼容。
|
||||
*/
|
||||
@Transactional
|
||||
public void recordSensitiveAccess(String operatorId, String resourceType, String resourceId,
|
||||
String purposeCode, String serviceAssignmentId) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
auditLog.setOperatorId(operatorId);
|
||||
auditLog.setAction("SENSITIVE_ACCESS");
|
||||
auditLog.setResourceType(resourceType);
|
||||
auditLog.setResourceId(resourceId);
|
||||
auditLog.setPurposeCode(purposeCode);
|
||||
auditLog.setServiceAssignmentId(serviceAssignmentId);
|
||||
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attrs) {
|
||||
HttpServletRequest req = attrs.getRequest();
|
||||
auditLog.setIp(req.getRemoteAddr());
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package com.danyang.match.platform.auth.controller;
|
||||
|
||||
import com.danyang.match.platform.auth.service.AuthService;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
@@ -26,8 +28,7 @@ public class AuthController {
|
||||
public ApiResponse<Map<String, Object>> wechatLogin(@RequestBody Map<String, String> body) {
|
||||
String code = body.get("code");
|
||||
if (code == null || code.isBlank()) {
|
||||
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "code 不能为空");
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "code 不能为空");
|
||||
}
|
||||
return ApiResponse.ok(authService.wechatLogin(code));
|
||||
}
|
||||
@@ -44,8 +45,7 @@ public class AuthController {
|
||||
@RequestBody Map<String, String> body) {
|
||||
String phone = body.get("phone");
|
||||
if (phone == null || phone.isBlank()) {
|
||||
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "手机号不能为空");
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "手机号不能为空");
|
||||
}
|
||||
authService.bindPhone(principal.id(), phone);
|
||||
return ApiResponse.ok();
|
||||
@@ -54,17 +54,19 @@ public class AuthController {
|
||||
/**
|
||||
* 刷新 Token
|
||||
* POST /api/v1/auth/refresh
|
||||
* Body: { "userId": "...", "refreshToken": "..." }
|
||||
* Body: { "refreshToken": "..." },userId 可选
|
||||
*
|
||||
* <p>userId 不再是必填:refresh token 的 subject 就是 userId,令牌本身自证身份。
|
||||
* 此前强制要求客户端重复传 userId,漏传即 400 —— 小程序 2026-09-22 的
|
||||
* 首页 401 事故正是被这个 400 放大的。显式传入时仍会被校验与令牌主体一致。
|
||||
*/
|
||||
@PostMapping("/refresh")
|
||||
public ApiResponse<Map<String, Object>> refreshToken(@RequestBody Map<String, String> body) {
|
||||
String userId = body.get("userId");
|
||||
String refreshToken = body.get("refreshToken");
|
||||
if (userId == null || refreshToken == null) {
|
||||
throw new com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException(
|
||||
com.danyang.match.platform.shared.enums.ApiResponseCode.BAD_REQUEST, "参数缺失");
|
||||
if (refreshToken == null || refreshToken.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "refreshToken 不能为空");
|
||||
}
|
||||
return ApiResponse.ok(authService.refreshToken(userId, refreshToken));
|
||||
return ApiResponse.ok(authService.refreshToken(body.get("userId"), refreshToken));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
package com.danyang.match.platform.auth.controller;
|
||||
|
||||
import com.danyang.match.platform.auth.cookie.OperatorCookieWriter;
|
||||
import com.danyang.match.platform.auth.service.OperatorAuthService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.security.principal.OperatorPrincipal;
|
||||
import jakarta.servlet.http.Cookie;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -17,14 +23,80 @@ import java.util.Map;
|
||||
public class OperatorAuthController {
|
||||
|
||||
private final OperatorAuthService operatorAuthService;
|
||||
private final OperatorCookieWriter cookieWriter;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body) {
|
||||
public ApiResponse<Map<String, Object>> login(@RequestBody Map<String, String> body,
|
||||
HttpServletRequest request, HttpServletResponse response) {
|
||||
String username = body.get("username");
|
||||
String password = body.get("password");
|
||||
if (username == null || username.isBlank() || password == null || password.isBlank()) {
|
||||
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "用户名和密码不能为空");
|
||||
}
|
||||
return ApiResponse.ok(operatorAuthService.login(username, password));
|
||||
Map<String, Object> result = operatorAuthService.login(username, password);
|
||||
// 同时下发 HttpOnly + SameSite=Lax Cookie(access / refresh),抗 XSS 读取令牌。
|
||||
// Cookie max-age 由 OperatorCookieWriter 按 JWT 有效期计算,控制器不引用 JWT 工具
|
||||
// (控制器禁止解析/引用 JWT 工具,见 PrincipalInjectionContractTest 契约)。
|
||||
cookieWriter.writeLoginCookies(response,
|
||||
(String) result.get("accessToken"), (String) result.get("refreshToken"));
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新 Operator Token。
|
||||
*
|
||||
* <p>refresh token 优先取请求体(兼容旧前端/跨端口开发代理),缺省时回退到 HttpOnly Cookie
|
||||
* {@code dy_op_rt}(生产同源场景,前端不持有 refresh token,抗 XSS)。
|
||||
* 响应同时下发新的 access / refresh Cookie,实现刷新即轮换。
|
||||
*/
|
||||
@PostMapping("/refresh")
|
||||
public ApiResponse<Map<String, Object>> refresh(@RequestBody(required = false) Map<String, String> body,
|
||||
HttpServletRequest request, HttpServletResponse response) {
|
||||
String refreshToken = (body != null) ? body.get("refreshToken") : null;
|
||||
if (refreshToken == null || refreshToken.isBlank()) {
|
||||
refreshToken = readCookie(request, OperatorCookieWriter.REFRESH_COOKIE);
|
||||
}
|
||||
if (refreshToken == null || refreshToken.isBlank()) {
|
||||
return ApiResponse.error(ApiResponseCode.BAD_REQUEST, "缺少 Refresh Token");
|
||||
}
|
||||
String operatorId = (body != null) ? body.get("operatorId") : null;
|
||||
Map<String, Object> result = operatorAuthService.refreshToken(operatorId, refreshToken);
|
||||
cookieWriter.writeLoginCookies(response,
|
||||
(String) result.get("accessToken"), (String) result.get("refreshToken"));
|
||||
return ApiResponse.ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出:后端将当前 access token 加入黑名单并清除 HttpOnly Cookie。
|
||||
* operatorId 取自 SecurityContext 中的 OperatorPrincipal(由 JwtAuthFilter 写入),
|
||||
* 不再由控制器自行解析 JWT;token 取自 Authorization 头,缺省时回退到 access Cookie,
|
||||
* 仅用于黑名单登记。
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public ApiResponse<Void> logout(HttpServletRequest request, HttpServletResponse response,
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
String token = readBearer(request);
|
||||
if (token != null && principal != null) {
|
||||
operatorAuthService.logout(principal.id(), token);
|
||||
}
|
||||
cookieWriter.clearCookies(response);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
private String readCookie(HttpServletRequest request, String name) {
|
||||
Cookie[] cookies = request.getCookies();
|
||||
if (cookies == null) return null;
|
||||
for (Cookie c : cookies) {
|
||||
if (name.equals(c.getName())) return c.getValue();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String readBearer(HttpServletRequest request) {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header != null && header.startsWith("Bearer ")) {
|
||||
return header.substring(7);
|
||||
}
|
||||
return readCookie(request, OperatorCookieWriter.ACCESS_COOKIE);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.danyang.match.platform.auth.cookie;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.ResponseCookie;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 管理端鉴权 Cookie 写入工具。
|
||||
*
|
||||
* <p>登录 / 刷新成功时下发 HttpOnly + SameSite=Lax 的 access / refresh Cookie;登出时清除。
|
||||
* Cookie 为 HttpOnly,JS 无法读取,从根本上消除 XSS 读取令牌并外泄的风险(此前令牌存于
|
||||
* 前端 localStorage,XSS 可读写并伪造权限)。
|
||||
*
|
||||
* <p>{@code Secure} 标志由 {@code app.auth-cookie-secure} 控制:生产(https)为 true;
|
||||
* dev / docker(http)为 false,否则浏览器拒绝写入。
|
||||
*
|
||||
* <p>路径固定为 {@code /api/v1}(即后端 servlet context-path),确保只随同源 API 请求携带。
|
||||
*/
|
||||
@Component
|
||||
public class OperatorCookieWriter {
|
||||
|
||||
public static final String ACCESS_COOKIE = "dy_op_at";
|
||||
public static final String REFRESH_COOKIE = "dy_op_rt";
|
||||
|
||||
@Value("${app.auth-cookie-secure:true}")
|
||||
private boolean secure;
|
||||
|
||||
// Cookie 生命周期与 JWT 自身有效期对齐(access 2h / refresh 14d),由配置驱动、不写死。
|
||||
// 由本组件持有而非控制器持有:控制器不得直接引用 JwtTokenService
|
||||
// (PrincipalInjectionContractTest 契约禁止控制器解析/引用 JWT 工具)。
|
||||
@Value("${jwt.access-token-expiration-ms:7200000}")
|
||||
private long accessExpirationMs;
|
||||
|
||||
@Value("${jwt.refresh-token-expiration-ms:1209600000}")
|
||||
private long refreshExpirationMs;
|
||||
|
||||
/** 登录/刷新下发 Cookie,max-age 自动取 JWT 有效期(秒)。 */
|
||||
public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken) {
|
||||
writeLoginCookies(response, accessToken, refreshToken, accessExpirationMs / 1000, refreshExpirationMs / 1000);
|
||||
}
|
||||
|
||||
public void writeLoginCookies(HttpServletResponse response, String accessToken, String refreshToken,
|
||||
long accessMaxAgeSec, long refreshMaxAgeSec) {
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, accessToken, accessMaxAgeSec).toString());
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, build(REFRESH_COOKIE, refreshToken, refreshMaxAgeSec).toString());
|
||||
}
|
||||
|
||||
public void clearCookies(HttpServletResponse response) {
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, build(ACCESS_COOKIE, "", 0).toString());
|
||||
response.addHeader(HttpHeaders.SET_COOKIE, build(REFRESH_COOKIE, "", 0).toString());
|
||||
}
|
||||
|
||||
private ResponseCookie build(String name, String value, long maxAgeSec) {
|
||||
return ResponseCookie.from(name, value)
|
||||
.httpOnly(true)
|
||||
.secure(secure)
|
||||
.sameSite("Lax")
|
||||
.path("/api/v1")
|
||||
.maxAge(maxAgeSec)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,15 @@ import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Mock 微信登录 Provider(开发/测试环境使用)
|
||||
* Mock 微信登录 Provider(开发/测试环境使用)。
|
||||
*
|
||||
* <p>属性已从 {@code verification.provider} 解耦为独立的 {@code wechat.login.provider},
|
||||
* 避免与实名核验 Provider 共用一把开关导致生产误启 Mock。
|
||||
* 保留 {@code matchIfMissing = true}:未显式配置时本地/测试默认走 Mock;
|
||||
* 生产必须在 {@code application-production.yml} 显式置 {@code wechat.login.provider=real}。
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "verification.provider", havingValue = "mock", matchIfMissing = true)
|
||||
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "mock", matchIfMissing = true)
|
||||
public class MockWechatLoginProvider implements WechatLoginProvider {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(MockWechatLoginProvider.class);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.danyang.match.platform.auth.provider;
|
||||
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 真实微信登录 Provider(生产环境使用)。
|
||||
*
|
||||
* <p>通过 {@code code2session} 用登录临时凭证 {@code code} 换取 {@code openid / session_key / unionid}。
|
||||
* 由配置 {@code wechat.login.provider=real} 激活;未显式配置时默认是 Mock(见 {@link MockWechatLoginProvider}),
|
||||
* 因此生产环境必须在 {@code application-production.yml} 里显式置 {@code real},否则启动即应失败(而非悄悄走 Mock)。
|
||||
*
|
||||
* <p>安全要点:
|
||||
* <ul>
|
||||
* <li>{@code appId / appSecret} 必须来自环境变量({@code WECHAT_APP_ID / WECHAT_APP_SECRET}),不得硬编码;
|
||||
* 缺失即 fail-fast,避免用占位值放行任意 code。</li>
|
||||
* <li>code 必须进 URL 前编码;响应必须校验 {@code errcode},微信对非法/过期 code 会返回 errcode 而非抛网络异常。</li>
|
||||
* <li>绝不把 appSecret / session_key 回传给前端。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "wechat.login.provider", havingValue = "real")
|
||||
public class RealWechatLoginProvider implements WechatLoginProvider {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(RealWechatLoginProvider.class);
|
||||
private static final String CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session";
|
||||
|
||||
@Value("${wechat.app-id:}")
|
||||
private String appId;
|
||||
|
||||
@Value("${wechat.app-secret:}")
|
||||
private String appSecret;
|
||||
|
||||
private final RestTemplate restTemplate = new RestTemplate();
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Override
|
||||
public Map<String, String> getCodeSession(String code) {
|
||||
if (appId == null || appId.isBlank() || appSecret == null || appSecret.isBlank()) {
|
||||
// 配置缺失 = 生产未就绪,fail-fast 而非放行任意 code
|
||||
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录未配置 appId/appSecret");
|
||||
}
|
||||
if (code == null || code.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:缺少 code");
|
||||
}
|
||||
|
||||
String url = CODE2SESSION_URL
|
||||
+ "?appid=" + appId
|
||||
+ "&secret=" + appSecret
|
||||
+ "&js_code=" + URLEncoder.encode(code, StandardCharsets.UTF_8)
|
||||
+ "&grant_type=authorization_code";
|
||||
|
||||
try {
|
||||
String resp = restTemplate.getForObject(url, String.class);
|
||||
JsonNode node = objectMapper.readTree(resp);
|
||||
if (node.has("errcode") && node.get("errcode").asInt(0) != 0) {
|
||||
log.warn("[Wechat] code2session 失败: errcode={}, errmsg={}",
|
||||
node.get("errcode").asText(), node.has("errmsg") ? node.get("errmsg").asText() : "");
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败,请重试");
|
||||
}
|
||||
String openid = node.has("openid") ? node.get("openid").asText() : null;
|
||||
if (openid == null || openid.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:无法获取 openid");
|
||||
}
|
||||
String sessionKey = node.has("session_key") ? node.get("session_key").asText() : null;
|
||||
String unionid = node.has("unionid") ? node.get("unionid").asText() : null;
|
||||
|
||||
// 用 HashMap(允许 unionid 为 null),不要 Map.of(禁止 null 值)
|
||||
Map<String, String> result = new HashMap<>();
|
||||
result.put("openid", openid);
|
||||
result.put("unionid", unionid);
|
||||
result.put("session_key", sessionKey);
|
||||
result.put("appid", appId);
|
||||
return result;
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("[Wechat] code2session 调用异常", e);
|
||||
throw new BusinessException(ApiResponseCode.SERVER_ERROR, "微信登录服务异常");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isMock() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import com.danyang.match.platform.auth.provider.WechatLoginProvider;
|
||||
import com.danyang.match.platform.auth.token.JwtTokenService;
|
||||
import com.danyang.match.platform.capability.service.CapabilityService;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.UnauthorizedException;
|
||||
import com.danyang.match.platform.local_eligibility.entity.LocalEligibility;
|
||||
import com.danyang.match.platform.local_eligibility.mapper.LocalEligibilityMapper;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
@@ -27,6 +30,7 @@ import java.time.Duration;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -58,7 +62,7 @@ public class AuthService {
|
||||
String unionid = session.get("unionid");
|
||||
|
||||
if (openid == null || openid.isBlank()) {
|
||||
throw new RuntimeException("微信登录失败:无法获取 openid");
|
||||
throw new BusinessException(ApiResponseCode.BAD_REQUEST, "微信登录失败:无法获取 openid");
|
||||
}
|
||||
|
||||
// 查找或创建用户
|
||||
@@ -87,7 +91,9 @@ public class AuthService {
|
||||
|
||||
// 将 refreshToken 存入 Redis(用于撤销)
|
||||
String refreshTokenKey = "auth:refresh_token:" + user.getId();
|
||||
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
|
||||
// 刷新令牌 Redis 登记的 TTL 必须与 refresh JWT 自身有效期一致(getRefreshExpirationMs,
|
||||
// 默认 14 天),否则 4h 后记录消失、刷新即失效,用户被迫频繁重新登录。
|
||||
redisTemplate.opsForValue().set(refreshTokenKey, refreshToken, Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
|
||||
|
||||
// 记录审计日志
|
||||
saveAuditLog(user.getId(), null, "LOGIN", "user", user.getId(), null, null);
|
||||
@@ -108,12 +114,12 @@ public class AuthService {
|
||||
public void bindPhone(String userId, String phoneCode) {
|
||||
// TODO: Phase 03 对接真实短信验证码
|
||||
User user = userMapper.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||
|
||||
// 检查手机号是否已被其他用户占用
|
||||
userMapper.findByPhone(phoneCode).ifPresent(existing -> {
|
||||
if (!existing.getId().equals(userId)) {
|
||||
throw new RuntimeException("该手机号已被其他账号绑定");
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "该手机号已被其他账号绑定");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -126,31 +132,58 @@ public class AuthService {
|
||||
|
||||
/**
|
||||
* 刷新 Token
|
||||
*
|
||||
* <p><b>2026-09-22 修复三处缺陷</b>:
|
||||
* <ol>
|
||||
* <li>验签用错了密钥:refresh token 由 refresh 密钥签发,此前却调
|
||||
* {@code validateToken}(用 access 密钥验签)→ 必然失败,
|
||||
* 于是「刷新」这条路在修好前端字段名之后仍然走不通。改用
|
||||
* {@link JwtTokenService#validateRefreshToken}。</li>
|
||||
* <li>失败语义是 500:客户端据此判断为服务端故障并重试,实际应回 401
|
||||
* 让它停止重试、走重新登录。改抛 {@link UnauthorizedException}。</li>
|
||||
* <li>userId 被强制要求:refresh token 的 subject 本身就是 userId,
|
||||
* 让客户端重复传一个它已持有凭据里已包含的信息,只会制造
|
||||
* 「漏传 → 400」这类无意义失败。改为**以令牌主体为准**,
|
||||
* 显式传入时仅用于一致性校验。</li>
|
||||
* </ol>
|
||||
*/
|
||||
public Map<String, Object> refreshToken(String userId, String refreshToken) {
|
||||
// 验证 Redis 中存储的 refresh token
|
||||
String storedToken = redisTemplate.opsForValue().get("auth:refresh_token:" + userId);
|
||||
if (storedToken == null || !storedToken.equals(refreshToken)) {
|
||||
throw new RuntimeException("Refresh Token 无效或已过期");
|
||||
if (refreshToken == null || refreshToken.isBlank()) {
|
||||
throw new UnauthorizedException("Refresh Token 无效或已过期");
|
||||
}
|
||||
|
||||
// 验证 JWT
|
||||
if (!jwtTokenService.validateToken(refreshToken)) {
|
||||
throw new RuntimeException("Refresh Token 无效");
|
||||
// 1. 令牌自证:签名有效(refresh 密钥)、未过期、type=refresh、未被撤销
|
||||
if (!jwtTokenService.validateRefreshToken(refreshToken)) {
|
||||
throw new UnauthorizedException("Refresh Token 无效或已过期");
|
||||
}
|
||||
|
||||
// 2. 用户身份以令牌主体为准;客户端显式传入时必须是同一个用户,
|
||||
// 否则就是拿 A 的令牌去刷 B 的会话。
|
||||
String tokenUserId = jwtTokenService.getUserIdFromRefreshToken(refreshToken);
|
||||
if (userId != null && !userId.isBlank() && !userId.equals(tokenUserId)) {
|
||||
throw new UnauthorizedException("Refresh Token 与用户不匹配");
|
||||
}
|
||||
userId = tokenUserId;
|
||||
|
||||
// 3. 校验 Redis 中登记的令牌:登出或换发后,旧令牌立即失效(可撤销性)
|
||||
String storedToken = redisTemplate.opsForValue().get("auth:refresh_token:" + userId);
|
||||
if (storedToken == null || !storedToken.equals(refreshToken)) {
|
||||
throw new UnauthorizedException("Refresh Token 无效或已过期");
|
||||
}
|
||||
|
||||
// 签发新 Token
|
||||
String newAccessToken = jwtTokenService.generateAccessToken(userId, null);
|
||||
String newRefreshToken = jwtTokenService.generateRefreshToken(userId);
|
||||
|
||||
// 更新 Redis
|
||||
// 更新 Redis:TTL 与 refresh JWT 一致(getRefreshExpirationMs),避免刷新记录先于令牌过期。
|
||||
redisTemplate.opsForValue().set("auth:refresh_token:" + userId, newRefreshToken,
|
||||
Duration.ofMillis(jwtTokenService.getExpirationMs() * 2));
|
||||
Duration.ofMillis(jwtTokenService.getRefreshExpirationMs()));
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("accessToken", newAccessToken);
|
||||
result.put("refreshToken", newRefreshToken);
|
||||
result.put("expiresIn", accessTokenExpireMs / 1000);
|
||||
result.put("userId", userId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -170,7 +203,7 @@ public class AuthService {
|
||||
*/
|
||||
public Map<String, Object> getMeStatus(String userId) {
|
||||
User user = userMapper.findById(userId)
|
||||
.orElseThrow(() -> new RuntimeException("用户不存在"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "用户不存在"));
|
||||
|
||||
Map<String, Object> status = new HashMap<>();
|
||||
status.put("userId", user.getId());
|
||||
@@ -206,13 +239,13 @@ public class AuthService {
|
||||
|
||||
private void checkAccountStatus(User user) {
|
||||
if (Constants.ACCOUNT_STATUS_CLOSED.equals(user.getAccountStatus())) {
|
||||
throw new RuntimeException("账号已注销");
|
||||
throw new BusinessException(ApiResponseCode.ACCOUNT_CLOSED, "账号已注销");
|
||||
}
|
||||
if (Constants.ACCOUNT_STATUS_BANNED.equals(user.getAccountStatus())) {
|
||||
throw new RuntimeException("账号已被封禁");
|
||||
throw new BusinessException(ApiResponseCode.USER_BANNED, "账号已被封禁");
|
||||
}
|
||||
if (Constants.ACCOUNT_STATUS_SUSPENDED.equals(user.getAccountStatus())) {
|
||||
throw new RuntimeException("账号已暂停");
|
||||
throw new BusinessException(ApiResponseCode.USER_SUSPENDED, "账号已暂停");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler;
|
||||
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -100,6 +101,9 @@ public class OperatorAuthService {
|
||||
.toList();
|
||||
|
||||
// 签发 Token
|
||||
// ZC-MM-006 / T02 声明:claims 中的 permissions 仅为登录时刻的展示快照(只写不读),
|
||||
// **不是授权事实源** —— 服务端每请求实时查库(JwtAuthFilter → PermissionMapper),
|
||||
// 权限回收/角色调整/账号禁用即时生效,无需等待 token 过期或撤销名单。
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("operatorId", operator.getId());
|
||||
claims.put("organizationId", operator.getOrganizationId());
|
||||
@@ -144,7 +148,7 @@ public class OperatorAuthService {
|
||||
*/
|
||||
public Map<String, Object> getMe(String operatorId) {
|
||||
Operator operator = operatorMapper.findById(operatorId)
|
||||
.orElseThrow(() -> new RuntimeException("Operator 不存在"));
|
||||
.orElseThrow(() -> new GlobalExceptionHandler.BusinessException(ApiResponseCode.NOT_FOUND, "Operator 不存在"));
|
||||
|
||||
List<Permission> permissions = permissionMapper.findPermissionsByOperatorId(operatorId);
|
||||
List<String> permissionCodes = permissions.stream()
|
||||
@@ -165,11 +169,25 @@ public class OperatorAuthService {
|
||||
|
||||
/**
|
||||
* 刷新 Operator Token
|
||||
*
|
||||
* <p>与 {@link AuthService#refreshToken} 对齐两点(2026-09-22):
|
||||
* 令牌先自证(签名/有效期/type,用 refresh 密钥),再比对 Redis 登记值;
|
||||
* 失败回 401 而非 500 —— 否则客户端会把「该重新登录」误判为服务端故障并重试。
|
||||
*/
|
||||
public Map<String, Object> refreshToken(String operatorId, String refreshToken) {
|
||||
if (refreshToken == null || refreshToken.isBlank()
|
||||
|| !jwtTokenService.validateRefreshToken(refreshToken)) {
|
||||
throw new UnauthorizedException("Refresh Token 无效");
|
||||
}
|
||||
|
||||
// 前端未传 operatorId 时,由 refresh token 自证身份(Cookie 场景下前端不持有 token)
|
||||
if (operatorId == null || operatorId.isBlank()) {
|
||||
operatorId = jwtTokenService.getUserIdFromRefreshToken(refreshToken);
|
||||
}
|
||||
|
||||
String storedToken = redisTemplate.opsForValue().get("auth:operator_refresh:" + operatorId);
|
||||
if (storedToken == null || !storedToken.equals(refreshToken)) {
|
||||
throw new RuntimeException("Refresh Token 无效");
|
||||
throw new UnauthorizedException("Refresh Token 无效");
|
||||
}
|
||||
|
||||
Map<String, Object> operatorInfo = getMe(operatorId);
|
||||
@@ -184,6 +202,12 @@ public class OperatorAuthService {
|
||||
result.put("accessToken", accessToken);
|
||||
result.put("refreshToken", newRefreshToken);
|
||||
result.put("expiresIn", jwtTokenService.getExpirationMs() / 1000);
|
||||
// 同步 operator 概要:前端凭 HttpOnly refresh cookie 静默刷新后,可直接恢复菜单态,无需重新登录
|
||||
result.put("operatorId", operatorInfo.get("operatorId"));
|
||||
result.put("name", operatorInfo.get("name"));
|
||||
result.put("username", operatorInfo.get("username"));
|
||||
result.put("organizationId", operatorInfo.get("organizationId"));
|
||||
result.put("permissions", operatorInfo.get("permissions"));
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,20 @@ import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Date;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class JwtTokenService {
|
||||
|
||||
/**
|
||||
* Refresh Token 的用途声明值。
|
||||
*
|
||||
* <p>校验时必须匹配,见 {@link #validateRefreshToken}:一旦运维把两把密钥配成同一个值,
|
||||
* 签名校验就无法区分两类令牌,只有这个声明能阻止 access token 被当作 refresh token 续期。
|
||||
*/
|
||||
public static final String TOKEN_TYPE_REFRESH = "refresh";
|
||||
|
||||
private final TokenBlacklistService tokenBlacklistService;
|
||||
|
||||
@Value("${jwt.access-token-secret}")
|
||||
@@ -46,6 +55,12 @@ public class JwtTokenService {
|
||||
|
||||
JwtBuilder builder = Jwts.builder()
|
||||
.subject(userId)
|
||||
// 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type,
|
||||
// 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。
|
||||
// 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等,
|
||||
// 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。
|
||||
// 2026-09-22 实测:50 次签发只产生 1 个不同令牌。
|
||||
.id(UUID.randomUUID().toString())
|
||||
.issuedAt(now)
|
||||
.expiration(expiry)
|
||||
.claim("type", "access")
|
||||
@@ -67,9 +82,15 @@ public class JwtTokenService {
|
||||
|
||||
return Jwts.builder()
|
||||
.subject(userId)
|
||||
// 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type,
|
||||
// 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。
|
||||
// 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等,
|
||||
// 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。
|
||||
// 2026-09-22 实测:50 次签发只产生 1 个不同令牌。
|
||||
.id(UUID.randomUUID().toString())
|
||||
.issuedAt(now)
|
||||
.expiration(expiry)
|
||||
.claim("type", "refresh")
|
||||
.claim("type", TOKEN_TYPE_REFRESH)
|
||||
.signWith(getRefreshKey())
|
||||
.compact();
|
||||
}
|
||||
@@ -83,6 +104,12 @@ public class JwtTokenService {
|
||||
|
||||
JwtBuilder builder = Jwts.builder()
|
||||
.subject(operatorId)
|
||||
// 唯一标识(jti)不是装饰:载荷只有 subject / iat(秒级) / type,
|
||||
// 同一用户在同一秒内重复签发会得到**逐字节相同**的令牌。
|
||||
// 后果是刷新时写入 Redis 的「新」refresh token 与旧值相等,
|
||||
// 于是「刷新即轮换、旧令牌立即作废」这一安全属性直接失效。
|
||||
// 2026-09-22 实测:50 次签发只产生 1 个不同令牌。
|
||||
.id(UUID.randomUUID().toString())
|
||||
.issuedAt(now)
|
||||
.expiration(expiry)
|
||||
.claim("type", "operator_access")
|
||||
@@ -142,9 +169,67 @@ public class JwtTokenService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Token 过期时间
|
||||
* 验证并解析 **Refresh** Token。
|
||||
*
|
||||
* <p><b>必须用 refresh 密钥验签</b>:{@link #generateRefreshToken} 用
|
||||
* {@code jwt.refresh-token-secret} 签发,而 {@link #parseToken} 用的是
|
||||
* {@code jwt.access-token-secret}。两把密钥在 dev / test / production 的取值
|
||||
* 各不相同(见 application-*.yml),因此拿 {@code parseToken} 去验 refresh token
|
||||
* 必然抛签名异常 —— 这正是 2026-09-22 定位到的「刷新永远失败 → 登录态被清 →
|
||||
* 全站 401」的根因:前端此前修好了缺 userId 的 400,仍会在这一层必然失败。
|
||||
*/
|
||||
public Claims parseRefreshToken(String token) {
|
||||
return Jwts.parser()
|
||||
.verifyWith(getRefreshKey())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Refresh Token:签名有效、未过期、未被撤销,且 {@code type} 必须为 refresh。
|
||||
*
|
||||
* <p>type 断言不是冗余:一旦运维把两把密钥配成同一个值(或都用了默认占位值),
|
||||
* 签名校验就无法区分两类令牌,access token 会被当作 refresh token 换出新令牌,
|
||||
* 使「短期凭证」获得「长期凭证」的续期能力。此处把令牌用途钉死在声明里。
|
||||
*/
|
||||
public boolean validateRefreshToken(String token) {
|
||||
if (token == null || token.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (tokenBlacklistService.isBlacklisted(token)) {
|
||||
return false;
|
||||
}
|
||||
return TOKEN_TYPE_REFRESH.equals(parseRefreshToken(token).get("type", String.class));
|
||||
} catch (JwtException | IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Refresh Token 解析用户 ID(用于客户端未传 userId 时自证身份,见 AuthService#refreshToken)。
|
||||
*/
|
||||
public String getUserIdFromRefreshToken(String token) {
|
||||
return parseRefreshToken(token).getSubject();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Access Token 过期时间(毫秒)
|
||||
*/
|
||||
public long getExpirationMs() {
|
||||
return accessExpirationMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取 Refresh Token 过期时间(毫秒)。
|
||||
*
|
||||
* <p>刷新令牌 Redis 登记的 TTL 必须使用本值,而不是 {@link #getExpirationMs()}
|
||||
* (access 过期,默认 2h)。此前 AuthService 用 {@code getExpirationMs() * 2}
|
||||
* 把 Redis 记录设成 4h,而 refresh JWT 自身有效期是 {@code refreshExpirationMs}
|
||||
* (默认 14 天)—— 4h 后 Redis 记录消失,刷新即抛未授权,用户被迫每 4h 重新登录。
|
||||
*/
|
||||
public long getRefreshExpirationMs() {
|
||||
return refreshExpirationMs;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.danyang.match.platform.contactauth.controller;
|
||||
|
||||
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
|
||||
import com.danyang.match.platform.shared.dto.ApiResponse;
|
||||
import com.danyang.match.platform.shared.security.principal.UserPrincipal;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 受控联系授权控制器(C 端用户,ZC-MM-003 / B1 立项)。
|
||||
*
|
||||
* <p>端点(基路径 {@code /api/v1/contact-authorizations},USER 角色兜底):
|
||||
* <ul>
|
||||
* <li>GET {@code /my} —— 我的授权列表 + 待同意队列;</li>
|
||||
* <li>POST {@code /{id}/consent} —— 用户<b>单独明示同意</b>(独立端点,
|
||||
* 非任何其他操作的捆绑步骤、无预勾选/预同意参数);</li>
|
||||
* <li>POST {@code /{id}/decline} —— 用户拒绝。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>归属校验(userId.equals)在 Service 层 {@code requireOwnedAuthorization} 承担;
|
||||
* 不碰 SecurityConfig(路径落入 {@code /** → hasRole("USER")} 兜底规则)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/contact-authorizations")
|
||||
@RequiredArgsConstructor
|
||||
public class ContactAuthorizationUserController {
|
||||
|
||||
private final ContactAuthorizationService contactAuthorizationService;
|
||||
|
||||
/** 我的授权列表 + 待同意队列。 */
|
||||
@GetMapping("/my")
|
||||
public ApiResponse<Map<String, Object>> my(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(contactAuthorizationService.myAuthorizationsForUser(principal.id()));
|
||||
}
|
||||
|
||||
/** 单独明示同意(独立端点,无预同意参数)。 */
|
||||
@PostMapping("/{id}/consent")
|
||||
public ApiResponse<Object> consent(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(contactAuthorizationService.consent(id, principal.id()));
|
||||
}
|
||||
|
||||
/** 拒绝授权请求。 */
|
||||
@PostMapping("/{id}/decline")
|
||||
public ApiResponse<Object> decline(@PathVariable String id,
|
||||
@AuthenticationPrincipal UserPrincipal principal) {
|
||||
if (principal == null) return ApiResponse.unauthorized();
|
||||
return ApiResponse.ok(contactAuthorizationService.decline(id, principal.id()));
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.danyang.match.platform.counseling.service;
|
||||
|
||||
import com.danyang.match.platform.audit.service.AuditLogService;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingFollowup;
|
||||
import com.danyang.match.platform.counseling.entity.CounselingServiceRecord;
|
||||
import com.danyang.match.platform.counseling.mapper.CounselingFollowupMapper;
|
||||
@@ -78,6 +79,7 @@ public class CounselingService {
|
||||
private final NotificationService notificationService;
|
||||
private final RiskReferralService riskReferralService;
|
||||
private final DataScopeGuard dataScopeGuard;
|
||||
private final AuditLogService auditLogService;
|
||||
|
||||
// ==================== 用户端:申请 ====================
|
||||
|
||||
@@ -264,6 +266,23 @@ public class CounselingService {
|
||||
fu.setResultCode(resultCode);
|
||||
fu.setNextFollowupAt(nextFollowupAt);
|
||||
followupMapper.insert(fu);
|
||||
|
||||
// P13A-T33: 随访正文与申请摘要共用同一风险检测(来源同为 source=counseling)。
|
||||
// 随访由 counselor 撰写、更贴近用户近况,是风险关键词的另一个真实入口;
|
||||
// 此前只有申请摘要(apply:133)被扫描,随访正文完全绕过风控。
|
||||
// INV-013 加固同 apply:绝不记录正文,只记级别与记录 ID。
|
||||
CounselingServiceRecord owner = mapper.findFull(serviceId);
|
||||
if (owner != null) {
|
||||
try {
|
||||
String riskLevel = riskReferralService.detectCounselingRisk(owner.getUserId(), content);
|
||||
if (riskLevel != null) {
|
||||
log.info("随访内容触发风险检测: serviceId={}, followupId={}, riskLevel={}",
|
||||
serviceId, fu.getId(), riskLevel);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("随访风险检测异常: serviceId={}, err={}", serviceId, e.getMessage());
|
||||
}
|
||||
}
|
||||
return fu;
|
||||
}
|
||||
|
||||
@@ -333,6 +352,12 @@ public class CounselingService {
|
||||
"INV-012: 只能查看分配给自己的咨询服务");
|
||||
return null;
|
||||
}
|
||||
// P13A-T31: assigned Counselor 读取的是**他人**的咨询正文与随访记录 ——
|
||||
// 敏感访问必须留痕(同 Phase 17 report/risk_profile 的 recordSensitiveAccess 模式)。
|
||||
// 本人读自己的不算敏感访问;manager/matchmaker 走脱敏摘要视图,不触正文,无需审计。
|
||||
if (isAssignedCounselor) {
|
||||
auditLogService.recordSensitiveAccess(requesterId, "counseling_followup", serviceId);
|
||||
}
|
||||
return svc;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,4 +20,9 @@ public class RecommendationRecord extends BaseEntity {
|
||||
private String action; // liked, passed, blocked, matched
|
||||
private java.time.Instant actionAt;
|
||||
private Integer ruleVersion;
|
||||
/**
|
||||
* 推荐判定当次所用的最低评分阈值快照(V050,MATCH-001)。
|
||||
* 配合 {@link #ruleVersion} 追溯历史推荐依据的规则版本;历史行为 null。
|
||||
*/
|
||||
private Integer thresholdSnapshot;
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ public class MatchEligibilityService {
|
||||
private final UserMapper userMapper;
|
||||
private final UserProfileMapper userProfileMapper;
|
||||
private final LocalEligibilityMapper localEligibilityMapper;
|
||||
/** 评分与双向 Required 的唯一事实源(2026-09-23 去桩:原候选池/评分/Required 均为 TODO 空实现)。 */
|
||||
private final MatchScoringService matchScoringService;
|
||||
|
||||
/**
|
||||
* 获取每日推荐列表
|
||||
@@ -103,14 +105,17 @@ public class MatchEligibilityService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. 双向 Required 检查
|
||||
// 2. 双向 Required 检查(唯一事实源 MatchScoringService;INV-001 Required 不可自动放宽)
|
||||
UserPreference targetPref = preferenceMapper.findByUserId(targetUserId).orElse(null);
|
||||
if (targetPref == null) {
|
||||
return null; // 目标用户未设置择偶条件,无法双向验证
|
||||
}
|
||||
|
||||
// A → B Required 检查
|
||||
if (!checkBidirectionalRequired(myPref, targetPref)) {
|
||||
UserProfile myProfile = userProfileMapper.findByUserId(userId).orElse(null);
|
||||
UserProfile targetProfile = userProfileMapper.findByUserId(targetUserId).orElse(null);
|
||||
if (myProfile == null || targetProfile == null) {
|
||||
return null; // 资料缺失则无法核验 Required,不假定满足
|
||||
}
|
||||
if (!matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, targetProfile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -175,36 +180,18 @@ public class MatchEligibilityService {
|
||||
return "verified".equals(el.getVerificationStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 双向 Required 检查
|
||||
* Required 是 Hard Constraint,不可自动放宽
|
||||
*/
|
||||
private boolean checkBidirectionalRequired(UserPreference myPref, UserPreference targetPref) {
|
||||
// 需要通过外部传入的 UserProfile 检查,本方法只比较 Preference
|
||||
// 完整检查在 getDailyRecommendations 的循环中调用 evaluateCandidate
|
||||
// 此处仅返回 true(实际过滤由 evaluateCandidate 的 isBasicEligible 和 Profile 数据完成)
|
||||
return true;
|
||||
}
|
||||
// checkBidirectionalRequired 已于 2026-09-23 收敛进 MatchScoringService.bidirectionalRequiredSatisfied
|
||||
// (原实现恒 return true,等于 Required 不生效——已删除,避免「声明做校验实际不校验」的 landmine)。
|
||||
|
||||
/**
|
||||
* 方向性评分(A对B的适配度)
|
||||
* 基于 Preferred 维度计算 0-100 分
|
||||
*/
|
||||
private double directionalScore(String fromUserId, String toUserId, UserPreference pref) {
|
||||
double score = 0;
|
||||
double totalWeight = pref.getAgeWeight() + pref.getRegionWeight() +
|
||||
pref.getMaritalWeight() + pref.getChildrenWeight() +
|
||||
pref.getEducationWeight() + pref.getHeightWeight() +
|
||||
pref.getMarriagePlanWeight() + pref.getSettlementWeight() +
|
||||
pref.getLifestyleWeight() + pref.getInterestWeight();
|
||||
|
||||
// TODO: 实现各维度评分逻辑
|
||||
// 年龄匹配:在范围内得满分,超出范围按比例扣分
|
||||
// 地区匹配:同地区得满分,不同地区按比例扣分
|
||||
// ...
|
||||
|
||||
if (totalWeight == 0.0) return 50.0; // 无权重配置时给默认分
|
||||
return (int)(Math.round(score / totalWeight * 100));
|
||||
// 委托唯一事实源;此前 score 恒 0(TODO 未实现)→ 任何候选都被阈值过滤。
|
||||
return userProfileMapper.findByUserId(toUserId)
|
||||
.map(p -> matchScoringService.scoreDirectionally(pref, p))
|
||||
.orElse(0.0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -212,20 +199,21 @@ public class MatchEligibilityService {
|
||||
*/
|
||||
private List<String> generateReasons(String userId, String targetUserId,
|
||||
UserPreference myPref, UserPreference targetPref) {
|
||||
List<String> reasons = new ArrayList<>();
|
||||
// TODO: 根据实际资料生成真实推荐理由
|
||||
reasons.add("年龄符合双方择偶要求");
|
||||
reasons.add("都长期生活在丹阳");
|
||||
reasons.add("婚姻计划接近");
|
||||
return reasons;
|
||||
// 委托唯一事实源,按实际达成维度生成(此前为无条件套话)。
|
||||
return userProfileMapper.findByUserId(targetUserId)
|
||||
.map(p -> matchScoringService.reasons(myPref, p))
|
||||
.orElseGet(ArrayList::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已Pass的用户ID集合(冷却期内)
|
||||
*/
|
||||
private Set<String> getPassedUserIds(String userId, Instant since) {
|
||||
// TODO: 查询 user_relations 表中 passed 关系的 target_user_id
|
||||
return Collections.emptySet();
|
||||
// 此前恒返回空集(TODO)→ Pass 冷却期失效;改为读取推荐记录中的 passed 行为。
|
||||
return recommendationRecordMapper.findRecentByUserId(userId, since).stream()
|
||||
.filter(r -> "passed".equals(r.getAction()))
|
||||
.map(RecommendationRecord::getTargetUserId)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -240,14 +228,28 @@ public class MatchEligibilityService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建候选池(简化版)
|
||||
* TODO: Phase 06 后续接入数据库查询可用用户
|
||||
* 构建候选池:查库取「性别相反 + 审核通过 + 未软删」的用户,再排除自己/已 Pass 冷却期/今日已推荐。
|
||||
* (2026-09-23:替换原 `return List.of()` 空实现。)
|
||||
*/
|
||||
private List<String> buildCandidatePool(String userId, UserPreference myPref,
|
||||
Set<String> excludedIds, Set<String> alreadyRecommended) {
|
||||
// TODO: 从数据库查询符合条件的用户
|
||||
// 当前返回空列表,等待后续阶段完善
|
||||
return List.of();
|
||||
// 此前 return List.of()(TODO 未实现)→ 此路径必然无候选。
|
||||
// 基础过滤:性别相反 + 审核通过 + 未软删;其余条件在 evaluateCandidate 逐条判定。
|
||||
UserProfile me = userProfileMapper.findByUserId(userId).orElse(null);
|
||||
com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<UserProfile> wrapper =
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<>();
|
||||
wrapper.isNotNull(UserProfile::getGender)
|
||||
.eq(UserProfile::getProfileStatus, Constants.PROFILE_STATUS_APPROVED)
|
||||
.isNull(UserProfile::getDeletedAt);
|
||||
if (me != null && me.getGender() != null && !me.getGender().isBlank()) {
|
||||
wrapper.ne(UserProfile::getGender, me.getGender());
|
||||
}
|
||||
return userProfileMapper.selectList(wrapper).stream()
|
||||
.map(UserProfile::getUserId)
|
||||
.filter(id -> !id.equals(userId))
|
||||
.filter(id -> !excludedIds.contains(id))
|
||||
.filter(id -> !alreadyRecommended.contains(id))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private RecommendationCandidateDTO toDTO(String userId, double score, List<String> reasons) {
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
package com.danyang.match.platform.match.service;
|
||||
|
||||
import com.danyang.match.platform.profile.entity.UserPreference;
|
||||
import com.danyang.match.platform.profile.entity.UserProfile;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 匹配评分 —— **全仓唯一事实源**。
|
||||
*
|
||||
* <p>背景:`RecommendationEngineService.calculateScore` 与
|
||||
* `MatchEligibilityService.directionalScore` 曾各自是**桩**(前者 `return 50.0`、
|
||||
* 后者 `score` 恒 0),而阈值 `match.default-score-threshold` 默认 60 →
|
||||
* `mutualScore < 60` 恒成立 → **推荐列表恒为空**(2026-09-23 UAT 走查实测确认)。
|
||||
* 且两处评分逻辑重复,是典型的「双实现漂移」隐患。
|
||||
*
|
||||
* <p>本类把「择偶条件 × 候选人资料」的方向性评分收敛到一处,供两条推荐路径共用。
|
||||
*
|
||||
* <p><b>规则(不新造常量)</b>:直接使用 {@link UserPreference} 上<b>已存在</b>的
|
||||
* 十个维度权重;只为「用户实际设置了的条件」计分(未设置 = 不构成约束,不计入分母)。
|
||||
* 某维度上候选人缺少可用于判定的资料时,该维度计 0 分(「无法核验即不假定满足」,
|
||||
* 与 Required「不可自动放宽」一致)。若用户未设置任何条件,视为无约束 → 返回 100。
|
||||
*
|
||||
* <p>返回值域 0–100;调用方按 `match.default-score-threshold`(技术默认 60,OQ-MATCH-001)过滤。
|
||||
*/
|
||||
@Service
|
||||
public class MatchScoringService {
|
||||
|
||||
/** 学历高低序列,取索引差做比较(与 chk_profile_edu 顺序一致)。 */
|
||||
private static final List<String> EDUCATION_ORDER = List.of(
|
||||
"primary", "junior_high", "senior_high", "vocational", "college", "bachelor", "master", "doctor");
|
||||
|
||||
/**
|
||||
* 方向性评分:seeker 的择偶条件对 candidate 资料的适配度(0–100)。
|
||||
*
|
||||
* @param pref seeker 的择偶条件(可为 null → 无约束)
|
||||
* @param candidate 候选人资料
|
||||
*/
|
||||
public double scoreDirectionally(UserPreference pref, UserProfile candidate) {
|
||||
if (candidate == null) return 0;
|
||||
if (pref == null) return 100;
|
||||
|
||||
double acc = 0; // 加权得分
|
||||
double denom = 0; // 已设置条件的权重合计
|
||||
|
||||
// —— 年龄 ——(设置了 min/maxAge 才构成约束)
|
||||
if (pref.getMinAge() != null && pref.getMaxAge() != null) {
|
||||
double w = w(pref.getAgeWeight());
|
||||
denom += w;
|
||||
acc += w * rangeRatio(age(candidate.getBirthDate()), pref.getMinAge(), pref.getMaxAge());
|
||||
}
|
||||
// —— 身高 ——
|
||||
if (pref.getMinHeightCm() != null && pref.getMaxHeightCm() != null) {
|
||||
double w = w(pref.getHeightWeight());
|
||||
denom += w;
|
||||
Integer h = candidate.getHeightCm();
|
||||
acc += w * (h == null ? 0 : rangeRatio(h, pref.getMinHeightCm(), pref.getMaxHeightCm()));
|
||||
}
|
||||
// —— 地区(regionScope 为地区码数组)——
|
||||
if (hasJsonArray(pref.getRegionScope())) {
|
||||
double w = w(pref.getRegionWeight());
|
||||
denom += w;
|
||||
acc += w * (inJsonArray(pref.getRegionScope(), candidate.getRegionCode()) ? 1 : 0);
|
||||
}
|
||||
// —— 婚姻状况(maritalStatuses 数组)——
|
||||
if (hasJsonArray(pref.getMaritalStatuses())) {
|
||||
double w = w(pref.getMaritalWeight());
|
||||
denom += w;
|
||||
acc += w * (inJsonArray(pref.getMaritalStatuses(), candidate.getMaritalStatus()) ? 1 : 0);
|
||||
}
|
||||
// —— 子女(childrenAcceptance:any/accept/reject,兼容中文)——
|
||||
if (notBlank(pref.getChildrenAcceptance())) {
|
||||
double w = w(pref.getChildrenWeight());
|
||||
denom += w;
|
||||
acc += w * (childrenAcceptable(pref.getChildrenAcceptance(), candidate.getChildrenStatus()) ? 1 : 0);
|
||||
}
|
||||
// —— 学历(minEducationCode 为下限)——
|
||||
if (notBlank(pref.getMinEducationCode())) {
|
||||
double w = w(pref.getEducationWeight());
|
||||
denom += w;
|
||||
acc += w * (educationAtLeast(candidate.getEducationCode(), pref.getMinEducationCode()) ? 1 : 0);
|
||||
}
|
||||
// —— 婚恋规划(marriagePlanPreference 与 candidate.marriagePlan 一致)——
|
||||
if (notBlank(pref.getMarriagePlanPreference())) {
|
||||
double w = w(pref.getMarriagePlanWeight());
|
||||
denom += w;
|
||||
acc += w * (sameValue(pref.getMarriagePlanPreference(), candidate.getMarriagePlan()) ? 1 : 0);
|
||||
}
|
||||
// —— 定居意愿(settlementPreference 对照 candidate 长期/未来丹阳意向)——
|
||||
if (notBlank(pref.getSettlementPreference())) {
|
||||
double w = w(pref.getSettlementWeight());
|
||||
denom += w;
|
||||
boolean ok = sameValue(pref.getSettlementPreference(), candidate.getLongTermDanyang())
|
||||
|| sameValue(pref.getSettlementPreference(), candidate.getFutureDanyang());
|
||||
acc += w * (ok ? 1 : 0);
|
||||
}
|
||||
// —— 生活方式(吸烟 / 饮酒,取已设置者的均值)——
|
||||
double lifeW = w(pref.getLifestyleWeight());
|
||||
int lifeDims = 0;
|
||||
double lifeHit = 0;
|
||||
if (notBlank(pref.getSmokingPreference())) {
|
||||
lifeDims++;
|
||||
if (sameValue(pref.getSmokingPreference(), candidate.getSmokingStatus())) lifeHit++;
|
||||
}
|
||||
if (notBlank(pref.getDrinkingPreference())) {
|
||||
lifeDims++;
|
||||
if (sameValue(pref.getDrinkingPreference(), candidate.getDrinkingStatus())) lifeHit++;
|
||||
}
|
||||
if (lifeDims > 0) {
|
||||
denom += lifeW;
|
||||
acc += lifeW * (lifeHit / lifeDims);
|
||||
}
|
||||
// interestWeight:当前无对应择偶条件字段,不参与计分(保留权重以备扩展)。
|
||||
|
||||
if (denom == 0) return 100; // 用户未设置任何条件 → 无约束
|
||||
return clamp(acc / denom * 100.0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成中文推荐理由(基于**实际达成**的维度,不再是无条件套话)。
|
||||
* 未达成任何具体维度时回退为「资料符合基本要求」。
|
||||
*/
|
||||
public List<String> reasons(UserPreference pref, UserProfile candidate) {
|
||||
List<String> out = new ArrayList<>();
|
||||
if (pref == null || candidate == null) return out;
|
||||
if (pref.getMinAge() != null && pref.getMaxAge() != null
|
||||
&& rangeRatio(age(candidate.getBirthDate()), pref.getMinAge(), pref.getMaxAge()) > 0) {
|
||||
out.add("年龄在您的要求范围内");
|
||||
}
|
||||
if (hasJsonArray(pref.getRegionScope()) && inJsonArray(pref.getRegionScope(), candidate.getRegionCode())) {
|
||||
out.add("同在您期望的地区");
|
||||
}
|
||||
if (hasJsonArray(pref.getMaritalStatuses())
|
||||
&& inJsonArray(pref.getMaritalStatuses(), candidate.getMaritalStatus())) {
|
||||
out.add("婚姻状况符合您的期望");
|
||||
}
|
||||
if (notBlank(pref.getMinEducationCode())
|
||||
&& educationAtLeast(candidate.getEducationCode(), pref.getMinEducationCode())) {
|
||||
out.add("学历符合您的要求");
|
||||
}
|
||||
if (notBlank(pref.getAgePriority()) && "required".equals(pref.getAgePriority())) {
|
||||
out.add("年龄为您设定的必要条件");
|
||||
}
|
||||
if (out.isEmpty()) out.add("资料符合基本要求");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 双向 Required 硬约束校验(INV-001「Required 不可自动放宽」)——唯一事实源。
|
||||
*
|
||||
* <p>任一方把某维度设为 `required`,则对方该维度<b>必须</b>落在要求内,否则整条候选被否。
|
||||
* 覆盖年龄 / 身高 / 地区 / 婚姻状况四类(当前产品定义的 Required 维度)。
|
||||
*
|
||||
* @return true 表示双向 Required 全部满足
|
||||
*/
|
||||
public boolean bidirectionalRequiredSatisfied(UserPreference myPref, UserProfile myProfile,
|
||||
UserPreference targetPref, UserProfile targetProfile) {
|
||||
if (myPref == null || myProfile == null || targetPref == null || targetProfile == null) return false;
|
||||
|
||||
// 年龄
|
||||
if (isRequired(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
|
||||
Integer candAge = age(targetProfile.getBirthDate());
|
||||
if (candAge == null || candAge < myPref.getMinAge() || candAge > myPref.getMaxAge()) return false;
|
||||
}
|
||||
if (isRequired(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) {
|
||||
Integer myAge = age(myProfile.getBirthDate());
|
||||
if (myAge == null || myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) return false;
|
||||
}
|
||||
// 身高
|
||||
if (isRequired(myPref.getHeightPriority()) && myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) {
|
||||
Integer h = targetProfile.getHeightCm();
|
||||
if (h == null || h < myPref.getMinHeightCm() || h > myPref.getMaxHeightCm()) return false;
|
||||
}
|
||||
if (isRequired(targetPref.getHeightPriority()) && targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) {
|
||||
Integer h = myProfile.getHeightCm();
|
||||
if (h == null || h < targetPref.getMinHeightCm() || h > targetPref.getMaxHeightCm()) return false;
|
||||
}
|
||||
// 地区(Required 用 isJsonArray:空数组=自相矛盾的硬约束 → 拒绝;不可放宽为「无约束」)
|
||||
if (isRequired(myPref.getRegionPriority()) && isJsonArray(myPref.getRegionScope())
|
||||
&& !inJsonArray(myPref.getRegionScope(), targetProfile.getRegionCode())) return false;
|
||||
if (isRequired(targetPref.getRegionPriority()) && isJsonArray(targetPref.getRegionScope())
|
||||
&& !inJsonArray(targetPref.getRegionScope(), myProfile.getRegionCode())) return false;
|
||||
// 婚姻状况
|
||||
if (isRequired(myPref.getMaritalPriority()) && isJsonArray(myPref.getMaritalStatuses())
|
||||
&& !inJsonArray(myPref.getMaritalStatuses(), targetProfile.getMaritalStatus())) return false;
|
||||
if (isRequired(targetPref.getMaritalPriority()) && isJsonArray(targetPref.getMaritalStatuses())
|
||||
&& !inJsonArray(targetPref.getMaritalStatuses(), myProfile.getMaritalStatus())) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static boolean isRequired(String priority) { return "required".equals(priority); }
|
||||
|
||||
// ==================== 私有工具 ====================
|
||||
|
||||
private static double w(Double v) { return v == null ? 0 : Math.max(0, v); }
|
||||
|
||||
private static boolean notBlank(String s) { return s != null && !s.isBlank(); }
|
||||
|
||||
private static Integer age(LocalDate birthDate) {
|
||||
if (birthDate == null) return null;
|
||||
return Period.between(birthDate, LocalDate.now()).getYears();
|
||||
}
|
||||
|
||||
/** 在 [min,max] 内 → 1;否则按越界距离线性衰减(跨度越大衰减越慢),最低 0。 */
|
||||
private static double rangeRatio(Integer value, int min, int max) {
|
||||
if (value == null) return 0;
|
||||
if (min > max) { int t = min; min = max; max = t; }
|
||||
if (value >= min && value <= max) return 1.0;
|
||||
int span = Math.max(1, max - min);
|
||||
int distance = value < min ? (min - value) : (value - max);
|
||||
return Math.max(0.0, 1.0 - (double) distance / span);
|
||||
}
|
||||
|
||||
/** 评分用:仅当数组**非空**才算「用户表达了该条件」。空数组 = 未设置,不计入分母。 */
|
||||
private static boolean hasJsonArray(JsonNode node) {
|
||||
return node != null && node.isArray() && node.size() > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Required 校验用:只要节点是数组即「用户声明了该硬约束」。
|
||||
* 空数组是自相矛盾的声明(要求必填却无任何可接受值)→ 交由调用方判为不满足,
|
||||
* 与「Required 不可自动放宽」(INV-001)一致;不可退化成「无约束」。
|
||||
*/
|
||||
private static boolean isJsonArray(JsonNode node) {
|
||||
return node != null && node.isArray();
|
||||
}
|
||||
|
||||
private static boolean inJsonArray(JsonNode node, String value) {
|
||||
if (node == null || !node.isArray() || value == null) return false;
|
||||
for (int i = 0; i < node.size(); i++) {
|
||||
if (value.equals(node.get(i).asText())) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 子女接受度:any/不限 → 恒满足;accept/可接受 → 有孩与否皆可;reject/不接受 → 仅无孩。 */
|
||||
private static boolean childrenAcceptable(String acceptance, String childrenStatus) {
|
||||
String a = acceptance == null ? "" : acceptance.trim();
|
||||
boolean candidateHasChildren = "yes".equals(childrenStatus) || "has".equals(childrenStatus)
|
||||
|| "有".equals(childrenStatus);
|
||||
return switch (a) {
|
||||
case "any", "不限" -> true;
|
||||
case "accept", "可接受" -> true; // 接受带孩(且同样接受无孩)
|
||||
case "reject", "不接受" -> !candidateHasChildren;
|
||||
default -> true;
|
||||
};
|
||||
}
|
||||
|
||||
private static boolean educationAtLeast(String candidateCode, String minCode) {
|
||||
int ci = EDUCATION_ORDER.indexOf(candidateCode);
|
||||
int mi = EDUCATION_ORDER.indexOf(minCode);
|
||||
if (mi < 0) return true; // 下限码不在白名单 → 不做约束
|
||||
if (ci < 0) return false; // 候选人学历缺失/未知 → 无法核验,不假定满足
|
||||
return ci >= mi;
|
||||
}
|
||||
|
||||
/** 宽松相等:去空白、忽略大小写;两边都非空才比较。 */
|
||||
private static boolean sameValue(String a, String b) {
|
||||
if (!notBlank(a) || !notBlank(b)) return false;
|
||||
return a.trim().equalsIgnoreCase(b.trim());
|
||||
}
|
||||
|
||||
private static double clamp(double v) {
|
||||
if (v < 0) return 0;
|
||||
if (v > 100) return 100;
|
||||
return v;
|
||||
}
|
||||
|
||||
/** 便于单测/文档:常量键说明。 */
|
||||
public static Map<String, String> weightKeys() {
|
||||
return Map.of(
|
||||
"age", "ageWeight", "region", "regionWeight", "marital", "maritalWeight",
|
||||
"children", "childrenWeight", "education", "educationWeight", "height", "heightWeight",
|
||||
"marriagePlan", "marriagePlanWeight", "lifestyle", "lifestyleWeight",
|
||||
"settlement", "settlementWeight", "interest", "interestWeight");
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.Period;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
@@ -55,6 +54,8 @@ public class RecommendationEngineService {
|
||||
private final UserMapper userMapper;
|
||||
private final UserPrivacySettingsMapper privacySettingsMapper;
|
||||
private final LocalEligibilityMapper localEligibilityMapper;
|
||||
/** 评分唯一事实源(2026-09-23:替换原 `return 50.0` 桩,修复「推荐恒空」)。 */
|
||||
private final MatchScoringService matchScoringService;
|
||||
|
||||
/**
|
||||
* 获取每日推荐列表
|
||||
@@ -241,86 +242,16 @@ public class RecommendationEngineService {
|
||||
return userProfileMapper.findByUserId(userId).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 双向 Required 硬约束校验(INV-001「Required 不可自动放宽」)——委托
|
||||
* {@link MatchScoringService#bidirectionalRequiredSatisfied} 唯一事实源。
|
||||
*
|
||||
* <p>此前本方法自带一份等价实现,与事实源构成「双实现漂移」隐患;2026-09-23 收敛为单点。
|
||||
* 参数对映:本方法的 {@code candidate} 即事实源签名中的 {@code targetProfile}。
|
||||
*/
|
||||
boolean checkBidirectionalRequired(UserPreference myPref, UserProfile candidate,
|
||||
UserPreference targetPref, UserProfile myProfile) {
|
||||
if (myPref == null || candidate == null || targetPref == null || myProfile == null) return false;
|
||||
// 1. 年龄 Required 检查
|
||||
if ("required".equals(myPref.getAgePriority()) && myPref.getMinAge() != null && myPref.getMaxAge() != null) {
|
||||
int candidateAge = calculateAge(candidate.getBirthDate());
|
||||
if (candidateAge < myPref.getMinAge() || candidateAge > myPref.getMaxAge()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if ("required".equals(targetPref.getAgePriority()) && targetPref.getMinAge() != null && targetPref.getMaxAge() != null) {
|
||||
int myAge = calculateAge(myProfile.getBirthDate());
|
||||
if (myAge < targetPref.getMinAge() || myAge > targetPref.getMaxAge()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 身高 Required 检查
|
||||
if ("required".equals(myPref.getHeightPriority())
|
||||
&& myPref.getMinHeightCm() != null && myPref.getMaxHeightCm() != null) {
|
||||
if (candidate.getHeightCm() == null
|
||||
|| candidate.getHeightCm() < myPref.getMinHeightCm()
|
||||
|| candidate.getHeightCm() > myPref.getMaxHeightCm()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if ("required".equals(targetPref.getHeightPriority())
|
||||
&& targetPref.getMinHeightCm() != null && targetPref.getMaxHeightCm() != null) {
|
||||
if (myProfile.getHeightCm() == null
|
||||
|| myProfile.getHeightCm() < targetPref.getMinHeightCm()
|
||||
|| myProfile.getHeightCm() > targetPref.getMaxHeightCm()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 地区 Required 检查
|
||||
if ("required".equals(myPref.getRegionPriority()) && myPref.getRegionScope() != null && myPref.getRegionScope().isArray()) {
|
||||
boolean inScope = false;
|
||||
for (int i = 0; i < myPref.getRegionScope().size(); i++) {
|
||||
if (myPref.getRegionScope().get(i).asText().equals(candidate.getRegionCode())) {
|
||||
inScope = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inScope) return false;
|
||||
}
|
||||
if ("required".equals(targetPref.getRegionPriority()) && targetPref.getRegionScope() != null && targetPref.getRegionScope().isArray()) {
|
||||
boolean inScope = false;
|
||||
for (int i = 0; i < targetPref.getRegionScope().size(); i++) {
|
||||
if (targetPref.getRegionScope().get(i).asText().equals(myProfile.getRegionCode())) {
|
||||
inScope = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inScope) return false;
|
||||
}
|
||||
|
||||
// 4. 婚姻状况 Required 检查
|
||||
if ("required".equals(myPref.getMaritalPriority()) && myPref.getMaritalStatuses() != null && myPref.getMaritalStatuses().isArray()) {
|
||||
boolean accepted = false;
|
||||
for (int i = 0; i < myPref.getMaritalStatuses().size(); i++) {
|
||||
if (myPref.getMaritalStatuses().get(i).asText().equals(candidate.getMaritalStatus())) {
|
||||
accepted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!accepted) return false;
|
||||
}
|
||||
if ("required".equals(targetPref.getMaritalPriority()) && targetPref.getMaritalStatuses() != null && targetPref.getMaritalStatuses().isArray()) {
|
||||
boolean accepted = false;
|
||||
for (int i = 0; i < targetPref.getMaritalStatuses().size(); i++) {
|
||||
if (targetPref.getMaritalStatuses().get(i).asText().equals(myProfile.getMaritalStatus())) {
|
||||
accepted = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!accepted) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return matchScoringService.bidirectionalRequiredSatisfied(myPref, myProfile, targetPref, candidate);
|
||||
}
|
||||
|
||||
private boolean isParticipantEligible(String userId) {
|
||||
@@ -341,23 +272,19 @@ public class RecommendationEngineService {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 方向性评分:委托 {@link MatchScoringService}(唯一事实源)。
|
||||
*
|
||||
* <p>此前为桩 `return 50.0`,而阈值默认 60 → `mutualScore < 60` 恒成立,
|
||||
* 导致 `GET /recommendations` **恒返回空数组**(2026-09-23 UAT 走查实测)。
|
||||
*/
|
||||
private double calculateScore(String fromUserId, UserProfile target, UserPreference pref) {
|
||||
// TODO: Phase 07 后续完善评分逻辑
|
||||
return 50.0; // V1 简化:默认中间分
|
||||
return matchScoringService.scoreDirectionally(pref, target);
|
||||
}
|
||||
|
||||
private List<String> generateReasons(String userId, UserProfile candidate,
|
||||
UserPreference myPref, UserPreference targetPref) {
|
||||
List<String> reasons = new ArrayList<>();
|
||||
// TODO: 基于真实资料生成推荐理由
|
||||
reasons.add("年龄符合双方择偶要求");
|
||||
reasons.add("都希望长期在丹阳生活");
|
||||
return reasons;
|
||||
}
|
||||
|
||||
private int calculateAge(LocalDate birthDate) {
|
||||
if (birthDate == null) return 0;
|
||||
return Period.between(birthDate, LocalDate.now()).getYears();
|
||||
return matchScoringService.reasons(myPref, candidate);
|
||||
}
|
||||
|
||||
private Set<String> getPassedUserIds(String userId, Instant since) {
|
||||
@@ -386,6 +313,8 @@ public class RecommendationEngineService {
|
||||
record.setReasons(String.join(";", reasons));
|
||||
record.setShownAt(Instant.now());
|
||||
record.setRuleVersion(matchConfig.getRuleVersion());
|
||||
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
|
||||
record.setThresholdSnapshot(matchConfig.getDefaultScoreThreshold());
|
||||
recommendationRecordMapper.insert(record);
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,8 @@ public class RecommendationService {
|
||||
record.setReasons(new java.util.ArrayList<>(reasons).toString());
|
||||
record.setShownAt(Instant.now());
|
||||
record.setRuleVersion(matchConfig.getRuleVersion());
|
||||
// MATCH-001:快照当次判定所用阈值(配合 rule_version 追溯历史推荐依据的规则版本)
|
||||
record.setThresholdSnapshot(matchConfig.getDefaultScoreThreshold());
|
||||
recommendationRecordMapper.insert(record);
|
||||
log.debug("推荐记录: userId={}, targetUserId={}, level={}", userId, targetUserId, level);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.danyang.match.platform.matchmaker.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 受控联系授权配置(ZC-MM-003 / B1 立项,对照 {@code ProfileProperties} 范式)。
|
||||
*
|
||||
* <p><b>配置化纪律</b>(AGENTS.md #12:未确认数量参数不得硬编码):
|
||||
* 五个 canonical key 统一在此管理,禁止散落在 Controller/Service/前端各自写死;
|
||||
* 治理登记见 T06 config-register(OQ-MATCHMAKER-003)。
|
||||
*
|
||||
* <p><b>fail-safe 语义</b>:{@code enabled} 的 Java 字段缺省为 <b>false</b> ——
|
||||
* 若配置键完全缺失(如新环境漏配),机制整体关闭(fail-safe 关闭优于意外开放)。
|
||||
* 生产 {@code application.yml} 显式声明 {@code matchmaker.contact.enabled: true};
|
||||
* "默认开启" 指的是配置注册表中的生效默认值,而非代码缺省。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "matchmaker.contact")
|
||||
public class ContactAuthorizationConfig {
|
||||
|
||||
/**
|
||||
* 机制总开关。
|
||||
* Java 缺省 false(fail-safe:配置缺失 → 机制关闭);生产 yml 显式置 true。
|
||||
* disclose(H4 唯一披露出口)前强校验,关闭时任何披露请求 404 化拒绝。
|
||||
*/
|
||||
private boolean enabled = false;
|
||||
|
||||
/**
|
||||
* 审批岗权限码(逗号分隔,<b>分层语义</b>,用户裁决①:组长主审 + 管理员兜底)。
|
||||
* 默认 "matchmaker.team.assign,matchmaker.assign"(键名与默认值不变)。
|
||||
*
|
||||
* <p>分层规则(ContactAuthorizationService.assertApproverAuthority):
|
||||
* <ol>
|
||||
* <li>列表<b>首段 = 主审岗</b>(默认 {@code matchmaker.team.assign},组长)——
|
||||
* 仅可审批本组织红娘的申请(NULL=NULL 平台直属,assertTeamScope 复用);</li>
|
||||
* <li><b>兜底岗键固定 {@code matchmaker.assign}</b>(平台管理员)——
|
||||
* 可审批全部申请且不做组织校验;兜底键不受本列表调整影响(权限面不放大);</li>
|
||||
* <li>两者皆无 → 403 拒绝。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>审批权限在 Service 层动态判定(principal 权限 ∈ 分层规则),不用 @PreAuthorize
|
||||
* 静态注解 —— 主审岗名单可经 yml 调整而无需改代码。
|
||||
*/
|
||||
private String approverAuthorities = "matchmaker.team.assign,matchmaker.assign";
|
||||
|
||||
/**
|
||||
* 用户同意窗口(小时):pending_consent 超窗未同意 → 惰性 expired。
|
||||
* 默认 72h(治理登记 OQ-MATCHMAKER-003 技术默认,待产品确认)。
|
||||
*/
|
||||
private int consentDeadlineHours = 72;
|
||||
|
||||
/**
|
||||
* 授权默认有效期(天):用户 consent 起算,expires_at = consent + N 天。
|
||||
* 默认 7 天;到期由双态惰性收敛置 expired。
|
||||
*/
|
||||
private int defaultValidityDays = 7;
|
||||
|
||||
/**
|
||||
* 授权审计数据保留期(天):受控联系相关审计/敏感访问记录的保留下限口径。
|
||||
* 默认 365 天;当前以 /my 响应元数据形式下发(retentionDays 字段)供前端展示,
|
||||
* 治理登记 T06 open-questions 同步说明。
|
||||
*/
|
||||
private int retentionDays = 365;
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.danyang.match.platform.matchmaker.controller;
|
||||
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||
import com.danyang.match.platform.matchmaker.service.MatchmakerAssignmentService;
|
||||
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.*;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 服务对象分配接口(运营端,ZC-MM-002 / A1)。
|
||||
*
|
||||
* <p>权限模型(D2):
|
||||
* <ul>
|
||||
* <li>写操作(创建/转分配/撤销/结束/可分配列表):
|
||||
* {@code matchmaker.assign}(平台授权人员,不限组织)
|
||||
* 或 {@code matchmaker.team.assign}(红娘组长,同组织范围);</li>
|
||||
* <li>读自己名下分配(/my):{@code matchmaker.service}(普通红娘即可)。</li>
|
||||
* </ul>
|
||||
* operatorId 一律取当前认证主体(principal),不信任请求参数。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/operator/matchmaker/assignments")
|
||||
@RequiredArgsConstructor
|
||||
public class MatchmakerAssignmentController {
|
||||
|
||||
private final MatchmakerAssignmentService assignmentService;
|
||||
|
||||
/**
|
||||
* 创建分配。
|
||||
* Body: userId / matchmakerOperatorId / serviceId? / reason(必填)/ expiresAt?(ISO-8601)
|
||||
*/
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||
public ApiResponse<?> create(@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@RequestBody Map<String, String> body) {
|
||||
MatchmakerAssignment created = assignmentService.create(
|
||||
principal.id(),
|
||||
principal.hasPermission("matchmaker.assign"),
|
||||
body.get("userId"),
|
||||
body.get("matchmakerOperatorId"),
|
||||
body.getOrDefault("serviceId", null),
|
||||
body.get("reason"),
|
||||
parseInstant(body.get("expiresAt")));
|
||||
return ApiResponse.ok(assignmentView(created));
|
||||
}
|
||||
|
||||
/** 转分配:旧记录置 transferred,同事务新建 active。reason 必填。 */
|
||||
@PostMapping("/{id}/transfer")
|
||||
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||
public ApiResponse<?> transfer(@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
MatchmakerAssignment created = assignmentService.transfer(
|
||||
id,
|
||||
principal.id(),
|
||||
principal.hasPermission("matchmaker.assign"),
|
||||
body.get("newMatchmakerOperatorId"),
|
||||
body.get("reason"));
|
||||
return ApiResponse.ok(assignmentView(created));
|
||||
}
|
||||
|
||||
/** 撤销分配。reason 必填。 */
|
||||
@PostMapping("/{id}/revoke")
|
||||
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||
public ApiResponse<?> revoke(@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
return ApiResponse.ok(assignmentView(assignmentService.revoke(
|
||||
id, principal.id(), principal.hasPermission("matchmaker.assign"),
|
||||
body.get("reason"))));
|
||||
}
|
||||
|
||||
/** 结束分配。reason 必填。 */
|
||||
@PostMapping("/{id}/end")
|
||||
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||
public ApiResponse<?> end(@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
return ApiResponse.ok(assignmentView(assignmentService.end(
|
||||
id, principal.id(), principal.hasPermission("matchmaker.assign"),
|
||||
body.get("reason"))));
|
||||
}
|
||||
|
||||
/** 我名下生效中的服务对象分配(普通红娘工作台)。 */
|
||||
@GetMapping("/my")
|
||||
@PreAuthorize("hasAuthority('matchmaker.service')")
|
||||
public ApiResponse<List<Map<String, Object>>> my(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
List<Map<String, Object>> list = assignmentService.listMy(principal.id()).stream()
|
||||
.map(this::assignmentView)
|
||||
.toList();
|
||||
return ApiResponse.ok(list);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可分配红娘列表(创建/转分配弹窗数据源)。
|
||||
* 返回 id/name/organizationId,不含联系方式(字段级隐私,ZC-MM-005 范围)。
|
||||
*/
|
||||
@GetMapping("/assignable-matchmakers")
|
||||
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||
public ApiResponse<List<Map<String, Object>>> assignable(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
return ApiResponse.ok(assignmentService.listAssignableMatchmakers(
|
||||
principal.id(), principal.hasPermission("matchmaker.assign")));
|
||||
}
|
||||
|
||||
/**
|
||||
* 组长团队统计(ZC-MM-005 / E4 新增)。仅 matchmaker.team.assign 持有者可调;
|
||||
* 以组长本人 organization_id 为边界(NULL 组长统计平台直属)。
|
||||
* 返回纯脱敏计数:activeAssignmentCount / perMatchmakerCounts / activeMatchmakerCount,
|
||||
* 不含任何用户身份或联系方式。
|
||||
*/
|
||||
@GetMapping("/team/statistics")
|
||||
@PreAuthorize("hasAuthority('matchmaker.team.assign')")
|
||||
public ApiResponse<Map<String, Object>> teamStatistics(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
return ApiResponse.ok(assignmentService.teamStatistics(principal.id()));
|
||||
}
|
||||
|
||||
private Instant parseInstant(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return Instant.parse(raw);
|
||||
}
|
||||
|
||||
private Map<String, Object> assignmentView(MatchmakerAssignment a) {
|
||||
return Map.ofEntries(
|
||||
Map.entry("id", a.getId()),
|
||||
Map.entry("userId", a.getUserId()),
|
||||
Map.entry("matchmakerOperatorId", a.getMatchmakerOperatorId()),
|
||||
Map.entry("serviceId", a.getServiceId() == null ? "" : a.getServiceId()),
|
||||
Map.entry("assignmentScope", a.getAssignmentScope()),
|
||||
Map.entry("status", a.getStatus()),
|
||||
Map.entry("assignmentReason", a.getAssignmentReason()),
|
||||
Map.entry("assignedAt", a.getAssignedAt() == null ? "" : a.getAssignedAt().toString()),
|
||||
Map.entry("expiresAt", a.getExpiresAt() == null ? "" : a.getExpiresAt().toString()),
|
||||
Map.entry("endedAt", a.getEndedAt() == null ? "" : a.getEndedAt().toString()),
|
||||
Map.entry("endReason", a.getEndReason() == null ? "" : a.getEndReason())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.danyang.match.platform.matchmaker.controller;
|
||||
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||
import com.danyang.match.platform.matchmaker.service.ContactAuthorizationService;
|
||||
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.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 受控联系授权控制器(运营端,ZC-MM-003 / B1 立项)。
|
||||
*
|
||||
* <p>端点(基路径 {@code /api/v1/operator/matchmaker/contact-authorizations}):
|
||||
* <ul>
|
||||
* <li>POST {@code ""} —— 红娘发起授权申请(matchmaker.service,purpose 必填);</li>
|
||||
* <li>POST {@code /{id}/approve} / {@code /{id}/reject} —— 审批岗批准/拒绝
|
||||
* (审批岗权限码在 Service 层动态判定 ∈ approver-authorities,组长另有组织范围校验);</li>
|
||||
* <li>GET {@code /my} —— 红娘本人授权列表;</li>
|
||||
* <li>POST {@code /{id}/revoke} —— 申请人或审批人撤销(reason 必填);</li>
|
||||
* <li>GET {@code /disclose} —— H4 唯一披露出口(userId/purpose 必填,
|
||||
* 每次落 SensitiveAccessLog;phone 不落任何应用日志)。</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>类级 {@code matchmaker.view} 覆盖列表/详情读;写动作方法级收敛;
|
||||
* 审批岗动态权限与 G4 404 化拒绝、approve 403 边界均由
|
||||
* {@code ContactAuthorizationService} 承担(T06 open-questions OQ-MATCHMAKER-009)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/operator/matchmaker/contact-authorizations")
|
||||
@PreAuthorize("hasAuthority('matchmaker.view')")
|
||||
@RequiredArgsConstructor
|
||||
public class MatchmakerContactAuthorizationController {
|
||||
|
||||
private final ContactAuthorizationService contactAuthorizationService;
|
||||
|
||||
/** 红娘发起授权申请(须持分配;purpose 必填)。 */
|
||||
@PostMapping
|
||||
@PreAuthorize("hasAuthority('matchmaker.service')")
|
||||
public ApiResponse<MatchmakerContactAuthorization> request(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@RequestBody Map<String, String> body) {
|
||||
return ApiResponse.ok(contactAuthorizationService.request(
|
||||
principal.id(),
|
||||
body.get("userId"),
|
||||
body.get("channel"),
|
||||
body.get("purpose")));
|
||||
}
|
||||
|
||||
/** 审批岗批准(权限动态判定 + 组长组织范围校验)。 */
|
||||
@PostMapping("/{id}/approve")
|
||||
public ApiResponse<MatchmakerContactAuthorization> approve(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(contactAuthorizationService.approve(
|
||||
id, principal.id(), principal.permissions()));
|
||||
}
|
||||
|
||||
/** 审批岗拒绝。 */
|
||||
@PostMapping("/{id}/reject")
|
||||
public ApiResponse<MatchmakerContactAuthorization> reject(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(contactAuthorizationService.reject(
|
||||
id, principal.id(), principal.permissions()));
|
||||
}
|
||||
|
||||
/** 红娘本人授权列表(含保留期元数据)。 */
|
||||
@GetMapping("/my")
|
||||
public ApiResponse<Map<String, Object>> my(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
return ApiResponse.ok(contactAuthorizationService.myAuthorizations(principal.id()));
|
||||
}
|
||||
|
||||
/** 申请人或审批人撤销(reason 必填)。 */
|
||||
@PostMapping("/{id}/revoke")
|
||||
public ApiResponse<MatchmakerContactAuthorization> revoke(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
return ApiResponse.ok(contactAuthorizationService.revoke(
|
||||
id, principal.id(), body.get("reason")));
|
||||
}
|
||||
|
||||
/** H4 唯一披露出口:userId/purpose 必填,channel 缺省 phone。 */
|
||||
@GetMapping("/disclose")
|
||||
public ApiResponse<Map<String, Object>> disclose(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@RequestParam String userId,
|
||||
@RequestParam String purpose,
|
||||
@RequestParam(required = false) String channel) {
|
||||
return ApiResponse.ok(contactAuthorizationService.disclose(
|
||||
principal.id(), userId, channel, purpose));
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 红娘服务端点
|
||||
* 红娘服务端点(用户端)
|
||||
*
|
||||
* 用户端:
|
||||
* POST /api/v1/matchmaker/services — 提交红娘服务请求
|
||||
@@ -26,10 +26,15 @@ import java.util.Map;
|
||||
* POST /api/v1/matchmaker/recommendations/{id}/accept — 用户主动接受(Like target)
|
||||
* POST /api/v1/matchmaker/recommendations/{id}/pass
|
||||
*
|
||||
* 操作人端(Matchmaker/Operator role):
|
||||
* PUT /api/v1/matchmaker/services/{id}/assign
|
||||
* POST /api/v1/matchmaker/services/{id}/recommendation — 为用户推荐对象
|
||||
* POST /api/v1/matchmaker/services/{id}/referral — 为用户转介服务
|
||||
* 操作人端(Matchmaker/Operator)在 {@link MatchmakerOperatorController},基路径为
|
||||
* <b>{@code /api/v1/operator/matchmaker}</b>(如 {@code POST /services/{id}/assign}、
|
||||
* {@code POST /services/{id}/recommend}、{@code POST /services/{id}/followup}、
|
||||
* {@code POST /services/{id}/complete})。
|
||||
*
|
||||
* <p><b>2026-09-23 修正</b>:本注释此前把上述 operator 端点写成
|
||||
* {@code /matchmaker/services/{id}/assign|recommendation|referral},但本类从未注册过这些路由
|
||||
* ({@code /referral} 亦不复存在,转介能力由独立控制器提供)——文档与实现不符会误导调用方。
|
||||
* 路由已由 {@code MatchmakerRouteContractTest} 之类的机械守卫锁定,此处只如实描述。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/matchmaker")
|
||||
|
||||
@@ -13,6 +13,11 @@ import java.util.Map;
|
||||
|
||||
/**
|
||||
* 红娘工作台控制器(Operator调用)
|
||||
*
|
||||
* <p>ZC-MM-002(A1)状态机变更:原 {@code POST /services/{id}/claim}(红娘接单)端点已删除。
|
||||
* 服务对象归属统一由 matchmaker_assignments 建模(D3 决策:不保留组长认领),
|
||||
* 分配入口收敛为 {@code MatchmakerAssignmentController}(POST /operator/matchmaker/assignments),
|
||||
* 服务单状态由分配动作同事务联动(submitted → assigned)。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/operator/matchmaker")
|
||||
@@ -29,42 +34,57 @@ public class MatchmakerOperatorController {
|
||||
return ApiResponse.ok(matchmakerServiceService.getDashboard(operatorId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 待分配队列。ZC-MM-002(D4):方法级权限收敛 —— 仅分配权持有者
|
||||
* (matchmaker.assign 平台授权 / matchmaker.team.assign 组长)可看全局待分配队列,
|
||||
* 普通红娘(仅 matchmaker.view/service)403。
|
||||
*
|
||||
* <p>组长视角说明(ZC-MM-005 / E4,治理登记 007):matchmaker_services 表无组织归属字段
|
||||
* (organization_id 恒 null 的已知限制),因此待分配队列无法按组长组织过滤,
|
||||
* 权衡后对分配权持有者全局可读 —— 待分配服务单尚无负责人,队列不含敏感分配关系。
|
||||
* 组织归属字段的补齐登记至 ZC-MM-007。
|
||||
*/
|
||||
@GetMapping("/pending")
|
||||
@PreAuthorize("hasAuthority('matchmaker.assign') or hasAuthority('matchmaker.team.assign')")
|
||||
public ApiResponse<List<Map<String, Object>>> getPending(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
return ApiResponse.ok(matchmakerServiceService.getPendingServices());
|
||||
}
|
||||
|
||||
/**
|
||||
* 分配服务给红娘(兼容入口)。ZC-MM-002(D4):
|
||||
* 同事务联动「创建 matchmaker_assignments 分配 + 服务单置 assigned」。
|
||||
*/
|
||||
@PostMapping("/services/{id}/assign")
|
||||
@PreAuthorize("hasAuthority('matchmaker.assign')")
|
||||
public ApiResponse<?> assignService(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id,
|
||||
@RequestBody Map<String, String> body) {
|
||||
String operatorId = principal.id();
|
||||
String assignOperatorId = body.get("operatorId");
|
||||
return ApiResponse.ok(matchmakerServiceService.assignService(id, assignOperatorId, null));
|
||||
String assignerOperatorId = principal.id();
|
||||
String targetOperatorId = body.get("operatorId");
|
||||
String reason = body.getOrDefault("reason", "红娘服务单分配");
|
||||
return ApiResponse.ok(matchmakerServiceService.assignService(
|
||||
id, assignerOperatorId, targetOperatorId, reason));
|
||||
}
|
||||
|
||||
@PostMapping("/services/{id}/claim")
|
||||
@PreAuthorize("hasAuthority('matchmaker.service')")
|
||||
public ApiResponse<?> claimService(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
String operatorId = principal.id();
|
||||
return ApiResponse.ok(matchmakerServiceService.claimService(operatorId, id));
|
||||
}
|
||||
|
||||
@GetMapping("/my-services")
|
||||
public ApiResponse<?> getMyServices(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal) {
|
||||
String operatorId = principal.id();
|
||||
return ApiResponse.ok(matchmakerServiceService.getMyServices(operatorId));
|
||||
}
|
||||
// GET /my-services 已删除(ZC-MM-005 / E4 死端点清理):
|
||||
// 原实现把 operatorId 误当 user_id 传给 getMyServices(按 C 端 user_id 查询,恒空),
|
||||
// 且 admin-web / miniapp 零调用。运营端"我的服务对象"替代路径:
|
||||
// GET /operator/matchmaker/assignments/my(ZC-MM-002 A1 新增)。
|
||||
// getMyServices 服务方法保留,供 C 端 GET /matchmaker/services 使用。
|
||||
|
||||
/**
|
||||
* Phase 13:红娘名下服务的全部推荐列表(运营端工作台用)。
|
||||
* 红娘名下服务的全部推荐列表(运营端工作台用)。
|
||||
* 补此前 admin-web 调用 `/matchmaker/recommendations/operator` 404 的契约缺口。
|
||||
*
|
||||
* <p>口径核查(ZC-MM-005 收口 / P1-1):范围事实源是 {@code matchmaker_assignments}
|
||||
* 中本人<b>生效中的分配</b>(active 且未过有效期)→ 该分配对象(user)名下
|
||||
* 服务单上的推荐,经 matchmaker_services 桥接;既不按
|
||||
* matchmaker_services.assigned_operator_id 残留列过滤,也<b>不</b>按
|
||||
* recommendation.operator_id 过滤(推荐条目只含 targetUserId/reason/status
|
||||
* 摘要,不涉及服务对象字段级隐私)。分配 revoke/end 后不再可见
|
||||
* (DEC-002 规则7:撤销即失权)。
|
||||
*/
|
||||
@GetMapping("/recommendations")
|
||||
public ApiResponse<List<Map<String, Object>>> getRecommendations(
|
||||
@@ -72,16 +92,19 @@ public class MatchmakerOperatorController {
|
||||
return ApiResponse.ok(matchmakerServiceService.getRecommendationsByOperator(principal.id()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务详情(ZC-MM-005 / E2 防探测 404)。
|
||||
*
|
||||
* <p>语义变更:非持有人与查无 serviceId 抛出完全相同的
|
||||
* BusinessException(NOT_FOUND, "服务不存在") —— 响应体逐字段一致
|
||||
* (success=false / code=NOT_FOUND / message=服务不存在),无法区分"无权"与"不存在"。
|
||||
* 此前 002 的 viewerIsOwner 双态(持有人完整 / 非负责人脱敏)已删除。
|
||||
*/
|
||||
@GetMapping("/services/{id}")
|
||||
public ApiResponse<Map<String, Object>> getServiceDetail(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
// Phase 13 D4:传入调用者身份,Service 层按归属决定返回完整/脱敏详情
|
||||
Map<String, Object> detail = matchmakerServiceService.getServiceDetail(id, principal.id());
|
||||
if (detail == null) {
|
||||
return ApiResponse.error(com.danyang.match.platform.shared.enums.ApiResponseCode.NOT_FOUND);
|
||||
}
|
||||
return ApiResponse.ok(detail);
|
||||
return ApiResponse.ok(matchmakerServiceService.getServiceDetail(id, principal.id()));
|
||||
}
|
||||
|
||||
@PostMapping("/services/{id}/recommend")
|
||||
@@ -114,7 +137,8 @@ public class MatchmakerOperatorController {
|
||||
public ApiResponse<?> completeService(
|
||||
@AuthenticationPrincipal OperatorPrincipal principal,
|
||||
@PathVariable String id) {
|
||||
return ApiResponse.ok(matchmakerServiceService.completeService(id));
|
||||
// ZC-MM-002(D4):归属判定切换到 ServiceAssignmentPolicy(服务层完成)
|
||||
return ApiResponse.ok(matchmakerServiceService.completeService(id, principal.id()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.danyang.match.platform.matchmaker.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 服务对象分配(ZC-MM-002 / A1)
|
||||
* 对应 DB: matchmaker_assignments(V053)
|
||||
*
|
||||
* <p>建模说明:
|
||||
* <ul>
|
||||
* <li>{@code matchmakerOperatorId}:持有该用户服务对象的红娘(operators 主体,
|
||||
* 沿用 matchmaker_services.assigned_operator_id 惯例)。</li>
|
||||
* <li>{@code assignedBy}:做出本次分配的操作者(组长 / 平台授权人员)。</li>
|
||||
* <li>状态机(D5):active → transferred / revoked / ended;
|
||||
* expires_at 届满由 {@code ServiceAssignmentPolicy} 惰性置 expired(无定时任务);
|
||||
* 终态一律拒绝再转换。</li>
|
||||
* <li>scope:primary(主责,同用户仅一条生效中,DB 部分唯一索引兜底)/
|
||||
* collaborating(协同)。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("matchmaker_assignments")
|
||||
public class MatchmakerAssignment extends BaseEntity {
|
||||
|
||||
/** 被服务用户 */
|
||||
private String userId;
|
||||
|
||||
/** 持有服务对象的红娘(operators.id) */
|
||||
private String matchmakerOperatorId;
|
||||
|
||||
/** 关联的服务单(可空:允许脱离服务单的用户级分配) */
|
||||
private String serviceId;
|
||||
|
||||
/** 做出本次分配的操作者(operators.id) */
|
||||
private String assignedBy;
|
||||
|
||||
/** 分配范围:primary / collaborating(与 V053 chk_mm_assignment_scope 白名单一致) */
|
||||
private String assignmentScope;
|
||||
|
||||
/** 分配理由(必填) */
|
||||
private String assignmentReason;
|
||||
|
||||
/** 状态:active/ended/revoked/transferred/expired(与 V053 chk_mm_assignment_status 白名单一致) */
|
||||
private String status;
|
||||
|
||||
private Instant assignedAt;
|
||||
|
||||
/** 有效期届满时间;null 表示长期有效 */
|
||||
private Instant expiresAt;
|
||||
|
||||
/** 终态时间(ended/revoked/transferred/expired 时落库) */
|
||||
private Instant endedAt;
|
||||
|
||||
/** 终态理由(revoked/ended 必填;transferred 记录转分配理由;expired 为固定文案) */
|
||||
private String endReason;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.danyang.match.platform.matchmaker.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.danyang.match.platform.shared.base.BaseEntity;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* 受控联系授权(ZC-MM-003 / B1 例外通道,架构师设计 H4)。
|
||||
*
|
||||
* <p>状态机(六态):pending_approval → pending_consent → active / rejected;
|
||||
* pending_consent / active 双态惰性收敛为 expired(有效期或同意窗口届满);
|
||||
* active → revoked(申请人或审批人撤销)。V056 建表。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@TableName("matchmaker_contact_authorizations")
|
||||
public class MatchmakerContactAuthorization extends BaseEntity {
|
||||
|
||||
/** 发起申请的红娘 operator id */
|
||||
private String operatorId;
|
||||
/** 被联系用户 id */
|
||||
private String userId;
|
||||
/** 联系渠道:phone / offline_meeting / full_contact */
|
||||
private String channel;
|
||||
/** 授权目的(必填,用户同意页明示) */
|
||||
private String purpose;
|
||||
/** 状态:pending_approval / pending_consent / active / rejected / expired / revoked */
|
||||
private String status;
|
||||
/** 审批岗 operator id */
|
||||
private String approvedBy;
|
||||
private Instant approvedAt;
|
||||
/** 用户单独同意时间(独立端点,无预同意参数) */
|
||||
private Instant userConsentedAt;
|
||||
/** 生效截止:pending 期为同意窗口上界,consent 后重算为 consent + validity days */
|
||||
private Instant expiresAt;
|
||||
private Instant revokedAt;
|
||||
private String revokedBy;
|
||||
private String revokeReason;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.danyang.match.platform.matchmaker.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||
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.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 服务对象分配 Mapper(ZC-MM-002 / A1)
|
||||
*
|
||||
* <p>「生效中」的统一口径:status = 'active' 且(expires_at IS NULL OR expires_at > NOW())。
|
||||
* 惰性过期由 {@link #expireOverdue} 负责(读前触发,无定时任务),
|
||||
* 这里的 expires 条件是双保险:即使惰性过期尚未执行,已届满的行也不会被当作生效中。
|
||||
*/
|
||||
@Mapper
|
||||
public interface MatchmakerAssignmentMapper extends BaseMapper<MatchmakerAssignment> {
|
||||
|
||||
@Select("SELECT * FROM matchmaker_assignments WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<MatchmakerAssignment> findById(@Param("id") String id);
|
||||
|
||||
/** 某红娘名下全部生效中的分配(GET /my、GET /assignable 的工作台口径) */
|
||||
@Select("SELECT * FROM matchmaker_assignments WHERE matchmaker_operator_id = #{operatorId} " +
|
||||
"AND status = 'active' AND (expires_at IS NULL OR expires_at > NOW()) " +
|
||||
"AND deleted_at IS NULL ORDER BY assigned_at DESC")
|
||||
List<MatchmakerAssignment> findActiveByOperator(@Param("operatorId") String operatorId);
|
||||
|
||||
/** 某红娘对某用户是否持有生效中的分配(数据范围判定核心查询) */
|
||||
@Select("SELECT * FROM matchmaker_assignments WHERE matchmaker_operator_id = #{operatorId} " +
|
||||
"AND user_id = #{userId} AND status = 'active' " +
|
||||
"AND (expires_at IS NULL OR expires_at > NOW()) " +
|
||||
"AND deleted_at IS NULL ORDER BY assigned_at DESC LIMIT 1")
|
||||
Optional<MatchmakerAssignment> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
|
||||
@Param("userId") String userId);
|
||||
|
||||
/** 某用户当前生效中的主责分配(创建/转分配前的唯一性预检) */
|
||||
@Select("SELECT * FROM matchmaker_assignments WHERE user_id = #{userId} " +
|
||||
"AND status = 'active' AND assignment_scope = 'primary' " +
|
||||
"AND (expires_at IS NULL OR expires_at > NOW()) " +
|
||||
"AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<MatchmakerAssignment> findActivePrimaryByUser(@Param("userId") String userId);
|
||||
|
||||
/** 某服务单上生效中的分配(服务单维度的归属判定) */
|
||||
@Select("SELECT * FROM matchmaker_assignments WHERE service_id = #{serviceId} " +
|
||||
"AND status = 'active' AND (expires_at IS NULL OR expires_at > NOW()) " +
|
||||
"AND deleted_at IS NULL ORDER BY assigned_at DESC LIMIT 1")
|
||||
Optional<MatchmakerAssignment> findActiveByService(@Param("serviceId") String serviceId);
|
||||
|
||||
/** 某服务单的全部分配历史(追溯用) */
|
||||
@Select("SELECT * FROM matchmaker_assignments WHERE service_id = #{serviceId} " +
|
||||
"AND deleted_at IS NULL ORDER BY assigned_at DESC")
|
||||
List<MatchmakerAssignment> findByService(@Param("serviceId") String serviceId);
|
||||
|
||||
/**
|
||||
* 惰性过期:把 expires_at 已届满的 active 行置为 expired 并落库。
|
||||
* 由 {@code ServiceAssignmentPolicy} 在读取路径前触发,无定时任务。
|
||||
*
|
||||
* @return 本次被置为 expired 的行数
|
||||
*/
|
||||
@Update("UPDATE matchmaker_assignments SET status = 'expired', " +
|
||||
"ended_at = NOW(), end_reason = '有效期届满自动失效', updated_at = NOW() " +
|
||||
"WHERE status = 'active' AND expires_at IS NOT NULL AND expires_at < NOW() " +
|
||||
"AND deleted_at IS NULL")
|
||||
int expireOverdue();
|
||||
|
||||
// ==================== ZC-MM-005 / E4:组长团队统计(纯脱敏计数) ====================
|
||||
// 组织边界:organization_id IS NOT DISTINCT FROM #{organizationId} ——
|
||||
// NULL=NULL 平台直属规则在 SQL 层等价复用(有组织组长统计本组织;NULL 组长统计平台直属)。
|
||||
|
||||
/** 某组织范围内生效中的分配总数 */
|
||||
@Select("SELECT COUNT(*) FROM matchmaker_assignments ma " +
|
||||
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
|
||||
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
|
||||
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
|
||||
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}")
|
||||
long countActiveByOrganization(@Param("organizationId") String organizationId);
|
||||
|
||||
/** 某组织范围内各红娘的生效分配数(operatorId/name/activeCount;别名加引号保持驼峰键名) */
|
||||
@Select("SELECT o.id AS \"operatorId\", o.name AS \"operatorName\", COUNT(*) AS \"activeCount\" " +
|
||||
"FROM matchmaker_assignments ma " +
|
||||
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
|
||||
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
|
||||
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
|
||||
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId} " +
|
||||
"GROUP BY o.id, o.name ORDER BY \"activeCount\" DESC")
|
||||
List<Map<String, Object>> countActiveGroupByOperator(@Param("organizationId") String organizationId);
|
||||
|
||||
/** 某组织范围内持有生效分配的红娘数(去重) */
|
||||
@Select("SELECT COUNT(DISTINCT ma.matchmaker_operator_id) FROM matchmaker_assignments ma " +
|
||||
"JOIN operators o ON o.id = ma.matchmaker_operator_id " +
|
||||
"WHERE ma.status = 'active' AND (ma.expires_at IS NULL OR ma.expires_at > NOW()) " +
|
||||
"AND ma.deleted_at IS NULL AND o.deleted_at IS NULL " +
|
||||
"AND o.organization_id IS NOT DISTINCT FROM #{organizationId}")
|
||||
long countActiveOperators(@Param("organizationId") String organizationId);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.danyang.match.platform.matchmaker.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 受控联系授权 Mapper(ZC-MM-003 / B1 立项)。
|
||||
*
|
||||
* <p>双态惰性收敛(对照 005 expireOverdue 范式,读前触发、无定时任务):
|
||||
* ① active 且 expires_at 届满 → expired(授权有效期到);
|
||||
* ② pending_consent 且同意窗口(expires_at 在 request/approve 期承载窗口上界)届满 → expired。
|
||||
*/
|
||||
@Mapper
|
||||
public interface MatchmakerContactAuthorizationMapper extends BaseMapper<MatchmakerContactAuthorization> {
|
||||
|
||||
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE id = #{id} AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<MatchmakerContactAuthorization> findById(@Param("id") String id);
|
||||
|
||||
/** 某红娘的全部授权记录(工作台列表,倒序) */
|
||||
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE operator_id = #{operatorId} " +
|
||||
"AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<MatchmakerContactAuthorization> findByOperator(@Param("operatorId") String operatorId);
|
||||
|
||||
/** 用户待同意队列(pending_consent,C 端同意页数据源) */
|
||||
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE user_id = #{userId} " +
|
||||
"AND status = 'pending_consent' AND expires_at > NOW() AND deleted_at IS NULL " +
|
||||
"ORDER BY created_at DESC")
|
||||
List<MatchmakerContactAuthorization> findPendingConsentByUser(@Param("userId") String userId);
|
||||
|
||||
/** 某红娘对某用户某渠道当前生效中的授权 */
|
||||
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE operator_id = #{operatorId} " +
|
||||
"AND user_id = #{userId} AND channel = #{channel} AND status = 'active' " +
|
||||
"AND expires_at > NOW() AND deleted_at IS NULL LIMIT 1")
|
||||
Optional<MatchmakerContactAuthorization> findActiveByOperatorUserChannel(@Param("operatorId") String operatorId,
|
||||
@Param("userId") String userId,
|
||||
@Param("channel") String channel);
|
||||
|
||||
/** 某用户的全部授权记录(C 端我的授权列表) */
|
||||
@Select("SELECT * FROM matchmaker_contact_authorizations WHERE user_id = #{userId} " +
|
||||
"AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<MatchmakerContactAuthorization> findByUser(@Param("userId") String userId);
|
||||
|
||||
/**
|
||||
* 双态惰性收敛:
|
||||
* ① active 过期 → expired;
|
||||
* ② pending_consent 超同意窗口 → expired(expires_at 即窗口上界)。
|
||||
*
|
||||
* @return 本次被收敛的行数
|
||||
*/
|
||||
@Update("UPDATE matchmaker_contact_authorizations SET status = 'expired', updated_at = NOW() " +
|
||||
"WHERE status IN ('active', 'pending_consent') AND expires_at < NOW() AND deleted_at IS NULL")
|
||||
int expireOverdue();
|
||||
}
|
||||
@@ -30,13 +30,19 @@ public interface MatchmakerRecommendationMapper extends BaseMapper<MatchmakerRec
|
||||
int markViewed(@Param("id") String id);
|
||||
|
||||
/**
|
||||
* Phase 13:某红娘名下服务的全部推荐(运营端列表用)。
|
||||
* 通过 service_id 关联 matchmaker_services.assigned_operator_id 限定为本人名下,
|
||||
* 避免运营端越权看到他人推荐。
|
||||
* 某红娘推荐列表(运营端工作台用)。
|
||||
*
|
||||
* <p>ZC-MM-005 收口(P1-1):范围事实源由 matchmaker_services.assigned_operator_id
|
||||
* 残留列切换为 matchmaker_assignments 生效中分配(active 且未过有效期)。
|
||||
* 语义 = 「本人当前有效分配对象(user)名下服务单上的推荐」—— 分配 revoke/end 后
|
||||
* 残留列不再构成可见凭据(DEC-002 规则7:撤销即失权)。
|
||||
*/
|
||||
@Select("SELECT r.* FROM matchmaker_recommendations r " +
|
||||
"JOIN matchmaker_services s ON s.id = r.service_id " +
|
||||
"WHERE s.assigned_operator_id = #{operatorId} AND s.deleted_at IS NULL " +
|
||||
"JOIN matchmaker_services s ON s.id = r.service_id AND s.deleted_at IS NULL " +
|
||||
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
|
||||
"AND a.matchmaker_operator_id = #{operatorId} " +
|
||||
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
|
||||
"AND a.deleted_at IS NULL " +
|
||||
"ORDER BY r.created_at DESC")
|
||||
List<MatchmakerRecommendation> findByOperatorId(@Param("operatorId") String operatorId);
|
||||
}
|
||||
|
||||
@@ -26,29 +26,55 @@ public interface MatchmakerServiceMapper extends BaseMapper<MatchmakerServiceRec
|
||||
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE status = 'submitted' AND deleted_at IS NULL")
|
||||
long countSubmitted();
|
||||
|
||||
/** 某红娘进行中的服务数(工作台统计) */
|
||||
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
|
||||
"AND status = 'processing' AND deleted_at IS NULL")
|
||||
/**
|
||||
* 某红娘进行中的服务数(工作台统计)。
|
||||
* ZC-MM-005 收口(P1-2):范围事实源由 assigned_operator_id 残留列切换为
|
||||
* matchmaker_assignments 生效中分配(active 且未过有效期)——「本人名下进行中」
|
||||
* = 本人 active 分配关联 user 的服务单中 status='in_service'。
|
||||
* 分配 end/revoke 后,该用户的服务单不再计入本人(DEC-002 规则7:服务结束失去访问权)。
|
||||
* 状态口径沿用 ZC-MM-002(D4)修正:'in_service'('processing' 不在 V009 白名单)。
|
||||
*/
|
||||
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
|
||||
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
|
||||
"AND a.matchmaker_operator_id = #{operatorId} " +
|
||||
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
|
||||
"AND a.deleted_at IS NULL " +
|
||||
"WHERE s.status = 'in_service' AND s.deleted_at IS NULL")
|
||||
long countInProgressByOperator(@Param("operatorId") String operatorId);
|
||||
|
||||
/** 某红娘今天名下新流转的服务数(工作台「今日」本人口径,ZC-MM-005 收口 / P1-2:按生效分配 join) */
|
||||
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
|
||||
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
|
||||
"AND a.matchmaker_operator_id = #{operatorId} " +
|
||||
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
|
||||
"AND a.deleted_at IS NULL " +
|
||||
"WHERE CAST(s.assigned_at AS DATE) = CURRENT_DATE AND s.deleted_at IS NULL")
|
||||
long countTodayByOperator(@Param("operatorId") String operatorId);
|
||||
|
||||
/**
|
||||
* 某红娘名下已完成的服务数(工作台「已完成」本人口径,ZC-MM-005 收口 / P1-2)。
|
||||
* 口径说明(与 E1「服务结束失去访问权」一致):按当前生效分配统计 ——
|
||||
* 分配 end/revoke 后,该用户的历史 completed 服务单<b>不再计入</b>本人完成数;
|
||||
* 仅当分配仍生效时,其名下 completed 单才计入。
|
||||
*/
|
||||
@Select("SELECT COUNT(DISTINCT s.id) FROM matchmaker_services s " +
|
||||
"JOIN matchmaker_assignments a ON a.user_id = s.user_id " +
|
||||
"AND a.matchmaker_operator_id = #{operatorId} " +
|
||||
"AND a.status = 'active' AND (a.expires_at IS NULL OR a.expires_at > NOW()) " +
|
||||
"AND a.deleted_at IS NULL " +
|
||||
"WHERE s.status = 'completed' AND s.deleted_at IS NULL")
|
||||
long countCompletedByOperator(@Param("operatorId") String operatorId);
|
||||
|
||||
@Select("SELECT * FROM matchmaker_services WHERE user_id = #{userId} ORDER BY created_at DESC LIMIT #{limit}")
|
||||
List<MatchmakerServiceRecord> findByUser(@Param("userId") String userId, @Param("limit") int limit);
|
||||
|
||||
/** 某红娘名下全部服务(含历史)—— 红娘工作台 */
|
||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
|
||||
"AND deleted_at IS NULL ORDER BY created_at DESC")
|
||||
List<MatchmakerServiceRecord> findByOperatorId(@Param("operatorId") String operatorId);
|
||||
|
||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} AND status IN ('submitted','assigned','processing') ORDER BY created_at DESC")
|
||||
List<MatchmakerServiceRecord> findAssignedTo(@Param("operatorId") String operatorId);
|
||||
|
||||
/**
|
||||
* Phase 13:查该红娘名下、且服务对象为该用户的进行中服务(转介授权校验用)。
|
||||
* 转介必须挂在真实存在的服务关系上,否则等于任意红娘可代任意用户操作。
|
||||
* 注销前检查(P16 收尾 2e):该用户仍处于非终态的红娘服务数。
|
||||
* 状态口径(submitted/assigned/in_service/waiting_user)。
|
||||
* ⚠️ 勿用 {@code Constants.MATCHMAKER_STATUS_PENDING} / {@code IN_PROGRESS} ——
|
||||
* 这两个常量不在生产 chk_matchmaker_status 白名单(V009),该表永不出现这两个值。
|
||||
*/
|
||||
@Select("SELECT * FROM matchmaker_services WHERE assigned_operator_id = #{operatorId} " +
|
||||
"AND user_id = #{userId} AND status IN ('assigned','in_service','waiting_user','submitted') " +
|
||||
"AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1")
|
||||
Optional<MatchmakerServiceRecord> findActiveByOperatorAndUser(@Param("operatorId") String operatorId,
|
||||
@Param("userId") String userId);
|
||||
@Select("SELECT COUNT(*) FROM matchmaker_services WHERE user_id = #{userId} " +
|
||||
"AND status IN ('submitted','assigned','in_service','waiting_user') AND deleted_at IS NULL")
|
||||
long countActiveByUserId(@Param("userId") String userId);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.danyang.match.platform.matchmaker.service;
|
||||
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 受控联系授权判定器(ZC-MM-003 / B1 立项,架构师设计 G4)。
|
||||
*
|
||||
* <p>红娘对某用户某渠道能否披露联系方式,唯一事实源是
|
||||
* {@code matchmaker_contact_authorizations} 中「生效中的授权」
|
||||
* (status=active 且未过期,读前双态惰性收敛)。
|
||||
*
|
||||
* <p>判定无效时走 {@link DataScopeGuard#denyAsNotFound}:先落 DATA_SCOPE_DENIED 审计,
|
||||
* 再抛 404「服务不存在」(G4 防探测范式,与分配判定器同款)。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ContactAuthorizationPolicy {
|
||||
|
||||
private final MatchmakerContactAuthorizationMapper authorizationMapper;
|
||||
private final DataScopeGuard dataScopeGuard;
|
||||
|
||||
/**
|
||||
* 断言生效中的授权;无效时先审计后 404。
|
||||
*
|
||||
* @return 生效中的授权记录(永不返回 null)
|
||||
*/
|
||||
public MatchmakerContactAuthorization assertContactAllowed(String operatorId, String userId,
|
||||
String channel, String purpose) {
|
||||
Optional<MatchmakerContactAuthorization> found = canContact(operatorId, userId, channel);
|
||||
if (found.isPresent()) {
|
||||
return found.get();
|
||||
}
|
||||
dataScopeGuard.denyAsNotFound(operatorId, "matchmaker_contact_authorization", userId,
|
||||
"服务不存在", null, null);
|
||||
return null; // denyAsNotFound 必然抛出,此行不可达
|
||||
}
|
||||
|
||||
/**
|
||||
* 徽标查询(不审计):双态惰性收敛后查生效授权。
|
||||
* 供 UI「可联系」徽标等高频读场景,避免每徽标一次拒绝审计。
|
||||
*/
|
||||
public Optional<MatchmakerContactAuthorization> canContact(String operatorId, String userId,
|
||||
String channel) {
|
||||
authorizationMapper.expireOverdue();
|
||||
return authorizationMapper.findActiveByOperatorUserChannel(operatorId, userId,
|
||||
channel == null ? Constants.CONTACT_CHANNEL_PHONE : channel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
package com.danyang.match.platform.matchmaker.service;
|
||||
|
||||
import com.danyang.match.platform.audit.entity.AuditLog;
|
||||
import com.danyang.match.platform.audit.entity.SensitiveAccessLog;
|
||||
import com.danyang.match.platform.audit.mapper.AuditLogMapper;
|
||||
import com.danyang.match.platform.audit.mapper.SensitiveAccessLogMapper;
|
||||
import com.danyang.match.platform.matchmaker.config.ContactAuthorizationConfig;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerAssignment;
|
||||
import com.danyang.match.platform.matchmaker.entity.MatchmakerContactAuthorization;
|
||||
import com.danyang.match.platform.matchmaker.mapper.MatchmakerContactAuthorizationMapper;
|
||||
import com.danyang.match.platform.shared.constant.Constants;
|
||||
import com.danyang.match.platform.shared.enums.ApiResponseCode;
|
||||
import com.danyang.match.platform.shared.exception.GlobalExceptionHandler.BusinessException;
|
||||
import com.danyang.match.platform.shared.security.DataScopeGuard;
|
||||
import com.danyang.match.platform.user.entity.User;
|
||||
import com.danyang.match.platform.user.mapper.UserMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.slf4j.MDC;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 受控联系授权服务(ZC-MM-003 / B1 立项,架构师设计 H4)。
|
||||
*
|
||||
* <p>六操作状态机(全矩阵):
|
||||
* <pre>
|
||||
* request : 红娘(须持分配 + 机制开启)→ pending_approval,审计含 service_assignment_id
|
||||
* approve : 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底,见
|
||||
* {@link #assertApproverAuthority})pending_approval → pending_consent;
|
||||
* 违规 403(无资源枚举面维持 403)
|
||||
* reject : 审批岗(分层同 approve)拒绝 pending_approval → rejected
|
||||
* consent : C 端用户本人(归属校验,独立端点、无预同意参数)
|
||||
* pending_consent → active(expires_at = consent + default-validity-days)
|
||||
* decline : C 端用户本人 pending_consent → rejected
|
||||
* revoke : 申请人或审批人,reason 必填 → revoked
|
||||
* disclose : H4 唯一披露出口 —— enabled(fail-safe) → 持分配 → 生效授权;
|
||||
* 返回 {phone, disclosedAt, authorizationId};每次落 SensitiveAccessLog;
|
||||
* phone 不落任何应用日志
|
||||
* </pre>
|
||||
*
|
||||
* <p>拒绝口径(G4 防探测):disclose 及授权有效性判定一律 404 化 deny
|
||||
* (先 DATA_SCOPE_DENIED 审计再 404「服务不存在」);approve/管理类违规维持 403。
|
||||
* 状态机非法转换一律 CONFLICT。
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ContactAuthorizationService {
|
||||
|
||||
private static final String RESOURCE_TYPE = "matchmaker_contact_authorization";
|
||||
/** 披露敏感访问的数据类型(SensitiveAccessLog.data_type) */
|
||||
public static final String SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE = "user_phone_disclosure";
|
||||
/**
|
||||
* 兜底审批岗权限键(用户裁决① 固定):持有者可审批全部申请且不做组织校验。
|
||||
* 主审岗权限键仍由 approver-authorities 列表首段配置(默认 matchmaker.team.assign)。
|
||||
*/
|
||||
private static final String FALLBACK_APPROVER_AUTHORITY = "matchmaker.assign";
|
||||
|
||||
private final MatchmakerContactAuthorizationMapper authorizationMapper;
|
||||
private final ServiceAssignmentPolicy serviceAssignmentPolicy;
|
||||
private final MatchmakerAssignmentService matchmakerAssignmentService;
|
||||
private final ContactAuthorizationPolicy contactAuthorizationPolicy;
|
||||
private final ContactAuthorizationConfig config;
|
||||
private final DataScopeGuard dataScopeGuard;
|
||||
private final AuditLogMapper auditLogMapper;
|
||||
private final SensitiveAccessLogMapper sensitiveAccessLogMapper;
|
||||
private final UserMapper userMapper;
|
||||
|
||||
// ==================== ① request:红娘发起申请 ====================
|
||||
|
||||
@Transactional
|
||||
public MatchmakerContactAuthorization request(String operatorId, String userId,
|
||||
String channel, String purpose) {
|
||||
// fail-safe 最先(与 disclose 同口径):机制关闭(配置缺失/显式关闭)→ 404 化拒绝。
|
||||
// ZC-MM-003 裁决落地 T-C:request 原实现漏查 enabled(真实缺口,本轮补齐)。
|
||||
if (!config.isEnabled()) {
|
||||
dataScopeGuard.denyAsNotFound(operatorId, RESOURCE_TYPE, userId,
|
||||
"服务不存在", null, null);
|
||||
}
|
||||
if (purpose == null || purpose.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "授权目的必填");
|
||||
}
|
||||
requireValidChannel(channel);
|
||||
|
||||
// 未持分配申请 → 403 + DATA_SCOPE_DENIED 审计(申请凭据是分配而非授权本身)
|
||||
MatchmakerAssignment assignment = serviceAssignmentPolicy.assertActiveAssignment(
|
||||
operatorId, userId, RESOURCE_TYPE);
|
||||
|
||||
// 同 (operator,user,channel) 已有生效授权或待处理申请 → CONFLICT(防重复申请洪泛)
|
||||
if (authorizationMapper.findActiveByOperatorUserChannel(operatorId, userId, channel).isPresent()) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "该渠道已存在生效中的授权");
|
||||
}
|
||||
boolean hasPending = authorizationMapper.findByOperator(operatorId).stream()
|
||||
.anyMatch(a -> a.getUserId().equals(userId) && a.getChannel().equals(channel)
|
||||
&& (Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(a.getStatus())
|
||||
|| Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(a.getStatus())));
|
||||
if (hasPending) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT, "该渠道已存在待处理的授权申请");
|
||||
}
|
||||
|
||||
MatchmakerContactAuthorization auth = new MatchmakerContactAuthorization();
|
||||
auth.setOperatorId(operatorId);
|
||||
auth.setUserId(userId);
|
||||
auth.setChannel(channel);
|
||||
auth.setPurpose(purpose.trim());
|
||||
auth.setStatus(Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL);
|
||||
// pending 期 expires_at 承载同意窗口上界(consent 后重算为有效期)
|
||||
auth.setExpiresAt(Instant.now().plusSeconds(config.getConsentDeadlineHours() * 3600L));
|
||||
authorizationMapper.insert(auth);
|
||||
|
||||
log.info("受控联系授权申请: authId={}, operatorId={}, userId={}, channel={}",
|
||||
auth.getId(), operatorId, userId, channel);
|
||||
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REQUEST, auth.getId(),
|
||||
assignment != null ? assignment.getId() : null,
|
||||
"matchmaker.contact.request", "channel=" + channel + ";purpose=" + auth.getPurpose());
|
||||
return auth;
|
||||
}
|
||||
|
||||
// ==================== ② approve / ③ reject:审批岗(分层) ====================
|
||||
|
||||
/**
|
||||
* 审批岗分层判定(用户裁决①:组长主审 + 管理员兜底)。
|
||||
*
|
||||
* <p>分层规则:
|
||||
* <ol>
|
||||
* <li>持 {@code matchmaker.assign}(兜底岗,平台管理员)→ 可审批全部申请,
|
||||
* <b>不做组织校验</b>(见 {@link #assertOrgScopeForApprover});</li>
|
||||
* <li>否则持主审岗权限键(approver-authorities 列表首段,默认
|
||||
* {@code matchmaker.team.assign},组长)→ 主审,须通过
|
||||
* {@code assertTeamScope} 组织范围校验(NULL=NULL 平台直属,现状维持);</li>
|
||||
* <li>两者皆无 → 403 deny(不变)。</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>主审岗键仍由配置驱动(列表首段),兜底岗键固定为 {@code matchmaker.assign}
|
||||
* —— 配置只能收紧/调整主审岗,不能扩大兜底岗权限面。
|
||||
*/
|
||||
private void assertApproverAuthority(String approverOperatorId, String authId,
|
||||
Set<String> principalPermissions) {
|
||||
boolean authorized = isFallbackApprover(principalPermissions)
|
||||
|| isPrimaryApprover(principalPermissions);
|
||||
if (!authorized) {
|
||||
// 非审批岗 → 403(approve 无资源枚举面,维持 403;边界登记 T06 OQ-MATCHMAKER-009)
|
||||
dataScopeGuard.deny(approverOperatorId, RESOURCE_TYPE, authId,
|
||||
"非审批岗,禁止审批受控联系授权");
|
||||
}
|
||||
}
|
||||
|
||||
/** 兜底岗判定:持固定键 matchmaker.assign(平台管理员)→ 全量审批、无组织校验。 */
|
||||
private boolean isFallbackApprover(Set<String> principalPermissions) {
|
||||
return principalPermissions != null && principalPermissions.contains(FALLBACK_APPROVER_AUTHORITY);
|
||||
}
|
||||
|
||||
/** 主审岗判定:principal 权限 ∈ approver-authorities 任一(默认首段 team.assign 组长)。 */
|
||||
private boolean isPrimaryApprover(Set<String> principalPermissions) {
|
||||
Set<String> approverAuthorities = Arrays.stream(config.getApproverAuthorities().split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toSet());
|
||||
return principalPermissions != null
|
||||
&& principalPermissions.stream().anyMatch(approverAuthorities::contains);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分层组织范围校验(用户裁决①):兜底岗(持 matchmaker.assign)跳过组织校验;
|
||||
* 主审岗(组长)固定走 {@code assertTeamScope}(hasGlobalAssign=false ——
|
||||
* 是否兜底由本层判定,不再向底层传组织豁免,防止权限面双通道扩大)。
|
||||
*/
|
||||
private void assertOrgScopeForApprover(String approverOperatorId, String authId,
|
||||
Set<String> principalPermissions, String targetOperatorId) {
|
||||
if (isFallbackApprover(principalPermissions)) {
|
||||
return;
|
||||
}
|
||||
matchmakerAssignmentService.assertTeamScope(approverOperatorId, false,
|
||||
targetOperatorId, authId);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MatchmakerContactAuthorization approve(String authId, String approverOperatorId,
|
||||
Set<String> principalPermissions) {
|
||||
assertApproverAuthority(approverOperatorId, authId, principalPermissions);
|
||||
MatchmakerContactAuthorization auth = requireAuthorization(authId);
|
||||
if (!Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||
"当前状态(" + auth.getStatus() + ")不允许审批");
|
||||
}
|
||||
// 分层组织范围校验(用户裁决①):管理员兜底全量 / 组长主审限同组织(NULL=NULL 平台直属)
|
||||
assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
|
||||
auth.getOperatorId());
|
||||
|
||||
auth.setApprovedBy(approverOperatorId);
|
||||
auth.setApprovedAt(Instant.now());
|
||||
auth.setStatus(Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT);
|
||||
// 刷新同意窗口上界(给用户完整窗口)
|
||||
auth.setExpiresAt(Instant.now().plusSeconds(config.getConsentDeadlineHours() * 3600L));
|
||||
authorizationMapper.updateById(auth);
|
||||
|
||||
log.info("受控联系授权审批通过: authId={}, approver={}", authId, approverOperatorId);
|
||||
saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId,
|
||||
activeAssignmentIdOf(auth), "matchmaker.contact.approve",
|
||||
"result=approved;channel=" + auth.getChannel());
|
||||
return auth;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MatchmakerContactAuthorization reject(String authId, String approverOperatorId,
|
||||
Set<String> principalPermissions) {
|
||||
assertApproverAuthority(approverOperatorId, authId, principalPermissions);
|
||||
MatchmakerContactAuthorization auth = requireAuthorization(authId);
|
||||
if (!Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||
"当前状态(" + auth.getStatus() + ")不允许拒绝");
|
||||
}
|
||||
// 分层组织范围校验(用户裁决①,与 approve 同口径)
|
||||
assertOrgScopeForApprover(approverOperatorId, authId, principalPermissions,
|
||||
auth.getOperatorId());
|
||||
|
||||
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED);
|
||||
authorizationMapper.updateById(auth);
|
||||
|
||||
log.info("受控联系授权审批拒绝: authId={}, approver={}", authId, approverOperatorId);
|
||||
saveAudit(approverOperatorId, Constants.CONTACT_AUDIT_ACTION_APPROVE, authId,
|
||||
activeAssignmentIdOf(auth), "matchmaker.contact.approve",
|
||||
"result=rejected;channel=" + auth.getChannel());
|
||||
return auth;
|
||||
}
|
||||
|
||||
// ==================== ④ consent / ⑤ decline:C 端用户本人 ====================
|
||||
|
||||
@Transactional
|
||||
public MatchmakerContactAuthorization consent(String authId, String userId) {
|
||||
MatchmakerContactAuthorization auth = requireOwnedAuthorization(authId, userId);
|
||||
if (!Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||
"当前状态(" + auth.getStatus() + ")不允许同意");
|
||||
}
|
||||
auth.setUserConsentedAt(Instant.now());
|
||||
auth.setExpiresAt(Instant.now().plusSeconds(config.getDefaultValidityDays() * 86400L));
|
||||
auth.setStatus(Constants.CONTACT_AUTH_STATUS_ACTIVE);
|
||||
authorizationMapper.updateById(auth);
|
||||
|
||||
log.info("受控联系授权用户同意: authId={}, userId={}", authId, userId);
|
||||
saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_CONSENT, authId,
|
||||
activeAssignmentIdOf(auth), "matchmaker.contact.consent",
|
||||
"channel=" + auth.getChannel());
|
||||
return auth;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public MatchmakerContactAuthorization decline(String authId, String userId) {
|
||||
MatchmakerContactAuthorization auth = requireOwnedAuthorization(authId, userId);
|
||||
if (!Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||
"当前状态(" + auth.getStatus() + ")不允许拒绝");
|
||||
}
|
||||
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REJECTED);
|
||||
authorizationMapper.updateById(auth);
|
||||
|
||||
log.info("受控联系授权用户拒绝: authId={}, userId={}", authId, userId);
|
||||
saveAudit(userId, Constants.CONTACT_AUDIT_ACTION_DECLINE, authId,
|
||||
activeAssignmentIdOf(auth), "matchmaker.contact.decline",
|
||||
"channel=" + auth.getChannel());
|
||||
return auth;
|
||||
}
|
||||
|
||||
// ==================== ⑥ revoke:申请人或审批人 ====================
|
||||
|
||||
@Transactional
|
||||
public MatchmakerContactAuthorization revoke(String authId, String operatorId, String reason) {
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "撤销理由必填");
|
||||
}
|
||||
MatchmakerContactAuthorization auth = requireAuthorization(authId);
|
||||
boolean isApplicant = operatorId.equals(auth.getOperatorId());
|
||||
boolean isApprover = operatorId.equals(auth.getApprovedBy());
|
||||
if (!isApplicant && !isApprover) {
|
||||
dataScopeGuard.deny(operatorId, RESOURCE_TYPE, authId,
|
||||
"仅申请人或审批人可撤销授权");
|
||||
}
|
||||
if (!Constants.CONTACT_AUTH_STATUS_ACTIVE.equals(auth.getStatus())
|
||||
&& !Constants.CONTACT_AUTH_STATUS_PENDING_APPROVAL.equals(auth.getStatus())
|
||||
&& !Constants.CONTACT_AUTH_STATUS_PENDING_CONSENT.equals(auth.getStatus())) {
|
||||
throw new BusinessException(ApiResponseCode.CONFLICT,
|
||||
"当前状态(" + auth.getStatus() + ")不允许撤销");
|
||||
}
|
||||
auth.setStatus(Constants.CONTACT_AUTH_STATUS_REVOKED);
|
||||
auth.setRevokedAt(Instant.now());
|
||||
auth.setRevokedBy(operatorId);
|
||||
auth.setRevokeReason(reason.trim());
|
||||
authorizationMapper.updateById(auth);
|
||||
|
||||
log.info("受控联系授权撤销: authId={}, by={}", authId, operatorId);
|
||||
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_REVOKE, authId,
|
||||
activeAssignmentIdOf(auth), "matchmaker.contact.revoke",
|
||||
"reason=" + auth.getRevokeReason());
|
||||
return auth;
|
||||
}
|
||||
|
||||
// ==================== ⑦ disclose:H4 唯一披露出口 ====================
|
||||
|
||||
/**
|
||||
* 披露用户电话(H4 唯一出口)。
|
||||
* 链路:enabled fail-safe → 持分配(404 化)→ 生效授权(404 化)→ 返回三键;
|
||||
* 每次落 SensitiveAccessLog(user_phone_disclosure);<b>phone 不落任何应用日志</b>。
|
||||
*/
|
||||
@Transactional
|
||||
public Map<String, Object> disclose(String operatorId, String userId, String channel,
|
||||
String purpose) {
|
||||
// fail-safe:机制关闭(配置缺失/显式关闭)→ 404 化拒绝
|
||||
if (!config.isEnabled()) {
|
||||
dataScopeGuard.denyAsNotFound(operatorId, RESOURCE_TYPE, userId,
|
||||
"服务不存在", null, null);
|
||||
}
|
||||
if (purpose == null || purpose.isBlank()) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "披露目的必填");
|
||||
}
|
||||
// 持分配校验(404 化,G4)
|
||||
MatchmakerAssignment assignment = serviceAssignmentPolicy.assertActiveAssignmentOrNotFound(
|
||||
operatorId, userId, RESOURCE_TYPE, userId);
|
||||
// 生效授权校验(404 化;未同意/已拒绝/已过期/已撤销全部落同一响应)
|
||||
MatchmakerContactAuthorization auth = contactAuthorizationPolicy.assertContactAllowed(
|
||||
operatorId, userId, channel, purpose);
|
||||
|
||||
User user = userMapper.selectById(userId);
|
||||
String phone = user != null ? user.getPhone() : null;
|
||||
|
||||
SensitiveAccessLog accessLog = new SensitiveAccessLog();
|
||||
accessLog.setOperatorId(operatorId);
|
||||
accessLog.setUserId(userId);
|
||||
accessLog.setDataType(SENSITIVE_DATA_TYPE_PHONE_DISCLOSURE);
|
||||
accessLog.setReason(purpose.trim());
|
||||
accessLog.setAccessTarget(auth.getId());
|
||||
accessLog.setAccessResult(Constants.SENSITIVE_ACCESS_RESULT_SUCCESS);
|
||||
sensitiveAccessLogMapper.insert(accessLog);
|
||||
|
||||
// 审计不含 phone(afterData 只记 channel;log.info 不打 phone)
|
||||
log.info("受控联系披露: authId={}, operatorId={}, userId={}, channel={}",
|
||||
auth.getId(), operatorId, userId, auth.getChannel());
|
||||
saveAudit(operatorId, Constants.CONTACT_AUDIT_ACTION_DISCLOSE, auth.getId(),
|
||||
assignment != null ? assignment.getId() : null,
|
||||
"matchmaker.contact.disclose", "channel=" + auth.getChannel());
|
||||
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("phone", phone);
|
||||
result.put("disclosedAt", Instant.now());
|
||||
result.put("authorizationId", auth.getId());
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 查询 ====================
|
||||
|
||||
/** 红娘工作台:本人全部授权(含保留期元数据 retentionDays)。 */
|
||||
public Map<String, Object> myAuthorizations(String operatorId) {
|
||||
List<Map<String, Object>> items = authorizationMapper.findByOperator(operatorId).stream()
|
||||
.map(this::toView).collect(Collectors.toList());
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("list", items);
|
||||
result.put("retentionDays", config.getRetentionDays());
|
||||
return result;
|
||||
}
|
||||
|
||||
/** C 端:我的授权列表 + 待同意队列。 */
|
||||
public Map<String, Object> myAuthorizationsForUser(String userId) {
|
||||
List<Map<String, Object>> items = authorizationMapper.findByUser(userId).stream()
|
||||
.map(this::toView).collect(Collectors.toList());
|
||||
List<Map<String, Object>> pendingConsent = authorizationMapper
|
||||
.findPendingConsentByUser(userId).stream()
|
||||
.map(this::toView).collect(Collectors.toList());
|
||||
Map<String, Object> result = new HashMap<>();
|
||||
result.put("list", items);
|
||||
result.put("pendingConsent", pendingConsent);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 视图投影:不含任何联系方式字段(授权表本身无 phone 列)。 */
|
||||
private Map<String, Object> toView(MatchmakerContactAuthorization auth) {
|
||||
Map<String, Object> item = new HashMap<>();
|
||||
item.put("id", auth.getId());
|
||||
item.put("operatorId", auth.getOperatorId());
|
||||
item.put("userId", auth.getUserId());
|
||||
item.put("channel", auth.getChannel());
|
||||
item.put("purpose", auth.getPurpose());
|
||||
item.put("status", auth.getStatus());
|
||||
item.put("createdAt", auth.getCreatedAt());
|
||||
item.put("approvedAt", auth.getApprovedAt());
|
||||
item.put("userConsentedAt", auth.getUserConsentedAt());
|
||||
item.put("expiresAt", auth.getExpiresAt());
|
||||
item.put("revokeReason", auth.getRevokeReason());
|
||||
return item;
|
||||
}
|
||||
|
||||
// ==================== 内部实现 ====================
|
||||
|
||||
private void requireValidChannel(String channel) {
|
||||
boolean valid = Constants.CONTACT_CHANNEL_PHONE.equals(channel)
|
||||
|| Constants.CONTACT_CHANNEL_OFFLINE_MEETING.equals(channel)
|
||||
|| Constants.CONTACT_CHANNEL_FULL_CONTACT.equals(channel);
|
||||
if (!valid) {
|
||||
throw new BusinessException(ApiResponseCode.VALIDATION_ERROR, "不支持的授权渠道: " + channel);
|
||||
}
|
||||
}
|
||||
|
||||
private MatchmakerContactAuthorization requireAuthorization(String authId) {
|
||||
return authorizationMapper.findById(authId)
|
||||
.orElseThrow(() -> new BusinessException(ApiResponseCode.NOT_FOUND, "授权记录不存在"));
|
||||
}
|
||||
|
||||
/** C 端归属校验:仅授权记录的本人用户可 consent/decline,违规 403。 */
|
||||
private MatchmakerContactAuthorization requireOwnedAuthorization(String authId, String userId) {
|
||||
MatchmakerContactAuthorization auth = requireAuthorization(authId);
|
||||
if (!auth.getUserId().equals(userId)) {
|
||||
dataScopeGuard.deny(userId, RESOURCE_TYPE, authId, "仅本人可处理该授权请求");
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
/** 审计用 service_assignment_id:当前 (红娘,用户) 生效中的分配 id,无则 null。 */
|
||||
private String activeAssignmentIdOf(MatchmakerContactAuthorization auth) {
|
||||
Optional<MatchmakerAssignment> assignment =
|
||||
serviceAssignmentPolicy.findActiveAssignment(auth.getOperatorId(), auth.getUserId());
|
||||
return assignment.map(MatchmakerAssignment::getId).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 审计写入:purpose_code(matchmaker.contact.*)+ service_assignment_id +
|
||||
* request_trace_id(MDC requestId)。afterData 一律不含 phone。
|
||||
*/
|
||||
private void saveAudit(String operatorId, String action, String authId,
|
||||
String serviceAssignmentId, String purposeCode, String afterData) {
|
||||
AuditLog auditLog = new AuditLog();
|
||||
auditLog.setOperatorId(operatorId);
|
||||
auditLog.setAction(action);
|
||||
auditLog.setResourceType(RESOURCE_TYPE);
|
||||
auditLog.setResourceId(authId);
|
||||
auditLog.setAfterData(afterData);
|
||||
auditLog.setPurposeCode(purposeCode);
|
||||
auditLog.setServiceAssignmentId(serviceAssignmentId);
|
||||
auditLog.setRequestTraceId(MDC.get("requestId"));
|
||||
auditLogMapper.insert(auditLog);
|
||||
}
|
||||
}
|
||||