From ecccecfd839d254afd34d9fcd1ddb665eaa73ebd Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 00:11:13 +0800 Subject: [PATCH 01/20] Update Dockerfiles to use custom image registry for Node and .NET base images --- .drone.yml | 77 ++++ CI-CD部署文档.md | 613 +++++++++++++++++++++++++ admin/Dockerfile | 4 +- deploy/docker-compose.yml | 47 ++ docker-compose.yml | 62 +++ scripts/push-base-images.sh | 31 ++ server/src/XiangYi.AdminApi/Dockerfile | 4 +- server/src/XiangYi.AppApi/Dockerfile | 4 +- 8 files changed, 836 insertions(+), 6 deletions(-) create mode 100644 .drone.yml create mode 100644 CI-CD部署文档.md create mode 100644 deploy/docker-compose.yml create mode 100644 docker-compose.yml create mode 100644 scripts/push-base-images.sh diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..299a2f8 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,77 @@ +--- +kind: pipeline +type: docker +name: xiangyixiangqin + +trigger: + branch: + - master + event: + - push + +steps: + - name: build-admin-api + image: plugins/docker + settings: + registry: 192.168.195.25:19900 + repo: 192.168.195.25:19900/xiangyixiangqin/admin-api + dockerfile: server/src/XiangYi.AdminApi/Dockerfile + context: server + tags: + - latest + - ${DRONE_COMMIT_SHA:0:8} + username: + from_secret: harbor_username + password: + from_secret: harbor_password + insecure: true + + - name: build-app-api + image: plugins/docker + settings: + registry: 192.168.195.25:19900 + repo: 192.168.195.25:19900/xiangyixiangqin/app-api + dockerfile: server/src/XiangYi.AppApi/Dockerfile + context: server + tags: + - latest + - ${DRONE_COMMIT_SHA:0:8} + username: + from_secret: harbor_username + password: + from_secret: harbor_password + insecure: true + + - name: build-admin-web + image: plugins/docker + settings: + registry: 192.168.195.25:19900 + repo: 192.168.195.25:19900/xiangyixiangqin/admin-web + dockerfile: admin/Dockerfile + context: admin + tags: + - latest + - ${DRONE_COMMIT_SHA:0:8} + username: + from_secret: harbor_username + password: + from_secret: harbor_password + insecure: true + + - name: deploy + image: appleboy/drone-ssh + settings: + host: 192.168.195.15 + username: + from_secret: ssh_username + password: + from_secret: ssh_password + port: 22 + script: + - cd /disk/docker-compose/xiangyixiangqin + - docker compose pull + - docker compose up -d + depends_on: + - build-admin-api + - build-app-api + - build-admin-web diff --git a/CI-CD部署文档.md b/CI-CD部署文档.md new file mode 100644 index 0000000..a6559d1 --- /dev/null +++ b/CI-CD部署文档.md @@ -0,0 +1,613 @@ +# CI/CD 部署文档 + +本文档描述基于 Drone CI + Docker + Harbor 私有镜像仓库(内网)的 CI/CD 流水线配置。其他项目可参考本文档搭建自己的 CI/CD 流程。 + +## 一、整体架构 + +``` +代码推送 (master) → Drone CI 触发 → 构建 Docker 镜像 → 推送内网 Harbor → SSH 部署到服务器 +``` + +| 组件 | 地址/说明 | +|------|----------| +| CI 平台 | Drone CI (`192.168.195.25:13080`) | +| 镜像仓库 | Harbor (`192.168.195.25:19900`,HTTP) | +| 部署方式 | Docker Compose | +| 部署服务器 | `192.168.195.15`(可按项目分配不同服务器) | +| 触发条件 | `master` 分支 push 事件 | + +## 二、快速开始(新项目接入指南) + +### 2.1 在 Harbor 创建项目 + +1. 访问 Harbor 管理界面:`http://192.168.195.25:19900` +2. 创建一个新项目,名称使用项目标识(如 `my-project`) +3. 设置为私有项目(推送需要认证) + +### 2.2 在 Drone CI 激活仓库 + +1. 访问 Drone CI:`http://192.168.195.25:13080` +2. 使用 Gitea/Gogs 账号登录(Drone 与代码仓库联动) +3. 在仓库列表中找到你的项目,点击 **Activate** +4. 进入仓库设置 → **Secrets**,添加以下密钥: + +| Secret 名称 | 说明 | 示例值 | +|-------------|------|--------| +| `harbor_username` | Harbor 仓库用户名 | `admin` | +| `harbor_password` | Harbor 仓库密码 | `Harbor12345` | +| `ssh_username` | 部署服务器 SSH 用户名 | `root` | +| `ssh_password` | 部署服务器 SSH 密码 | `your-password` | + +### 2.3 编写 `.drone.yml` + +在项目根目录创建 `.drone.yml`,参考下方模板。 + +### 2.4 编写 Dockerfile + +为每个需要构建的服务编写 Dockerfile。 + +### 2.5 编写 `docker-compose.yml` + +在部署服务器上创建 docker-compose 配置。 + +### 2.6 推送代码触发流水线 + +推送代码到 `master` 分支,Drone 会自动触发构建和部署。 + +## 三、`.drone.yml` 配置模板 + +### 3.1 单服务项目模板 + +适用于只有一个服务需要构建和部署的项目: + +```yaml +--- +kind: pipeline +type: docker +name: my-project # ← 改为你的项目名 + +trigger: + branch: + - master # ← 触发分支,可改为 main + event: + - push + +steps: + # ==================== 构建并推送镜像 ==================== + - name: build + image: plugins/docker + settings: + registry: 192.168.195.25:19900 + repo: 192.168.195.25:19900/my-project/app # ← 改为 Harbor项目名/镜像名 + dockerfile: Dockerfile # ← Dockerfile 路径 + context: . # ← 构建上下文目录 + tags: + - latest + - ${DRONE_COMMIT_SHA:0:8} # commit SHA 前8位,用于回滚 + username: + from_secret: harbor_username + password: + from_secret: harbor_password + insecure: true # Harbor 使用 HTTP 时必须 + + # ==================== 部署到服务器 ==================== + - name: deploy + image: appleboy/drone-ssh + settings: + host: 192.168.195.15 # ← 改为你的部署服务器 IP + username: + from_secret: ssh_username + password: + from_secret: ssh_password + port: 22 + script: + - cd /disk/docker-compose/my-project # ← 改为服务器上的部署目录 + - docker compose pull + - docker compose up -d + depends_on: + - build +``` + +### 3.2 多服务项目模板 + +适用于有多个服务(如 API + Admin)需要分别构建的项目: + +```yaml +--- +kind: pipeline +type: docker +name: my-project + +trigger: + branch: + - master + event: + - push + +steps: + # ==================== 构建服务 A ==================== + - name: build-service-a + image: plugins/docker + settings: + registry: 192.168.195.25:19900 + repo: 192.168.195.25:19900/my-project/service-a + dockerfile: src/ServiceA/Dockerfile + context: src + tags: + - latest + - ${DRONE_COMMIT_SHA:0:8} + username: + from_secret: harbor_username + password: + from_secret: harbor_password + insecure: true + + # ==================== 构建服务 B ==================== + - name: build-service-b + image: plugins/docker + settings: + registry: 192.168.195.25:19900 + repo: 192.168.195.25:19900/my-project/service-b + dockerfile: src/ServiceB/Dockerfile + context: src + tags: + - latest + - ${DRONE_COMMIT_SHA:0:8} + username: + from_secret: harbor_username + password: + from_secret: harbor_password + insecure: true + + # ==================== 部署到服务器 ==================== + - name: deploy + image: appleboy/drone-ssh + settings: + host: 192.168.195.15 + username: + from_secret: ssh_username + password: + from_secret: ssh_password + port: 22 + script: + - cd /disk/docker-compose/my-project + - docker compose pull + - docker compose up -d + depends_on: + - build-service-a # 等待所有构建完成 + - build-service-b +``` + +> 多个 build 步骤默认并行执行,deploy 通过 `depends_on` 等待全部完成后执行。 + +## 四、MiAssessment 项目实际配置 + +本项目(学业邑规划)的实际配置如下,供参考: + +### 4.1 构建的镜像 + +| 镜像 | 说明 | Dockerfile | 构建上下文 | +|------|------|-----------|-----------| +| `mi-assessment/api` | 小程序 API(.NET 10) | `server/MiAssessment/src/MiAssessment.Api/Dockerfile` | `server/MiAssessment` | +| `mi-assessment/admin` | 后台管理 API + 前端(.NET 10,内含 admin-web 构建产物) | `server/MiAssessment/src/MiAssessment.Admin/Dockerfile` | `server/MiAssessment` | + +每个镜像打两个标签:`latest` 和 commit SHA 前 8 位(用于回滚)。 + +### 4.2 流水线步骤 + +`.drone.yml` 定义了 3 个步骤: + +1. `build-api` — 构建小程序 API 镜像 +2. `build-admin` — 构建后台管理 API 镜像(并行) +3. `deploy` — SSH 部署(等待前两步完成) + +### 4.3 完整 `.drone.yml` + +```yaml +--- +kind: pipeline +type: docker +name: mi-assessment + +trigger: + branch: + - master + event: + - push + +steps: + - name: build-api + image: plugins/docker + settings: + registry: 192.168.195.25:19900 + repo: 192.168.195.25:19900/mi-assessment/api + dockerfile: server/MiAssessment/src/MiAssessment.Api/Dockerfile + context: server/MiAssessment + tags: + - latest + - ${DRONE_COMMIT_SHA:0:8} + username: + from_secret: harbor_username + password: + from_secret: harbor_password + insecure: true + + - name: build-admin + image: plugins/docker + settings: + registry: 192.168.195.25:19900 + repo: 192.168.195.25:19900/mi-assessment/admin + dockerfile: server/MiAssessment/src/MiAssessment.Admin/Dockerfile + context: server/MiAssessment + tags: + - latest + - ${DRONE_COMMIT_SHA:0:8} + username: + from_secret: harbor_username + password: + from_secret: harbor_password + insecure: true + + - name: deploy + image: appleboy/drone-ssh + settings: + host: 192.168.195.15 + username: + from_secret: ssh_username + password: + from_secret: ssh_password + port: 22 + script: + - cd /disk/docker-compose/mi-assessment + - docker compose pull + - docker compose up -d + depends_on: + - build-api + - build-admin +``` + +## 五、基础镜像管理 + +### 5.1 为什么需要内网基础镜像 + +内网环境无法直接拉取 Docker Hub / MCR 的镜像,需要提前将基础镜像推送到内网 Harbor 的 `library` 项目中。 + +### 5.2 推送基础镜像 + +在能访问外网的机器上执行: + +```bash +# 一键推送(如果项目提供了脚本) +bash scripts/push-base-images.sh + +# 手动推送单个镜像 +docker pull mcr.microsoft.com/dotnet/aspnet:10.0-preview +docker tag mcr.microsoft.com/dotnet/aspnet:10.0-preview 192.168.195.25:19900/library/dotnet/aspnet:10.0-preview +docker push 192.168.195.25:19900/library/dotnet/aspnet:10.0-preview +docker tag mcr.microsoft.com/dotnet/sdk:8.0 192.168.195.25:19900/library/dotnet/sdk:8.0 + +docker pull mcr.microsoft.com/dotnet/sdk:8.0 +mcr.microsoft.com/dotnet/sdk:8.0 +192.168.195.25:19900/library/dotnet/sdk:8.0 + +docker push 192.168.195.25:19900/library/dotnet/sdk:8.0 +mcr.microsoft.com/dotnet/aspnet:8.0.12 + + +``` + +### 5.3 常用基础镜像 + +| 基础镜像 | 内网地址 | 用途 | +|---------|---------|------| +| `dotnet/aspnet:10.0-preview` | `192.168.195.25:19900/library/dotnet/aspnet:10.0-preview` | .NET 运行时 | +| `dotnet/sdk:10.0-preview` | `192.168.195.25:19900/library/dotnet/sdk:10.0-preview` | .NET 构建 | +| `node:20-alpine` | `192.168.195.25:19900/library/node:20-alpine` | Node.js 前端构建 | +| `python:3.12-slim` | `192.168.195.25:19900/library/python:3.12-slim` | Python 项目 | +| `golang:1.22-alpine` | `192.168.195.25:19900/library/golang:1.22-alpine` | Go 项目 | +| `nginx:alpine` | `192.168.195.25:19900/library/nginx:alpine` | 静态文件服务 | + +### 5.4 Dockerfile 中使用内网镜像 + +```dockerfile +# 使用内网 Harbor 的基础镜像 +FROM 192.168.195.25:19900/library/node:20-alpine AS build +WORKDIR /app +COPY . . +RUN npm install && npm run build + +FROM 192.168.195.25:19900/library/nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +``` + +## 六、部署服务器配置 + +### 6.1 安装 Docker 和 Docker Compose + +```bash +# CentOS / RHEL +yum install -y docker-ce docker-compose-plugin + +# Ubuntu / Debian +apt-get install -y docker-ce docker-compose-plugin + +# 启动 Docker +systemctl enable docker +systemctl start docker +``` + +### 6.2 配置信任内网 Harbor(HTTP) + +由于 Harbor 使用 HTTP 而非 HTTPS,需要配置 Docker 信任该仓库: + +```bash +# 编辑 /etc/docker/daemon.json +{ + "insecure-registries": ["192.168.195.25:19900"] +} + +# 重启 Docker +systemctl restart docker +``` + +### 6.3 登录 Harbor + +```bash +docker login 192.168.195.25:19900 +# 输入用户名和密码 +``` + +### 6.4 创建部署目录 + +```bash +mkdir -p /disk/docker-compose/my-project +cd /disk/docker-compose/my-project +``` + +### 6.5 编写 `docker-compose.yml` + +示例(单服务): + +```yaml +version: "3.8" + +services: + app: + image: 192.168.195.25:19900/my-project/app:latest + container_name: my-project-app + ports: + - "8080:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Production + restart: unless-stopped +``` + +示例(多服务,参考 MiAssessment): + +```yaml +version: "3.8" + +services: + api: + image: 192.168.195.25:19900/mi-assessment/api:latest + container_name: mi-assessment-api + ports: + - "5000:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Production + volumes: + - ./appsettings.api.json:/app/appsettings.Production.json + restart: unless-stopped + + admin: + image: 192.168.195.25:19900/mi-assessment/admin:latest + container_name: mi-assessment-admin + ports: + - "5001:8080" + environment: + - ASPNETCORE_ENVIRONMENT=Production + volumes: + - ./appsettings.admin.json:/app/appsettings.Production.json + restart: unless-stopped +``` + +## 七、Drone CI 配置详解 + +### 7.1 关键字段说明 + +| 字段 | 说明 | +|------|------| +| `kind: pipeline` | 流水线类型 | +| `type: docker` | 使用 Docker 执行器 | +| `trigger.branch` | 触发分支 | +| `trigger.event` | 触发事件(push / pull_request / tag) | +| `settings.registry` | Harbor 仓库地址 | +| `settings.repo` | 镜像完整路径(含 registry) | +| `settings.dockerfile` | Dockerfile 相对路径 | +| `settings.context` | Docker 构建上下文目录 | +| `settings.tags` | 镜像标签列表 | +| `settings.insecure` | 允许 HTTP 推送(Harbor 非 HTTPS 时必须) | +| `from_secret` | 引用 Drone Secrets 中的密钥 | +| `depends_on` | 步骤依赖,等待指定步骤完成后执行 | + +### 7.2 常用 Drone 变量 + +| 变量 | 说明 | 示例值 | +|------|------|--------| +| `${DRONE_COMMIT_SHA}` | 完整 commit SHA | `a1b2c3d4e5f6...` | +| `${DRONE_COMMIT_SHA:0:8}` | commit SHA 前 8 位 | `a1b2c3d4` | +| `${DRONE_BRANCH}` | 当前分支名 | `master` | +| `${DRONE_TAG}` | Git 标签(tag 事件时) | `v1.0.0` | +| `${DRONE_BUILD_NUMBER}` | 构建编号 | `42` | +| `${DRONE_REPO_NAME}` | 仓库名 | `mi-assessment` | + +### 7.3 高级配置示例 + +#### 按分支部署到不同环境 + +```yaml +--- +kind: pipeline +type: docker +name: deploy-staging + +trigger: + branch: + - develop + event: + - push + +steps: + - name: build + image: plugins/docker + settings: + registry: 192.168.195.25:19900 + repo: 192.168.195.25:19900/my-project/app + tags: + - staging + - ${DRONE_COMMIT_SHA:0:8} + # ... 其他配置同上 + + - name: deploy-staging + image: appleboy/drone-ssh + settings: + host: 192.168.195.20 # 测试服务器 + username: + from_secret: ssh_username + password: + from_secret: ssh_password + port: 22 + script: + - cd /disk/docker-compose/my-project-staging + - docker compose pull + - docker compose up -d + depends_on: + - build + +--- +kind: pipeline +type: docker +name: deploy-production + +trigger: + branch: + - master + event: + - push + +steps: + - name: build + image: plugins/docker + settings: + registry: 192.168.195.25:19900 + repo: 192.168.195.25:19900/my-project/app + tags: + - latest + - ${DRONE_COMMIT_SHA:0:8} + # ... 其他配置同上 + + - name: deploy-production + image: appleboy/drone-ssh + settings: + host: 192.168.195.15 # 生产服务器 + # ... 其他配置同上 +``` + +#### 添加构建通知(钉钉/企业微信) + +```yaml + - name: notify + image: plugins/webhook + settings: + urls: https://oapi.dingtalk.com/robot/send?access_token=xxx + content_type: application/json + template: | + { + "msgtype": "text", + "text": { + "content": "✅ {{repo.name}} 部署成功\n分支: {{build.branch}}\n提交: {{build.commit}}" + } + } + depends_on: + - deploy + when: + status: + - success +``` + +## 八、新项目接入清单 + +按以下清单逐项完成,即可为新项目接入 CI/CD: + +| # | 步骤 | 操作位置 | 说明 | +|---|------|---------|------| +| 1 | 创建 Harbor 项目 | Harbor Web UI | 项目名与代码仓库名一致 | +| 2 | 推送基础镜像 | 外网机器 | 将 Dockerfile 中用到的基础镜像推送到 `library` | +| 3 | 编写 Dockerfile | 代码仓库 | 使用内网基础镜像地址 | +| 4 | 编写 `.drone.yml` | 代码仓库根目录 | 参考上方模板 | +| 5 | 激活 Drone 仓库 | Drone Web UI | 点击 Activate | +| 6 | 配置 Drone Secrets | Drone Web UI | 添加 harbor / ssh 凭证 | +| 7 | 创建部署目录 | 部署服务器 | `/disk/docker-compose/{项目名}` | +| 8 | 编写 `docker-compose.yml` | 部署服务器 | 配置镜像、端口、环境变量 | +| 9 | 配置 `insecure-registries` | 部署服务器 | Docker daemon 信任 Harbor | +| 10 | 推送代码触发 | 代码仓库 | push 到 master 分支 | + +## 九、常见问题 + +### Q: 构建时拉取基础镜像失败? + +基础镜像需要提前推送到内网 Harbor。Drone Runner 的 Docker daemon 也需要配置 `insecure-registries`。`.drone.yml` 中的 `insecure: true` 只处理推送,Dockerfile 中 `FROM` 拉取需要 runner 层面配置。 + +### Q: Harbor 认证失败(unauthorized)? + +检查 Drone Secrets 中的 `harbor_username` / `harbor_password` 是否正确且未过期。 + +### Q: 部署后服务没更新? + +1. 检查 `docker compose pull` 是否拉到了新镜像 +2. 确认服务器 Docker 已配置 `insecure-registries` +3. 检查容器状态:`docker compose ps` / `docker compose logs` + +### Q: 如何回滚到指定版本? + +使用 commit SHA 标签: + +```bash +cd /disk/docker-compose/my-project + +# 修改 docker-compose.yml 中的镜像标签 +# 将 :latest 改为 :a1b2c3d4(对应 commit SHA 前8位) +# 然后重新部署 +docker compose up -d +``` + +### Q: 如何手动触发构建? + +在 Drone 界面找到对应仓库,点击 **New Build**,选择分支即可。 + +### Q: 如何只部署不重新构建? + +直接在服务器上操作: + +```bash +cd /disk/docker-compose/my-project +docker compose pull +docker compose up -d +``` + +### Q: 构建步骤超时? + +在 `.drone.yml` 的 step 中添加超时配置(Drone 默认 60 分钟): + +```yaml + - name: build + image: plugins/docker + settings: + # ... + # Drone 2.x 不直接支持 step 级别 timeout, + # 可在仓库设置中调整全局超时时间 +``` + +### Q: 如何查看构建日志? + +访问 Drone CI 界面 `http://192.168.195.25:13080`,找到对应仓库和构建编号,点击查看每个步骤的日志。 diff --git a/admin/Dockerfile b/admin/Dockerfile index f1d4772..07c1b55 100644 --- a/admin/Dockerfile +++ b/admin/Dockerfile @@ -1,5 +1,5 @@ # 构建阶段 -FROM node:20-alpine AS build +FROM 192.168.195.25:19900/library/node:20-alpine AS build WORKDIR /app @@ -16,7 +16,7 @@ COPY . . RUN npm run build # 生产阶段 -FROM nginx:alpine +FROM 192.168.195.25:19900/library/nginx:alpine # 复制构建产物到 nginx COPY --from=build /app/dist /usr/share/nginx/html diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..6e9c1ec --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,47 @@ +services: + xiangyi-admin-api: + image: 192.168.195.25:19900/xiangyixiangqin/admin-api:latest + container_name: xiangyi-admin-api + ports: + - "${ADMIN_API_PORT:-2801}:8080" + volumes: + - ./configs/admin-api/appsettings.json:/app/appsettings.Production.json:ro + - ./configs/admin-api/appsettings.json:/app/appsettings.json:ro + - ./configs/apiclient_key.pem:/app/apiclient_key.pem + - ./configs/apiclient_cert.pem:/app/apiclient_cert.pem + - ./configs/apiclient_cert.p12:/app/apiclient_cert.p12 + - ./configs/pub_key.pem:/app/pub_key.pem + - ./configs/wwwroot:/app/wwwroot + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:8080 + - TZ=Asia/Shanghai + restart: unless-stopped + + xiangyi-app-api: + image: 192.168.195.25:19900/xiangyixiangqin/app-api:latest + container_name: xiangyi-app-api + ports: + - "${APP_API_PORT:-2802}:8080" + volumes: + - ./configs/app-api/appsettings.json:/app/appsettings.Production.json:ro + - ./configs/app-api/appsettings.json:/app/appsettings.json:ro + - ./configs/wwwroot:/app/wwwroot + - ./configs/apiclient_key.pem:/app/apiclient_key.pem + - ./configs/apiclient_cert.pem:/app/apiclient_cert.pem + - ./configs/apiclient_cert.p12:/app/apiclient_cert.p12 + - ./configs/pub_key.pem:/app/pub_key.pem + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:8080 + - TZ=Asia/Shanghai + restart: unless-stopped + + xiangyi-admin-web: + image: 192.168.195.25:19900/xiangyixiangqin/admin-web:latest + container_name: xiangyi-admin-web + ports: + - "${ADMIN_WEB_PORT:-2803}:80" + environment: + - TZ=Asia/Shanghai + restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2623ee2 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,62 @@ +services: + xiangyi-admin-api: + build: + context: ../../CodeManagea/xiangyixiangqin/server + dockerfile: src/XiangYi.AdminApi/Dockerfile + container_name: xiangyi-admin-api + ports: + - "${ADMIN_API_PORT:-2801}:8080" + volumes: + - ./configs/admin-api/appsettings.json:/app/appsettings.Production.json:ro + - ./configs/admin-api/appsettings.json:/app/appsettings.json:ro + - ./configs/apiclient_key.pem:/app/apiclient_key.pem + - ./configs/apiclient_cert.pem:/app/apiclient_cert.pem + - ./configs/apiclient_cert.p12:/app/apiclient_cert.p12 # 改这里 + - ./configs/pub_key.pem:/app/pub_key.pem + - ./configs/wwwroot:/app/wwwroot + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:8080 + - TZ=Asia/Shanghai # ✅ 设置时区 + restart: unless-stopped + networks: + - code-network + + xiangyi-app-api: + build: + context: ../../CodeManagea/xiangyixiangqin/server + dockerfile: src/XiangYi.AppApi/Dockerfile + container_name: xiangyi-app-api + ports: + - "${APP_API_PORT:-2802}:8080" + volumes: + - ./configs/app-api/appsettings.json:/app/appsettings.Production.json:ro + - ./configs/app-api/appsettings.json:/app/appsettings.json:ro0 + - ./configs/wwwroot:/app/wwwroot + - ./configs/apiclient_key.pem:/app/apiclient_key.pem + - ./configs/apiclient_cert.pem:/app/apiclient_cert.pem + - ./configs/apiclient_cert.p12:/app/apiclient_cert.p12 # 改这里 + - ./configs/pub_key.pem:/app/pub_key.pem + environment: + - ASPNETCORE_ENVIRONMENT=Production + - ASPNETCORE_URLS=http://+:8080 + - TZ=Asia/Shanghai # ✅ 设置时区 + restart: unless-stopped + networks: + - code-network + + xiangyi-admin-web: + build: + context: ../../CodeManagea/xiangyixiangqin/admin + dockerfile: Dockerfile + container_name: xiangyi-admin-web + ports: + - "${ADMIN_WEB_PORT:-2803}:80" + restart: unless-stopped + environment: + - TZ=Asia/Shanghai # ✅ 设置时区 + networks: + - code-network +networks: + code-network: + external: true diff --git a/scripts/push-base-images.sh b/scripts/push-base-images.sh new file mode 100644 index 0000000..ed1d3df --- /dev/null +++ b/scripts/push-base-images.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# +# 将 Dockerfile 所需的基础镜像推送到内网 Harbor +# 在能访问外网的机器上执行此脚本 +# + +HARBOR="192.168.195.25:19900" + +declare -A IMAGES=( + ["mcr.microsoft.com/dotnet/aspnet:8.0.12"]="library/dotnet/aspnet:8.0.12" + ["mcr.microsoft.com/dotnet/sdk:8.0"]="library/dotnet/sdk:8.0" + ["mcr.microsoft.com/dotnet/sdk:8.0.412"]="library/dotnet/sdk:8.0.412" + ["node:20-alpine"]="library/node:20-alpine" + ["nginx:alpine"]="library/nginx:alpine" +) + +echo "=== 登录 Harbor ===" +docker login "$HARBOR" + +for SRC in "${!IMAGES[@]}"; do + DST="${HARBOR}/${IMAGES[$SRC]}" + echo "" + echo "--- 处理: $SRC → $DST ---" + docker pull "$SRC" + docker tag "$SRC" "$DST" + docker push "$DST" + echo "--- 完成: $DST ---" +done + +echo "" +echo "=== 所有基础镜像已推送完毕 ===" diff --git a/server/src/XiangYi.AdminApi/Dockerfile b/server/src/XiangYi.AdminApi/Dockerfile index a07be6e..76e68e0 100644 --- a/server/src/XiangYi.AdminApi/Dockerfile +++ b/server/src/XiangYi.AdminApi/Dockerfile @@ -1,14 +1,14 @@ # 请参阅 https://aka.ms/customizecontainer 以了解如何自定义调试容器,以及 Visual Studio 如何使用此 Dockerfile 生成映像以更快地进行调试。 # 此阶段用于在快速模式(默认为调试配置)下从 VS 运行时 -FROM mcr.microsoft.com/dotnet/aspnet:8.0.12 AS base +FROM 192.168.195.25:19900/library/dotnet/aspnet:8.0.12 AS base USER $APP_UID WORKDIR /app EXPOSE 8080 # 此阶段用于生成服务项目 -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +FROM 192.168.195.25:19900/library/dotnet/sdk:8.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Directory.Build.props", "."] diff --git a/server/src/XiangYi.AppApi/Dockerfile b/server/src/XiangYi.AppApi/Dockerfile index 8d374f0..262c296 100644 --- a/server/src/XiangYi.AppApi/Dockerfile +++ b/server/src/XiangYi.AppApi/Dockerfile @@ -1,14 +1,14 @@ # 请参阅 https://aka.ms/customizecontainer 以了解如何自定义调试容器,以及 Visual Studio 如何使用此 Dockerfile 生成映像以更快地进行调试。 # 此阶段用于在快速模式(默认为调试配置)下从 VS 运行时 -FROM mcr.microsoft.com/dotnet/aspnet:8.0.12 AS base +FROM 192.168.195.25:19900/library/dotnet/aspnet:8.0.12 AS base WORKDIR /app EXPOSE 8080 EXPOSE 8081 # 此阶段用于生成服务项目 -FROM mcr.microsoft.com/dotnet/sdk:8.0.412 AS build +FROM 192.168.195.25:19900/library/dotnet/sdk:8.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src COPY ["Directory.Build.props", "."] From b55b56c9cb627ac3defb2c7e62c9ca5f245c8eee Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 01:11:22 +0800 Subject: [PATCH 02/20] Add notification template configuration management --- README.md | 137 ++++++++++++++++++ admin/src/api/config.ts | 24 +++ admin/src/views/system/config.vue | 106 +++++++++++++- .../Controllers/AdminConfigController.cs | 20 +++ .../Interfaces/ISystemConfigService.cs | 10 ++ .../Services/NotificationService.cs | 28 +++- .../Services/SystemConfigService.cs | 65 +++++++++ 7 files changed, 384 insertions(+), 6 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..8c87f77 --- /dev/null +++ b/README.md @@ -0,0 +1,137 @@ +# 相宜相亲 + +一站式相亲交友平台,包含微信小程序、后台管理系统、官方网站三端,采用 .NET 8 + Vue 3 + uni-app 技术栈。 + +## 项目结构 + +``` +xiangyixiangqin/ +├── server/ # 后端服务(.NET 8) +│ ├── src/ +│ │ ├── XiangYi.Core/ # 核心层:实体、枚举、常量、接口 +│ │ ├── XiangYi.Application/ # 应用层:业务服务、DTO、校验、定时任务 +│ │ ├── XiangYi.Infrastructure/ # 基础设施层:数据库、缓存、微信、支付、存储、短信 +│ │ ├── XiangYi.AppApi/ # 小程序端 API + SignalR 聊天 +│ │ └── XiangYi.AdminApi/ # 后台管理端 API +│ └── tests/ # 单元测试 & 集成测试 +├── admin/ # 后台管理前端(Vue 3 + Element Plus) +├── miniapp/ # 微信小程序(uni-app) +├── website/ # 官方网站(静态页面) +├── deploy/ # 生产部署配置 +├── docs/ # 需求文档、协议文本 +└── scripts/ # 运维脚本 +``` + +## 技术栈 + +### 后端 + +| 类别 | 技术 | 说明 | +|------|------|------| +| 运行时 | .NET 8 | LTS 版本 | +| Web 框架 | ASP.NET Core WebAPI | RESTful API,双 API 独立部署 | +| ORM | FreeSql | CodeFirst,支持迁移 | +| 数据库 | SQL Server | 主数据库 | +| 缓存 | Redis | 验证码、Token、热点数据 | +| 认证 | JWT | 身份认证 | +| 实时通信 | SignalR | 聊天消息推送 | +| 后台任务 | Hangfire | 每日推荐刷新、通知推送等 | +| 日志 | Serilog | 结构化日志 | +| 对象映射 | Mapster | 轻量级映射 | +| 参数校验 | FluentValidation | 请求验证 | + +### 后台管理前端 + +| 类别 | 技术 | +|------|------| +| 框架 | Vue 3 + TypeScript | +| 构建 | Vite | +| UI | Element Plus | +| 状态管理 | Pinia | +| 图表 | ECharts | + +### 小程序端 + +| 类别 | 技术 | +|------|------| +| 框架 | uni-app (Vue 3) | +| UI | uView Plus | +| 状态管理 | Pinia | + +### 第三方服务 + +| 服务 | 方案 | +|------|------| +| 文件存储 | 腾讯云 COS(可切换阿里云 OSS) | +| 短信 | 阿里云 SMS(可切换腾讯云 SMS) | +| 实名认证 | 阿里云实人认证(可切换腾讯云) | +| 支付 | 微信支付 V3 | + +所有第三方云服务采用接口抽象 + 依赖注入模式,可通过配置切换服务商。 + +## 核心功能 + +- **用户体系** — 微信登录、手机绑定、实名认证、相亲编号 +- **相亲资料** — 资料提交/编辑、照片管理、择偶条件 +- **智能推荐** — 基于择偶条件的每日推荐匹配 +- **即时聊天** — 基于 SignalR 的实时消息、在线状态 +- **会员服务** — 会员等级、联系方式解锁、微信支付 +- **互动功能** — 浏览、收藏、点赞、送花、关注 +- **内容管理** — Banner 轮播、首页弹窗、系统通知 +- **后台管理** — 用户审核、数据统计、系统配置、操作日志 + +## 本地开发 + +### 后端 + +```bash +cd server +dotnet restore +dotnet run --project src/XiangYi.AppApi # 小程序 API,默认 http://localhost:5000 +dotnet run --project src/XiangYi.AdminApi # 管理端 API,默认 http://localhost:5001 +``` + +### 后台管理前端 + +```bash +cd admin +npm install +npm run dev # 默认 http://localhost:5173 +``` + +### 小程序 + +```bash +cd miniapp +npm install +npm run dev:mp-weixin # 生成到 unpackage/dist/dev/mp-weixin +``` + +用微信开发者工具打开 `unpackage/dist/dev/mp-weixin` 目录进行调试。 + +## 部署 + +项目使用 **Drone CI + Harbor + Docker Compose** 进行自动化部署。 + +推送代码到 `master` 分支后,Drone 自动执行: + +1. 并行构建 3 个 Docker 镜像(admin-api、app-api、admin-web) +2. 推送到内网 Harbor 镜像仓库 +3. SSH 到目标服务器执行 `docker compose pull && docker compose up -d` + +| 服务 | 默认端口 | +|------|---------| +| Admin API | 2801 | +| App API | 2802 | +| Admin Web | 2803 | + +详见 [CI-CD部署文档.md](CI-CD部署文档.md)。 + +## 相关文档 + +| 文档 | 说明 | +|------|------| +| [技术栈与开发规范](server/技术栈与开发规范.md) | 技术选型、项目结构、API 设计、安全规范 | +| [数据库设计](server/数据库设计.md) | 表结构、字段说明、索引设计 | +| [CI-CD部署文档](CI-CD部署文档.md) | Drone CI 流水线、Harbor 镜像管理、部署步骤 | +| [需求文档](docs/需求文档.md) | 产品需求说明 | diff --git a/admin/src/api/config.ts b/admin/src/api/config.ts index fcc3693..924ce2d 100644 --- a/admin/src/api/config.ts +++ b/admin/src/api/config.ts @@ -184,3 +184,27 @@ export function getRealNamePrice() { export function setRealNamePrice(price: number) { return request.post('/admin/config/realNamePrice', { price }) } + +/** + * 服务号通知模板配置 + */ +export interface NotificationTemplatesConfig { + unlockTemplateId?: string + favoriteTemplateId?: string + messageTemplateId?: string + dailyRecommendTemplateId?: string +} + +/** + * 获取服务号通知模板配置 + */ +export function getNotificationTemplates() { + return request.get('/admin/config/notificationTemplates') +} + +/** + * 设置服务号通知模板配置 + */ +export function setNotificationTemplates(templates: NotificationTemplatesConfig) { + return request.post('/admin/config/notificationTemplates', templates) +} diff --git a/admin/src/views/system/config.vue b/admin/src/views/system/config.vue index 4957d19..faf3f3d 100644 --- a/admin/src/views/system/config.vue +++ b/admin/src/views/system/config.vue @@ -301,6 +301,61 @@ /> + + + + + + + + +
有用户解锁我时,服务号发送相应通知
+
+ + + +
有用户收藏我时,服务号发送相应通知
+
+ + + +
首次沟通通知、5分钟未回复提醒共用此模板
+
+ + + +
每天早上8~10点随机时间发送推荐更新通知
+
+ + + + 保存模板配置 + + +
+
@@ -330,7 +385,9 @@ import { getMemberEntryImage, setMemberEntryImage, getRealNamePrice, - setRealNamePrice + setRealNamePrice, + getNotificationTemplates, + setNotificationTemplates } from '@/api/config' import { useUserStore } from '@/stores/user' @@ -359,9 +416,17 @@ const agreementForm = ref({ privacyPolicy: '' }) +const templateForm = ref({ + unlockTemplateId: '', + favoriteTemplateId: '', + messageTemplateId: '', + dailyRecommendTemplateId: '' +}) + const saving = ref(false) const savingAgreement = ref(false) const savingPolicy = ref(false) +const savingTemplates = ref(false) const uploadUrl = computed(() => `${apiBaseUrl}/admin/upload`) @@ -626,9 +691,41 @@ const savePrivacyPolicy = async () => { } } +const loadNotificationTemplates = async () => { + try { + const res = await getNotificationTemplates() + if (res) { + templateForm.value.unlockTemplateId = res.unlockTemplateId || '' + templateForm.value.favoriteTemplateId = res.favoriteTemplateId || '' + templateForm.value.messageTemplateId = res.messageTemplateId || '' + templateForm.value.dailyRecommendTemplateId = res.dailyRecommendTemplateId || '' + } + } catch (error) { + console.error('加载模板配置失败:', error) + } +} + +const saveNotificationTemplates = async () => { + savingTemplates.value = true + try { + await setNotificationTemplates({ + unlockTemplateId: templateForm.value.unlockTemplateId || undefined, + favoriteTemplateId: templateForm.value.favoriteTemplateId || undefined, + messageTemplateId: templateForm.value.messageTemplateId || undefined, + dailyRecommendTemplateId: templateForm.value.dailyRecommendTemplateId || undefined + }) + ElMessage.success('模板配置保存成功') + } catch (error) { + console.error('保存模板配置失败:', error) + } finally { + savingTemplates.value = false + } +} + onMounted(() => { loadConfig() loadAgreements() + loadNotificationTemplates() }) @@ -863,4 +960,11 @@ onMounted(() => { color: #606266; font-size: 14px; } + +.template-tip { + color: #909399; + font-size: 12px; + margin-top: 4px; + line-height: 1.6; +} diff --git a/server/src/XiangYi.AdminApi/Controllers/AdminConfigController.cs b/server/src/XiangYi.AdminApi/Controllers/AdminConfigController.cs index a65085e..5474b09 100644 --- a/server/src/XiangYi.AdminApi/Controllers/AdminConfigController.cs +++ b/server/src/XiangYi.AdminApi/Controllers/AdminConfigController.cs @@ -362,6 +362,26 @@ public class AdminConfigController : ControllerBase var result = await _configService.SetRealNamePriceAsync(request.Price); return result ? ApiResponse.Success("设置成功") : ApiResponse.Error(40001, "设置失败"); } + + /// + /// 获取服务号通知模板配置 + /// + [HttpGet("notificationTemplates")] + public async Task> GetNotificationTemplates() + { + var templates = await _configService.GetNotificationTemplatesAsync(); + return ApiResponse.Success(templates); + } + + /// + /// 设置服务号通知模板配置 + /// + [HttpPost("notificationTemplates")] + public async Task SetNotificationTemplates([FromBody] NotificationTemplatesDto request) + { + var result = await _configService.SetNotificationTemplatesAsync(request); + return result ? ApiResponse.Success("设置成功") : ApiResponse.Error(40001, "设置失败"); + } } /// diff --git a/server/src/XiangYi.Application/Interfaces/ISystemConfigService.cs b/server/src/XiangYi.Application/Interfaces/ISystemConfigService.cs index 39bf38b..c0195ba 100644 --- a/server/src/XiangYi.Application/Interfaces/ISystemConfigService.cs +++ b/server/src/XiangYi.Application/Interfaces/ISystemConfigService.cs @@ -141,4 +141,14 @@ public interface ISystemConfigService /// 设置实名认证费用 /// Task SetRealNamePriceAsync(decimal price); + + /// + /// 获取服务号通知模板配置 + /// + Task GetNotificationTemplatesAsync(); + + /// + /// 设置服务号通知模板配置 + /// + Task SetNotificationTemplatesAsync(NotificationTemplatesDto templates); } diff --git a/server/src/XiangYi.Application/Services/NotificationService.cs b/server/src/XiangYi.Application/Services/NotificationService.cs index 5e60e30..753487c 100644 --- a/server/src/XiangYi.Application/Services/NotificationService.cs +++ b/server/src/XiangYi.Application/Services/NotificationService.cs @@ -19,6 +19,7 @@ public class NotificationService : INotificationService private readonly IRepository _userRepository; private readonly IWeChatService _weChatService; private readonly WeChatOptions _weChatOptions; + private readonly ISystemConfigService _configService; private readonly ILogger _logger; /// @@ -47,6 +48,7 @@ public class NotificationService : INotificationService IRepository userRepository, IWeChatService weChatService, IOptions weChatOptions, + ISystemConfigService configService, ILogger logger) { _notificationRepository = notificationRepository; @@ -54,6 +56,7 @@ public class NotificationService : INotificationService _userRepository = userRepository; _weChatService = weChatService; _weChatOptions = weChatOptions.Value; + _configService = configService; _logger = logger; } @@ -534,7 +537,7 @@ public class NotificationService : INotificationService { try { - var templateId = GetServiceAccountTemplateId(templateType); + var templateId = await GetServiceAccountTemplateIdAsync(templateType); if (string.IsNullOrEmpty(templateId)) { _logger.LogWarning("服务号模板ID未配置: TemplateType={TemplateType}", templateType); @@ -648,12 +651,27 @@ public class NotificationService : INotificationService } /// - /// 获取服务号模板ID + /// 获取服务号模板ID(优先从数据库配置读取,回退到appsettings.json) /// - /// 模板类型 - /// 模板ID - private string GetServiceAccountTemplateId(NotificationTemplateType templateType) + private async Task GetServiceAccountTemplateIdAsync(NotificationTemplateType templateType) { + string? configKey = templateType switch + { + NotificationTemplateType.Unlock => SystemConfigService.SaUnlockTemplateIdKey, + NotificationTemplateType.Favorite => SystemConfigService.SaFavoriteTemplateIdKey, + NotificationTemplateType.FirstMessage => SystemConfigService.SaMessageTemplateIdKey, + NotificationTemplateType.MessageReminder => SystemConfigService.SaMessageTemplateIdKey, + NotificationTemplateType.DailyRecommend => SystemConfigService.SaDailyRecommendTemplateIdKey, + _ => null + }; + + if (configKey != null) + { + var dbValue = await _configService.GetConfigValueAsync(configKey); + if (!string.IsNullOrEmpty(dbValue)) + return dbValue; + } + return templateType switch { NotificationTemplateType.Unlock => _weChatOptions.ServiceAccount.UnlockTemplateId, diff --git a/server/src/XiangYi.Application/Services/SystemConfigService.cs b/server/src/XiangYi.Application/Services/SystemConfigService.cs index 27ad45a..64f72ee 100644 --- a/server/src/XiangYi.Application/Services/SystemConfigService.cs +++ b/server/src/XiangYi.Application/Services/SystemConfigService.cs @@ -98,6 +98,11 @@ public class SystemConfigService : ISystemConfigService /// public const decimal DefaultRealNamePrice = 88m; + public const string SaUnlockTemplateIdKey = "sa_unlock_template_id"; + public const string SaFavoriteTemplateIdKey = "sa_favorite_template_id"; + public const string SaMessageTemplateIdKey = "sa_message_template_id"; + public const string SaDailyRecommendTemplateIdKey = "sa_daily_recommend_template_id"; + public SystemConfigService( IRepository configRepository, ILogger logger) @@ -344,6 +349,40 @@ public class SystemConfigService : ISystemConfigService { return await SetConfigValueAsync(RealNamePriceKey, price.ToString(), "实名认证费用(元)"); } + + /// + public async Task GetNotificationTemplatesAsync() + { + return new NotificationTemplatesDto + { + UnlockTemplateId = await GetConfigValueAsync(SaUnlockTemplateIdKey), + FavoriteTemplateId = await GetConfigValueAsync(SaFavoriteTemplateIdKey), + MessageTemplateId = await GetConfigValueAsync(SaMessageTemplateIdKey), + DailyRecommendTemplateId = await GetConfigValueAsync(SaDailyRecommendTemplateIdKey) + }; + } + + /// + public async Task SetNotificationTemplatesAsync(NotificationTemplatesDto templates) + { + try + { + if (!string.IsNullOrEmpty(templates.UnlockTemplateId)) + await SetConfigValueAsync(SaUnlockTemplateIdKey, templates.UnlockTemplateId, "服务号解锁通知模板ID"); + if (!string.IsNullOrEmpty(templates.FavoriteTemplateId)) + await SetConfigValueAsync(SaFavoriteTemplateIdKey, templates.FavoriteTemplateId, "服务号收藏通知模板ID"); + if (!string.IsNullOrEmpty(templates.MessageTemplateId)) + await SetConfigValueAsync(SaMessageTemplateIdKey, templates.MessageTemplateId, "服务号消息通知模板ID"); + if (!string.IsNullOrEmpty(templates.DailyRecommendTemplateId)) + await SetConfigValueAsync(SaDailyRecommendTemplateIdKey, templates.DailyRecommendTemplateId, "服务号每日推荐通知模板ID"); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "设置通知模板配置失败"); + return false; + } + } } /// @@ -371,3 +410,29 @@ public class MemberIconsDto /// public string? TimeLimitedMemberIcon { get; set; } } + +/// +/// 服务号通知模板配置DTO +/// +public class NotificationTemplatesDto +{ + /// + /// 解锁通知模板ID + /// + public string? UnlockTemplateId { get; set; } + + /// + /// 收藏通知模板ID + /// + public string? FavoriteTemplateId { get; set; } + + /// + /// 消息通知模板ID(首次消息/未回复提醒共用) + /// + public string? MessageTemplateId { get; set; } + + /// + /// 每日推荐通知模板ID + /// + public string? DailyRecommendTemplateId { get; set; } +} From e4e4fb774e3b84f094f06d61f184a4061cd7a3b9 Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 19:26:34 +0800 Subject: [PATCH 03/20] =?UTF-8?q?=E4=BF=AE=E6=94=B9web=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/vite.config.ts | 2 +- miniapp/config/index.js | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/admin/vite.config.ts b/admin/vite.config.ts index b949eff..087ed7f 100644 --- a/admin/vite.config.ts +++ b/admin/vite.config.ts @@ -9,7 +9,7 @@ export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') return { - base: '/xyqj/admin/', + // base: '/xyqj/admin/', plugins: [ vue() // 暂时禁用自动导入插件来解决兼容性问题 diff --git a/miniapp/config/index.js b/miniapp/config/index.js index 05b4651..d94e20f 100644 --- a/miniapp/config/index.js +++ b/miniapp/config/index.js @@ -19,11 +19,17 @@ const ENV = { STATIC_BASE_URL: 'https://api.xyqinjia.com', ADMIN_API_BASE_URL: 'https://admin-api.xyqinjia.com/', SIGNALR_URL: 'wss://api.xyqinjia.com/hubs/chat' + }, + test: { + API_BASE_URL: 'https://api.wtd.shhmkjgs.cn/api/app', + STATIC_BASE_URL: 'https://api.wtd.shhmkjgs.cn', + ADMIN_API_BASE_URL: 'https://admin.wtd.shhmkjgs.cn/', + SIGNALR_URL: 'wss://api.wtd.shhmkjgs.cn/hubs/chat' } } // 当前环境 - 开发时使用 development,打包时改为 production -const CURRENT_ENV = 'production' +const CURRENT_ENV = 'test' // 导出配置 export const config = { From 600eeead702c40ee0c893352be692d0f90051d5c Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 19:31:36 +0800 Subject: [PATCH 04/20] chore(deploy): Configure nginx reverse proxy and docker network - Update admin API base URL to use nginx reverse proxy path (/api) - Add nginx reverse proxy configuration for admin-api service with proper headers and timeouts - Add all services to xyqj-network for internal container communication - Reorder app-api volume mounts for consistency - Create bridge network for service-to-service communication in docker-compose --- admin/.env.production | 4 ++-- admin/nginx.conf | 12 ++++++++++++ deploy/docker-compose.yml | 12 +++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/admin/.env.production b/admin/.env.production index 2e63226..24c0f77 100644 --- a/admin/.env.production +++ b/admin/.env.production @@ -3,8 +3,8 @@ # 应用标题 VITE_APP_TITLE=相宜相亲后台管理系统 -# API基础地址 - 生产环境请修改为实际地址 -VITE_API_BASE_URL=https://app.zpc-xy.com/xyqj/adminapi/api +# API基础地址 - 通过nginx反向代理到admin-api容器 +VITE_API_BASE_URL=/api # 静态资源服务器地址 - 生产环境请修改为实际地址 VITE_STATIC_BASE_URL=https://app.zpc-xy.com/xyqj/adminapi diff --git a/admin/nginx.conf b/admin/nginx.conf index 687ae6f..66b0063 100644 --- a/admin/nginx.conf +++ b/admin/nginx.conf @@ -4,6 +4,18 @@ server { root /usr/share/nginx/html; index index.html; + # 反向代理后台API + location /api/ { + proxy_pass http://xiangyi-admin-api:8080/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_read_timeout 120s; + proxy_send_timeout 60s; + } + # 处理前端路由 location / { try_files $uri $uri/ /index.html; diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 6e9c1ec..e10572b 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -17,6 +17,8 @@ services: - ASPNETCORE_URLS=http://+:8080 - TZ=Asia/Shanghai restart: unless-stopped + networks: + - xyqj-network xiangyi-app-api: image: 192.168.195.25:19900/xiangyixiangqin/app-api:latest @@ -24,8 +26,8 @@ services: ports: - "${APP_API_PORT:-2802}:8080" volumes: - - ./configs/app-api/appsettings.json:/app/appsettings.Production.json:ro - ./configs/app-api/appsettings.json:/app/appsettings.json:ro + - ./configs/app-api/appsettings.json:/app/appsettings.Production.json:ro - ./configs/wwwroot:/app/wwwroot - ./configs/apiclient_key.pem:/app/apiclient_key.pem - ./configs/apiclient_cert.pem:/app/apiclient_cert.pem @@ -36,6 +38,8 @@ services: - ASPNETCORE_URLS=http://+:8080 - TZ=Asia/Shanghai restart: unless-stopped + networks: + - xyqj-network xiangyi-admin-web: image: 192.168.195.25:19900/xiangyixiangqin/admin-web:latest @@ -45,3 +49,9 @@ services: environment: - TZ=Asia/Shanghai restart: unless-stopped + networks: + - xyqj-network + +networks: + xyqj-network: + driver: bridge From 4af5ae8065b82268f23e8878455c869f11da9c07 Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 20:03:12 +0800 Subject: [PATCH 05/20] feat(wechat): Add WeChat official account token and encryption key configuration - Add token and encodingAESKey fields to NotificationTemplatesConfig interface - Add UI form inputs for WeChat token and EncodingAESKey in system config page - Add configuration constants SaTokenKey and SaEncodingAesKeyKey to SystemConfigService - Update WeChatEventController to fetch token from database instead of hardcoded value - Make CheckSignature method async and retrieve token from ISystemConfigService - Update GetNotificationTemplates to include token and encodingAESKey from config - Update SetNotificationTemplates to persist token and encodingAESKey to database - Update mini app manifest with new appid - Enables dynamic WeChat server configuration without code changes --- admin/src/api/config.ts | 2 ++ admin/src/views/system/config.vue | 24 +++++++++++++++++++ miniapp/manifest.json | 2 +- .../Controllers/WeChatEventController.cs | 24 ++++++++++++------- .../Services/SystemConfigService.cs | 18 ++++++++++++++ 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/admin/src/api/config.ts b/admin/src/api/config.ts index 924ce2d..0c09497 100644 --- a/admin/src/api/config.ts +++ b/admin/src/api/config.ts @@ -189,6 +189,8 @@ export function setRealNamePrice(price: number) { * 服务号通知模板配置 */ export interface NotificationTemplatesConfig { + token?: string + encodingAESKey?: string unlockTemplateId?: string favoriteTemplateId?: string messageTemplateId?: string diff --git a/admin/src/views/system/config.vue b/admin/src/views/system/config.vue index faf3f3d..b7abdfe 100644 --- a/admin/src/views/system/config.vue +++ b/admin/src/views/system/config.vue @@ -313,6 +313,24 @@ style="margin-bottom: 24px;" /> + + +
在微信公众平台 → 基本配置 → 服务器配置中设置的Token,需保持一致
+
+ + + +
微信公众平台 → 基本配置 → 服务器配置中的消息加解密密钥,明文模式可不填
+
+ { try { const res = await getNotificationTemplates() if (res) { + templateForm.value.token = res.token || '' + templateForm.value.encodingAESKey = res.encodingAESKey || '' templateForm.value.unlockTemplateId = res.unlockTemplateId || '' templateForm.value.favoriteTemplateId = res.favoriteTemplateId || '' templateForm.value.messageTemplateId = res.messageTemplateId || '' @@ -709,6 +731,8 @@ const saveNotificationTemplates = async () => { savingTemplates.value = true try { await setNotificationTemplates({ + token: templateForm.value.token || undefined, + encodingAESKey: templateForm.value.encodingAESKey || undefined, unlockTemplateId: templateForm.value.unlockTemplateId || undefined, favoriteTemplateId: templateForm.value.favoriteTemplateId || undefined, messageTemplateId: templateForm.value.messageTemplateId || undefined, diff --git a/miniapp/manifest.json b/miniapp/manifest.json index 6af00ca..7bf37e5 100644 --- a/miniapp/manifest.json +++ b/miniapp/manifest.json @@ -1,6 +1,6 @@ { "name" : "相宜亲家", - "appid" : "__UNI__39EAECC", + "appid" : "__UNI__85044B9", "description" : "", "versionName" : "1.0.0", "versionCode" : "100", diff --git a/server/src/XiangYi.AppApi/Controllers/WeChatEventController.cs b/server/src/XiangYi.AppApi/Controllers/WeChatEventController.cs index 7985f04..4444a1f 100644 --- a/server/src/XiangYi.AppApi/Controllers/WeChatEventController.cs +++ b/server/src/XiangYi.AppApi/Controllers/WeChatEventController.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Mvc; using System.Security.Cryptography; using System.Text; using System.Xml.Linq; +using XiangYi.Application.Interfaces; using XiangYi.Core.Entities.Biz; using XiangYi.Core.Interfaces; @@ -19,30 +20,30 @@ public class WeChatEventController : ControllerBase private readonly IRepository _userRepository; private readonly ILogger _logger; private readonly IConfiguration _configuration; - - // 服务号Token(需要在微信公众平台配置) - private const string Token = "xiangyi2024"; + private readonly ISystemConfigService _configService; public WeChatEventController( IRepository userRepository, ILogger logger, - IConfiguration configuration) + IConfiguration configuration, + ISystemConfigService configService) { _userRepository = userRepository; _logger = logger; _configuration = configuration; + _configService = configService; } /// /// 微信服务器验证(GET请求) /// [HttpGet] - public IActionResult Verify(string signature, string timestamp, string nonce, string echostr) + public async Task Verify(string signature, string timestamp, string nonce, string echostr) { _logger.LogInformation("收到微信服务器验证请求: signature={Signature}, timestamp={Timestamp}, nonce={Nonce}", signature, timestamp, nonce); - if (CheckSignature(signature, timestamp, nonce)) + if (await CheckSignatureAsync(signature, timestamp, nonce)) { _logger.LogInformation("微信服务器验证成功"); return Content(echostr); @@ -169,9 +170,16 @@ public class WeChatEventController : ControllerBase /// /// 验证微信签名 /// - private bool CheckSignature(string signature, string timestamp, string nonce) + private async Task CheckSignatureAsync(string signature, string timestamp, string nonce) { - var arr = new[] { Token, timestamp, nonce }; + var token = await _configService.GetConfigValueAsync("sa_token"); + if (string.IsNullOrEmpty(token)) + { + _logger.LogWarning("服务号Token未配置"); + return false; + } + + var arr = new[] { token, timestamp, nonce }; Array.Sort(arr); var str = string.Join("", arr); diff --git a/server/src/XiangYi.Application/Services/SystemConfigService.cs b/server/src/XiangYi.Application/Services/SystemConfigService.cs index 64f72ee..888ea75 100644 --- a/server/src/XiangYi.Application/Services/SystemConfigService.cs +++ b/server/src/XiangYi.Application/Services/SystemConfigService.cs @@ -98,6 +98,8 @@ public class SystemConfigService : ISystemConfigService /// public const decimal DefaultRealNamePrice = 88m; + public const string SaTokenKey = "sa_token"; + public const string SaEncodingAesKeyKey = "sa_encoding_aes_key"; public const string SaUnlockTemplateIdKey = "sa_unlock_template_id"; public const string SaFavoriteTemplateIdKey = "sa_favorite_template_id"; public const string SaMessageTemplateIdKey = "sa_message_template_id"; @@ -355,6 +357,8 @@ public class SystemConfigService : ISystemConfigService { return new NotificationTemplatesDto { + Token = await GetConfigValueAsync(SaTokenKey), + EncodingAESKey = await GetConfigValueAsync(SaEncodingAesKeyKey), UnlockTemplateId = await GetConfigValueAsync(SaUnlockTemplateIdKey), FavoriteTemplateId = await GetConfigValueAsync(SaFavoriteTemplateIdKey), MessageTemplateId = await GetConfigValueAsync(SaMessageTemplateIdKey), @@ -367,6 +371,10 @@ public class SystemConfigService : ISystemConfigService { try { + if (!string.IsNullOrEmpty(templates.Token)) + await SetConfigValueAsync(SaTokenKey, templates.Token, "服务号验证Token"); + if (!string.IsNullOrEmpty(templates.EncodingAESKey)) + await SetConfigValueAsync(SaEncodingAesKeyKey, templates.EncodingAESKey, "服务号消息加解密密钥"); if (!string.IsNullOrEmpty(templates.UnlockTemplateId)) await SetConfigValueAsync(SaUnlockTemplateIdKey, templates.UnlockTemplateId, "服务号解锁通知模板ID"); if (!string.IsNullOrEmpty(templates.FavoriteTemplateId)) @@ -416,6 +424,16 @@ public class MemberIconsDto /// public class NotificationTemplatesDto { + /// + /// 服务号验证Token(微信公众平台配置的Token) + /// + public string? Token { get; set; } + + /// + /// 服务号消息加解密密钥(EncodingAESKey) + /// + public string? EncodingAESKey { get; set; } + /// /// 解锁通知模板ID /// From 3c53cddd0b6c75e6ffd95560b9257f024b2d4b94 Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 20:23:54 +0800 Subject: [PATCH 06/20] feat(wechat): Integrate service account user info API for UnionId retrieval - Add IWeChatService dependency injection to WeChatEventController - Implement GetServiceAccountUserInfoAsync method to fetch user info including UnionId from WeChat API - Add ServiceAccountUserInfo DTO class with Subscribe, OpenId, and UnionId properties - Refactor follow event handler to use WeChat API for UnionId instead of XML parsing - Add validation to ensure UnionId is retrieved before attempting user association - Update notification URLs in appsettings.json to remove redundant path segment - Improve error logging when UnionId retrieval fails or user association is not found --- .../Controllers/WeChatEventController.cs | 47 ++++++++-------- server/src/XiangYi.AppApi/appsettings.json | 4 +- .../WeChat/IWeChatService.cs | 28 ++++++++++ .../WeChat/WeChatService.cs | 56 +++++++++++++++++++ 4 files changed, 111 insertions(+), 24 deletions(-) diff --git a/server/src/XiangYi.AppApi/Controllers/WeChatEventController.cs b/server/src/XiangYi.AppApi/Controllers/WeChatEventController.cs index 4444a1f..9930087 100644 --- a/server/src/XiangYi.AppApi/Controllers/WeChatEventController.cs +++ b/server/src/XiangYi.AppApi/Controllers/WeChatEventController.cs @@ -6,6 +6,7 @@ using System.Xml.Linq; using XiangYi.Application.Interfaces; using XiangYi.Core.Entities.Biz; using XiangYi.Core.Interfaces; +using XiangYi.Infrastructure.WeChat; namespace XiangYi.AppApi.Controllers; @@ -21,17 +22,20 @@ public class WeChatEventController : ControllerBase private readonly ILogger _logger; private readonly IConfiguration _configuration; private readonly ISystemConfigService _configService; + private readonly IWeChatService _weChatService; public WeChatEventController( IRepository userRepository, ILogger logger, IConfiguration configuration, - ISystemConfigService configService) + ISystemConfigService configService, + IWeChatService weChatService) { _userRepository = userRepository; _logger = logger; _configuration = configuration; _configService = configService; + _weChatService = weChatService; } /// @@ -109,34 +113,33 @@ public class WeChatEventController : ControllerBase _logger.LogInformation("用户关注服务号: ServiceAccountOpenId={OpenId}", serviceAccountOpenId); - // 尝试通过UnionId关联用户 - // 注意:需要服务号和小程序绑定到同一个开放平台才能获取UnionId - var unionId = root?.Element("UnionId")?.Value; + // 通过服务号接口获取用户信息(包含UnionId) + var userInfo = await _weChatService.GetServiceAccountUserInfoAsync(serviceAccountOpenId); + var unionId = userInfo?.UnionId; - if (!string.IsNullOrEmpty(unionId)) + if (string.IsNullOrEmpty(unionId)) { - // 通过UnionId查找用户 - var users = await _userRepository.GetListAsync(u => u.UnionId == unionId); - var user = users.FirstOrDefault(); + _logger.LogWarning("用户关注服务号但获取UnionId失败: ServiceAccountOpenId={OpenId},请确认服务号已绑定微信开放平台", serviceAccountOpenId); + return; + } - if (user != null) - { - user.ServiceAccountOpenId = serviceAccountOpenId; - user.IsFollowServiceAccount = true; - user.UpdateTime = DateTime.Now; - await _userRepository.UpdateAsync(user); + // 通过UnionId查找小程序用户 + var users = await _userRepository.GetListAsync(u => u.UnionId == unionId); + var user = users.FirstOrDefault(); - _logger.LogInformation("用户关注服务号并关联成功: UserId={UserId}, UnionId={UnionId}, ServiceAccountOpenId={OpenId}", - user.Id, unionId, serviceAccountOpenId); - } - else - { - _logger.LogInformation("用户关注服务号但未找到关联用户: UnionId={UnionId}", unionId); - } + if (user != null) + { + user.ServiceAccountOpenId = serviceAccountOpenId; + user.IsFollowServiceAccount = true; + user.UpdateTime = DateTime.Now; + await _userRepository.UpdateAsync(user); + + _logger.LogInformation("用户关注服务号并关联成功: UserId={UserId}, UnionId={UnionId}, ServiceAccountOpenId={OpenId}", + user.Id, unionId, serviceAccountOpenId); } else { - _logger.LogInformation("用户关注服务号但无UnionId: ServiceAccountOpenId={OpenId}", serviceAccountOpenId); + _logger.LogInformation("用户关注服务号但未找到关联小程序用户: UnionId={UnionId}", unionId); } } diff --git a/server/src/XiangYi.AppApi/appsettings.json b/server/src/XiangYi.AppApi/appsettings.json index 3d17d88..e830cbc 100644 --- a/server/src/XiangYi.AppApi/appsettings.json +++ b/server/src/XiangYi.AppApi/appsettings.json @@ -59,7 +59,7 @@ "CertSerialNo": "429F8544BF89D61B1A98643277A8DC7E5C4B1DAA", "PrivateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDoFVHlfDo3flrT\nXLEbAcoQvh3bG/TJq38sjim0Frk740zkRQfkYMIUqtkPZBLl47MSPwaOKjxIyYJB\nr4CIW10rg7pA67pnpnRgYoPGF/DMT5Mle+pxWt94xGT8ircwx7WwTBHDthJiGhQN\nwTiy7dSH8Nbk14fcK6AN794dNRVwILon0O8z2osuoQngjVDLXB0BVYqZjV7/js99\nyXRZnZVuxiuvnbHzNxxe86qZgXjgFKpiey5sinx+CRjTyDD7CCVOVKwr7h7cKqVC\nbbnN20DWzLundRkBJFh3dbUcX4Pp3gV9m6B0UURARjpLkym6j3fRDLGJHWREeQ7N\no6qsaUdtAgMBAAECggEBAMuOD4OQ/tq3Z2Ak122RlzIyHauVDJFpaqSgl/FNUPA2\n/7Ti2vYy62cHJlR6eJzLpr8lKlG8t507qJSGItz2DXTiF5Vja94HP+Fd5qfzTY9V\naAEje1Aq3QBmeRCLdftB3pifT6FxaxRCPT6HL3y4XoVQ9ppGc/HnDX3L2euSKJhr\nYCZa+kB5L4FtM0JDGTnx+Q9fuCuKtCcT3YHaryildwiz4WiQxp1kvXj9bK+kNbBO\nPi79Kui8mRY3KDYaccxBgmqR9JkJ2/l52kKlJb5HWoRS3jh2/MulNj7gpWVy6KNb\ni6OMWs548EJRw9jrZu1cGmlThrguX9XaGWFvFfcRx0ECgYEA/ENIW7mm8cVnTCsu\n30qzQlJ7plljTIaan+TmVead8KK3fiRjUg4jK1Zh0JYFRaA4lC0M2mitsNKw0zHk\nKM3sh4ZIbzzh6CCOkUd0L7+3p4v9U3Bm6sjTS5WLy/MusPQEd4W86Gmn8LWcPx5q\n9Tz2hpbAyDals9AZEjgWu/rgWrECgYEA64WBaE+EEDkAtqkmpn8OLdlN5K51ncLe\n9Q4nv+NP9AtlwCmd3SBKv6qAEviS/hM4m+tjNlvS/UwP+6SPXTcnaBaCrpYdgv7e\nkVSZHboxHdRnYqReg6WhnOErol2GLqe/9+gc70x97T/KCIZ+/nE2MnQy2uFZVm12\nkxMvj1g+r30CgYAPb2Z8Bk4KuRNq+7FwhDeXtUhPk2SaCBpp8i2N0ACV+r7TfxJ8\nsNTCEBUIGEXWTslnd6Izsvf9u8aKBaF6Ra9VU4gXFliURXmztfWL/mUUYWJsupHx\nh7w2Ab5+CjEvLp8fWRWH+v8FoXcf/ZJ50vMapRrCpWVaLT97d+ccNWuI4QKBgQDO\nR4goDDzm2IY/dbdcbDvG/GS0vfhVzK/qghNehYEphjIANHMHkZjmdjbmZsCXt84F\nAg1LNvF82HnHNUI7qmrhR5X9w4zlhsT5FNdmqgUK01YZl00QkKkT9kN5WeCETHhe\ncPWmwaApg404GlRwFkgZuJwyCN1uTUFlX5BwRCHjIQKBgHTXcrlGfW5U2piJGdBs\nbi+I3nYPioyyHM9jUmdBtEtR04pXVV2590KZL2TknPB1dN2yhv9FUt4XO5+baoie\nas6QkQGrtOtVnO2X/oVOZQBmPG3RGZAMcWgYXJeLCxlf+DZ0OZNn0/V3od39WN7t\n84/yPSRGUr71Q48atr9N9N9x\n-----END PRIVATE KEY-----", "ApiV3Key": "1230uaPcnzdh3lkxjcoiddUBXddWkpx2", - "NotifyUrl": "https://app.zpc-xy.com/xyqj/api/order/payNotify" + "NotifyUrl": "https://app.zpc-xy.com/api/order/payNotify" } }, "WeChatPay": { @@ -71,7 +71,7 @@ "CertPath": "apiclient_cert.pem", "PlatformCertPath": "pub_key.pem", "PlatformCertSerialNo": "PUB_KEY_ID_0117379432252026012200382382002003", - "NotifyUrl": "https://app.zpc-xy.com/xyqj/api/app/pay/notify" + "NotifyUrl": "https://app.zpc-xy.com/api/app/pay/notify" }, "Storage": { "Provider": "TencentCos", diff --git a/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs b/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs index b05ced4..bca96d2 100644 --- a/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs +++ b/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs @@ -69,6 +69,13 @@ public interface IWeChatService /// /// AccessToken Task GetServiceAccountAccessTokenAsync(); + + /// + /// 获取服务号关注用户信息(包含UnionId) + /// + /// 用户在服务号的OpenId + /// 用户信息 + Task GetServiceAccountUserInfoAsync(string openId); } /// @@ -328,3 +335,24 @@ public class MiniProgramInfo /// public string? PagePath { get; set; } } + +/// +/// 服务号关注用户信息 +/// +public class ServiceAccountUserInfo +{ + /// + /// 是否关注(0未关注,1已关注) + /// + public int Subscribe { get; set; } + + /// + /// 用户OpenId + /// + public string OpenId { get; set; } = string.Empty; + + /// + /// UnionId(绑定开放平台后才有) + /// + public string? UnionId { get; set; } +} diff --git a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs index 2d872e6..40ca46a 100644 --- a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs +++ b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs @@ -470,6 +470,44 @@ public class WeChatService : IWeChatService } } + public async Task GetServiceAccountUserInfoAsync(string openId) + { + try + { + var accessToken = await GetServiceAccountAccessTokenAsync(); + if (string.IsNullOrEmpty(accessToken)) + { + _logger.LogError("获取服务号用户信息失败: AccessToken为空"); + return null; + } + + var url = $"https://api.weixin.qq.com/cgi-bin/user/info?access_token={accessToken}&openid={openId}&lang=zh_CN"; + var httpResponse = await _httpClient.GetAsync(url); + var responseContent = await httpResponse.Content.ReadAsStringAsync(); + + _logger.LogInformation("获取服务号用户信息响应: {Response}", responseContent); + + var result = System.Text.Json.JsonSerializer.Deserialize(responseContent); + if (result == null || result.ErrCode != 0) + { + _logger.LogWarning("获取服务号用户信息失败: {ErrCode} - {ErrMsg}", result?.ErrCode, result?.ErrMsg); + return null; + } + + return new ServiceAccountUserInfo + { + Subscribe = result.Subscribe, + OpenId = result.OpenId ?? openId, + UnionId = result.UnionId + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "获取服务号用户信息异常: OpenId={OpenId}", openId); + return null; + } + } + #endregion #region 私有方法 @@ -752,5 +790,23 @@ public class WeChatService : IWeChatService public string? OpenId { get; set; } } + private class ServiceAccountUserInfoResponse + { + [JsonPropertyName("subscribe")] + public int Subscribe { get; set; } + + [JsonPropertyName("openid")] + public string? OpenId { get; set; } + + [JsonPropertyName("unionid")] + public string? UnionId { get; set; } + + [JsonPropertyName("errcode")] + public int ErrCode { get; set; } + + [JsonPropertyName("errmsg")] + public string? ErrMsg { get; set; } + } + #endregion } From a74eee4dc84b44a2af67ebdc95113b8e984fe5ef Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 20:37:30 +0800 Subject: [PATCH 07/20] feat(wechat): Improve template message response handling with logging - Add response content logging for debugging template message sends - Handle empty response cases with explicit null check and warning log - Replace ReadFromJsonAsync with manual deserialization for better error visibility - Ensure service account template messages are properly validated before processing --- .../XiangYi.Infrastructure/WeChat/WeChatService.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs index 40ca46a..ec39679 100644 --- a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs +++ b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs @@ -452,7 +452,17 @@ public class WeChatService : IWeChatService } var response = await _httpClient.PostAsJsonAsync(url, requestBody); - var result = await response.Content.ReadFromJsonAsync(); + var responseContent = await response.Content.ReadAsStringAsync(); + + _logger.LogInformation("发送服务号模板消息响应: {Response}", responseContent); + + if (string.IsNullOrWhiteSpace(responseContent)) + { + _logger.LogWarning("发送服务号模板消息失败: 微信返回空响应"); + return false; + } + + var result = System.Text.Json.JsonSerializer.Deserialize(responseContent); if (result?.ErrCode != 0) { From e34d90886dbcd38fc9b7596546402d5fdd283051 Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 20:43:26 +0800 Subject: [PATCH 08/20] feat(wechat): Enhance template message logging with status code and request body - Add HTTP status code to template message response logging for better debugging - Include serialized request body in logs to facilitate troubleshooting - Improve observability of WeChat service account template message operations --- server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs index ec39679..2bcfb33 100644 --- a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs +++ b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs @@ -454,7 +454,12 @@ public class WeChatService : IWeChatService var response = await _httpClient.PostAsJsonAsync(url, requestBody); var responseContent = await response.Content.ReadAsStringAsync(); - _logger.LogInformation("发送服务号模板消息响应: {Response}", responseContent); + _logger.LogInformation("发送服务号模板消息: StatusCode={StatusCode}, Response={Response}", + (int)response.StatusCode, responseContent); + + // 打印请求体方便排查 + var requestJson = System.Text.Json.JsonSerializer.Serialize(requestBody); + _logger.LogInformation("发送服务号模板消息请求体: {RequestBody}", requestJson); if (string.IsNullOrWhiteSpace(responseContent)) { From 9be72eb106de0832c1f913c10063507059e14316 Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 20:51:06 +0800 Subject: [PATCH 09/20] feat(wechat): Add access token refresh control and improve template message error handling - Add forceRefresh parameter to GetServiceAccountAccessTokenAsync for cache bypass capability - Implement automatic token refresh retry logic for HTTP 412 and token expiration error codes (40001, 42001) - Extract SendServiceAccountTemplateMessageAsync into internal method to support retry mechanism - Enhance logging with contextual parameters (AppId, ToUser, TemplateId, AccessToken prefix) for better diagnostics - Add conditional logging for failed requests with full diagnostic information - Improve error handling to distinguish between token expiration scenarios and other failures - Add informational logging when requesting new access tokens --- .../WeChat/IWeChatService.cs | 3 +- .../WeChat/WeChatService.cs | 62 ++++++++++++++----- 2 files changed, 49 insertions(+), 16 deletions(-) diff --git a/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs b/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs index bca96d2..3ff0642 100644 --- a/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs +++ b/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs @@ -67,8 +67,9 @@ public interface IWeChatService /// /// 获取服务号AccessToken /// + /// 是否强制刷新(忽略缓存) /// AccessToken - Task GetServiceAccountAccessTokenAsync(); + Task GetServiceAccountAccessTokenAsync(bool forceRefresh = false); /// /// 获取服务号关注用户信息(包含UnionId) diff --git a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs index 2bcfb33..88cfede 100644 --- a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs +++ b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs @@ -371,7 +371,7 @@ public class WeChatService : IWeChatService private const string ServiceAccountAccessTokenCacheKey = "wechat:service_account:access_token"; - public async Task GetServiceAccountAccessTokenAsync() + public async Task GetServiceAccountAccessTokenAsync(bool forceRefresh = false) { // 检查服务号配置 if (string.IsNullOrEmpty(_options.ServiceAccount?.AppId) || @@ -381,10 +381,13 @@ public class WeChatService : IWeChatService return null; } - // 先从缓存获取 - var cached = await _cache.GetStringAsync(ServiceAccountAccessTokenCacheKey); - if (!string.IsNullOrEmpty(cached)) - return cached; + // 先从缓存获取(非强制刷新时) + if (!forceRefresh) + { + var cached = await _cache.GetStringAsync(ServiceAccountAccessTokenCacheKey); + if (!string.IsNullOrEmpty(cached)) + return cached; + } // 请求新的AccessToken var url = $"https://api.weixin.qq.com/cgi-bin/token" + @@ -392,6 +395,8 @@ public class WeChatService : IWeChatService $"&appid={_options.ServiceAccount.AppId}" + $"&secret={_options.ServiceAccount.AppSecret}"; + _logger.LogInformation("请求服务号AccessToken: AppId={AppId}", _options.ServiceAccount.AppId); + var httpResponse = await _httpClient.GetAsync(url); var responseContent = await httpResponse.Content.ReadAsStringAsync(); var response = JsonSerializer.Deserialize(responseContent); @@ -402,6 +407,8 @@ public class WeChatService : IWeChatService return null; } + _logger.LogInformation("获取服务号AccessToken成功"); + // 缓存AccessToken,提前5分钟过期 var expireSeconds = response.ExpiresIn - 300; await _cache.SetStringAsync( @@ -416,13 +423,20 @@ public class WeChatService : IWeChatService } public async Task SendServiceAccountTemplateMessageAsync(ServiceAccountTemplateMessageRequest request) + { + return await SendServiceAccountTemplateMessageInternalAsync(request, isRetry: false); + } + + private async Task SendServiceAccountTemplateMessageInternalAsync( + ServiceAccountTemplateMessageRequest request, bool isRetry) { try { var accessToken = await GetServiceAccountAccessTokenAsync(); if (string.IsNullOrEmpty(accessToken)) { - _logger.LogError("获取服务号AccessToken失败"); + _logger.LogError("获取服务号AccessToken失败, AppId={AppId}, ToUser={ToUser}, TemplateId={TemplateId}", + _options.ServiceAccount?.AppId, request.ToUser, request.TemplateId); return false; } @@ -454,33 +468,51 @@ public class WeChatService : IWeChatService var response = await _httpClient.PostAsJsonAsync(url, requestBody); var responseContent = await response.Content.ReadAsStringAsync(); - _logger.LogInformation("发送服务号模板消息: StatusCode={StatusCode}, Response={Response}", - (int)response.StatusCode, responseContent); + // 非200时打印完整诊断信息 + if (!response.IsSuccessStatusCode || string.IsNullOrWhiteSpace(responseContent)) + { + _logger.LogWarning("发送服务号模板消息失败: StatusCode={StatusCode}, AppId={AppId}, ToUser={ToUser}, TemplateId={TemplateId}, AccessToken={AccessToken}, IsRetry={IsRetry}, Response={Response}", + (int)response.StatusCode, _options.ServiceAccount?.AppId, request.ToUser, request.TemplateId, + accessToken?[..Math.Min(accessToken.Length, 20)] + "...", isRetry, responseContent); + } - // 打印请求体方便排查 - var requestJson = System.Text.Json.JsonSerializer.Serialize(requestBody); - _logger.LogInformation("发送服务号模板消息请求体: {RequestBody}", requestJson); + // HTTP 412 表示 access_token 失效,强制刷新后重试一次 + if ((int)response.StatusCode == 412 && !isRetry) + { + _logger.LogWarning("服务号AccessToken可能已失效(412),强制刷新后重试"); + await _cache.RemoveAsync(ServiceAccountAccessTokenCacheKey); + return await SendServiceAccountTemplateMessageInternalAsync(request, isRetry: true); + } if (string.IsNullOrWhiteSpace(responseContent)) { - _logger.LogWarning("发送服务号模板消息失败: 微信返回空响应"); return false; } var result = System.Text.Json.JsonSerializer.Deserialize(responseContent); + // errcode 40001/42001 也是 token 失效,重试 + if (result != null && (result.ErrCode == 40001 || result.ErrCode == 42001) && !isRetry) + { + _logger.LogWarning("服务号AccessToken失效({ErrCode}),强制刷新后重试, AppId={AppId}", result.ErrCode, _options.ServiceAccount?.AppId); + await _cache.RemoveAsync(ServiceAccountAccessTokenCacheKey); + return await SendServiceAccountTemplateMessageInternalAsync(request, isRetry: true); + } + if (result?.ErrCode != 0) { - _logger.LogWarning("发送服务号模板消息失败: {ErrCode} - {ErrMsg}", result?.ErrCode, result?.ErrMsg); + _logger.LogWarning("发送服务号模板消息失败: ErrCode={ErrCode}, ErrMsg={ErrMsg}, AppId={AppId}, ToUser={ToUser}, TemplateId={TemplateId}", + result?.ErrCode, result?.ErrMsg, _options.ServiceAccount?.AppId, request.ToUser, request.TemplateId); return false; } - _logger.LogInformation("发送服务号模板消息成功: {ToUser}", request.ToUser); + _logger.LogInformation("发送服务号模板消息成功: ToUser={ToUser}, TemplateId={TemplateId}", request.ToUser, request.TemplateId); return true; } catch (Exception ex) { - _logger.LogError(ex, "发送服务号模板消息异常"); + _logger.LogError(ex, "发送服务号模板消息异常: AppId={AppId}, ToUser={ToUser}, TemplateId={TemplateId}", + _options.ServiceAccount?.AppId, request.ToUser, request.TemplateId); return false; } } From e5f398e43738a1a7dd0f4a5190ed56f67d73bd73 Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 20:59:23 +0800 Subject: [PATCH 10/20] feat(wechat): Add response headers logging to template message error diagnostics - Include response headers in diagnostic logging when template message send fails - Extract headers as formatted key-value pairs for better error visibility - Update log message to include Headers parameter alongside existing diagnostics - Improves troubleshooting capability by capturing full HTTP response metadata --- server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs index 88cfede..74b2eec 100644 --- a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs +++ b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs @@ -468,11 +468,12 @@ public class WeChatService : IWeChatService var response = await _httpClient.PostAsJsonAsync(url, requestBody); var responseContent = await response.Content.ReadAsStringAsync(); - // 非200时打印完整诊断信息 + // 非200时打印完整诊断信息,包括响应头 if (!response.IsSuccessStatusCode || string.IsNullOrWhiteSpace(responseContent)) { - _logger.LogWarning("发送服务号模板消息失败: StatusCode={StatusCode}, AppId={AppId}, ToUser={ToUser}, TemplateId={TemplateId}, AccessToken={AccessToken}, IsRetry={IsRetry}, Response={Response}", - (int)response.StatusCode, _options.ServiceAccount?.AppId, request.ToUser, request.TemplateId, + var headers = string.Join(", ", response.Headers.Select(h => $"{h.Key}={string.Join(";", h.Value)}")); + _logger.LogWarning("发送服务号模板消息失败: StatusCode={StatusCode}, Headers={Headers}, AppId={AppId}, ToUser={ToUser}, TemplateId={TemplateId}, AccessToken={AccessToken}, IsRetry={IsRetry}, Response={Response}", + (int)response.StatusCode, headers, _options.ServiceAccount?.AppId, request.ToUser, request.TemplateId, accessToken?[..Math.Min(accessToken.Length, 20)] + "...", isRetry, responseContent); } From df9a1d40c9243b8aca3dda0cd34d1039a7b1ab61 Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 21:01:35 +0800 Subject: [PATCH 11/20] feat(wechat): Enhance template message error logging with app secret and full access token - Add AppSecret to template message failure logging for better diagnostics - Include full access token in logs instead of truncated version for complete debugging context - Improve error visibility by logging complete credentials information when template message sending fails --- server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs index 74b2eec..5029449 100644 --- a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs +++ b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs @@ -472,9 +472,9 @@ public class WeChatService : IWeChatService if (!response.IsSuccessStatusCode || string.IsNullOrWhiteSpace(responseContent)) { var headers = string.Join(", ", response.Headers.Select(h => $"{h.Key}={string.Join(";", h.Value)}")); - _logger.LogWarning("发送服务号模板消息失败: StatusCode={StatusCode}, Headers={Headers}, AppId={AppId}, ToUser={ToUser}, TemplateId={TemplateId}, AccessToken={AccessToken}, IsRetry={IsRetry}, Response={Response}", - (int)response.StatusCode, headers, _options.ServiceAccount?.AppId, request.ToUser, request.TemplateId, - accessToken?[..Math.Min(accessToken.Length, 20)] + "...", isRetry, responseContent); + _logger.LogWarning("发送服务号模板消息失败: StatusCode={StatusCode}, Headers={Headers}, AppId={AppId}, AppSecret={AppSecret}, ToUser={ToUser}, TemplateId={TemplateId}, AccessToken={AccessToken}, IsRetry={IsRetry}, Response={Response}", + (int)response.StatusCode, headers, _options.ServiceAccount?.AppId, _options.ServiceAccount?.AppSecret, + request.ToUser, request.TemplateId, accessToken, isRetry, responseContent); } // HTTP 412 表示 access_token 失效,强制刷新后重试一次 From 125ff21b774589746fa0235023abab340c961d9f Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 21:09:26 +0800 Subject: [PATCH 12/20] feat(wechat): Enforce HTTP/1.1 protocol and improve template message request handling - Set HTTP client to use HTTP/1.1 with exact version policy for WeChat API compatibility - Replace PostAsJsonAsync with manual HttpRequestMessage construction for better control - Add User-Agent and Accept headers to template message requests - Use StringContent with explicit UTF-8 encoding for JSON serialization - Update error logging to include request body and remove sensitive app secret from logs - Improve diagnostic information for template message failures --- .../Extensions/InfrastructureExtensions.cs | 2 ++ .../WeChat/WeChatService.cs | 18 +++++++++++++----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/server/src/XiangYi.Infrastructure/Extensions/InfrastructureExtensions.cs b/server/src/XiangYi.Infrastructure/Extensions/InfrastructureExtensions.cs index aa1499b..98cd27a 100644 --- a/server/src/XiangYi.Infrastructure/Extensions/InfrastructureExtensions.cs +++ b/server/src/XiangYi.Infrastructure/Extensions/InfrastructureExtensions.cs @@ -24,6 +24,8 @@ public static class InfrastructureExtensions services.AddHttpClient("WeChat", client => { client.Timeout = TimeSpan.FromSeconds(30); + client.DefaultRequestVersion = new Version(1, 1); + client.DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact; }); services.AddHttpClient("WeChatPay", client => { diff --git a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs index 5029449..9ca5390 100644 --- a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs +++ b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs @@ -465,16 +465,24 @@ public class WeChatService : IWeChatService }; } - var response = await _httpClient.PostAsJsonAsync(url, requestBody); + var jsonContent = System.Text.Json.JsonSerializer.Serialize(requestBody); + var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(jsonContent, Encoding.UTF8, "application/json") + }; + httpRequest.Headers.TryAddWithoutValidation("User-Agent", "XiangYi/1.0"); + httpRequest.Headers.TryAddWithoutValidation("Accept", "application/json"); + + var response = await _httpClient.SendAsync(httpRequest); var responseContent = await response.Content.ReadAsStringAsync(); // 非200时打印完整诊断信息,包括响应头 if (!response.IsSuccessStatusCode || string.IsNullOrWhiteSpace(responseContent)) { - var headers = string.Join(", ", response.Headers.Select(h => $"{h.Key}={string.Join(";", h.Value)}")); - _logger.LogWarning("发送服务号模板消息失败: StatusCode={StatusCode}, Headers={Headers}, AppId={AppId}, AppSecret={AppSecret}, ToUser={ToUser}, TemplateId={TemplateId}, AccessToken={AccessToken}, IsRetry={IsRetry}, Response={Response}", - (int)response.StatusCode, headers, _options.ServiceAccount?.AppId, _options.ServiceAccount?.AppSecret, - request.ToUser, request.TemplateId, accessToken, isRetry, responseContent); + var respHeaders = string.Join(", ", response.Headers.Select(h => $"{h.Key}={string.Join(";", h.Value)}")); + _logger.LogWarning("发送服务号模板消息失败: StatusCode={StatusCode}, Headers={Headers}, AppId={AppId}, ToUser={ToUser}, TemplateId={TemplateId}, AccessToken={AccessToken}, IsRetry={IsRetry}, RequestBody={RequestBody}, Response={Response}", + (int)response.StatusCode, respHeaders, _options.ServiceAccount?.AppId, + request.ToUser, request.TemplateId, accessToken, isRetry, jsonContent, responseContent); } // HTTP 412 表示 access_token 失效,强制刷新后重试一次 From 3764b6ef5720e184f4db62504a77134068e70eb7 Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 21:22:01 +0800 Subject: [PATCH 13/20] feat(wechat): Add template field mapping configuration for notification templates - Add field mapping properties to NotificationTemplatesConfig interface for unlock, favorite, message, and daily recommend templates - Add UI form fields in system config view for configuring JSON-based field mappings with helper text - Implement dynamic template data construction using configured field mappings in NotificationService - Add fallback to traditional first/keyword format when field mapping is not configured - Support flexible field mapping with value types: title, name, content, time, remark - Update SystemConfigService to persist and retrieve field mapping configurations - Enable backend-driven template field customization without code changes --- admin/src/api/config.ts | 4 + admin/src/views/system/config.vue | 52 ++++++++++++- .../Services/NotificationService.cs | 77 +++++++++++++++++-- .../Services/SystemConfigService.cs | 38 ++++++++- 4 files changed, 160 insertions(+), 11 deletions(-) diff --git a/admin/src/api/config.ts b/admin/src/api/config.ts index 0c09497..c7abbd1 100644 --- a/admin/src/api/config.ts +++ b/admin/src/api/config.ts @@ -192,9 +192,13 @@ export interface NotificationTemplatesConfig { token?: string encodingAESKey?: string unlockTemplateId?: string + unlockFieldMapping?: string favoriteTemplateId?: string + favoriteFieldMapping?: string messageTemplateId?: string + messageFieldMapping?: string dailyRecommendTemplateId?: string + dailyRecommendFieldMapping?: string } /** diff --git a/admin/src/views/system/config.vue b/admin/src/views/system/config.vue index b7abdfe..663b2db 100644 --- a/admin/src/views/system/config.vue +++ b/admin/src/views/system/config.vue @@ -340,6 +340,15 @@
有用户解锁我时,服务号发送相应通知
+ + +
JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, remark=备注
+
+ 有用户收藏我时,服务号发送相应通知 + + +
JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, remark=备注
+
+ 首次沟通通知、5分钟未回复提醒共用此模板 + + +
JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, remark=备注
+
+ 每天早上8~10点随机时间发送推荐更新通知 + + +
JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, remark=备注
+
+ 保存模板配置 @@ -438,9 +474,13 @@ const templateForm = ref({ token: '', encodingAESKey: '', unlockTemplateId: '', + unlockFieldMapping: '', favoriteTemplateId: '', + favoriteFieldMapping: '', messageTemplateId: '', - dailyRecommendTemplateId: '' + messageFieldMapping: '', + dailyRecommendTemplateId: '', + dailyRecommendFieldMapping: '' }) const saving = ref(false) @@ -718,9 +758,13 @@ const loadNotificationTemplates = async () => { templateForm.value.token = res.token || '' templateForm.value.encodingAESKey = res.encodingAESKey || '' templateForm.value.unlockTemplateId = res.unlockTemplateId || '' + templateForm.value.unlockFieldMapping = res.unlockFieldMapping || '' templateForm.value.favoriteTemplateId = res.favoriteTemplateId || '' + templateForm.value.favoriteFieldMapping = res.favoriteFieldMapping || '' templateForm.value.messageTemplateId = res.messageTemplateId || '' + templateForm.value.messageFieldMapping = res.messageFieldMapping || '' templateForm.value.dailyRecommendTemplateId = res.dailyRecommendTemplateId || '' + templateForm.value.dailyRecommendFieldMapping = res.dailyRecommendFieldMapping || '' } } catch (error) { console.error('加载模板配置失败:', error) @@ -734,9 +778,13 @@ const saveNotificationTemplates = async () => { token: templateForm.value.token || undefined, encodingAESKey: templateForm.value.encodingAESKey || undefined, unlockTemplateId: templateForm.value.unlockTemplateId || undefined, + unlockFieldMapping: templateForm.value.unlockFieldMapping || undefined, favoriteTemplateId: templateForm.value.favoriteTemplateId || undefined, + favoriteFieldMapping: templateForm.value.favoriteFieldMapping || undefined, messageTemplateId: templateForm.value.messageTemplateId || undefined, - dailyRecommendTemplateId: templateForm.value.dailyRecommendTemplateId || undefined + messageFieldMapping: templateForm.value.messageFieldMapping || undefined, + dailyRecommendTemplateId: templateForm.value.dailyRecommendTemplateId || undefined, + dailyRecommendFieldMapping: templateForm.value.dailyRecommendFieldMapping || undefined }) ElMessage.success('模板配置保存成功') } catch (error) { diff --git a/server/src/XiangYi.Application/Services/NotificationService.cs b/server/src/XiangYi.Application/Services/NotificationService.cs index 753487c..31b6135 100644 --- a/server/src/XiangYi.Application/Services/NotificationService.cs +++ b/server/src/XiangYi.Application/Services/NotificationService.cs @@ -544,6 +544,43 @@ public class NotificationService : INotificationService return false; } + // 从数据库读取字段映射 + var fieldMapping = await GetServiceAccountFieldMappingAsync(templateType); + Dictionary data; + + if (fieldMapping != null && fieldMapping.Count > 0) + { + // 使用后台配置的字段映射动态构建 + // 映射格式: {"thing1":"title","thing2":"name","time3":"time","thing4":"content","thing5":"remark"} + // 值映射: title->标题, name->名称, content->内容, time->时间, remark->备注 + data = new Dictionary(); + foreach (var (fieldName, valueKey) in fieldMapping) + { + var value = valueKey?.ToLower() switch + { + "title" => title, + "name" => name, + "content" => content, + "time" => time, + "remark" => "点击查看详情", + _ => valueKey ?? "" + }; + data[fieldName] = new TemplateDataItem { Value = value }; + } + } + else + { + // 兜底:使用传统的 first/keyword 格式 + data = new Dictionary + { + ["first"] = new TemplateDataItem { Value = title }, + ["keyword1"] = new TemplateDataItem { Value = name }, + ["keyword2"] = new TemplateDataItem { Value = content }, + ["keyword3"] = new TemplateDataItem { Value = time }, + ["remark"] = new TemplateDataItem { Value = "点击查看详情" } + }; + } + var request = new ServiceAccountTemplateMessageRequest { ToUser = serviceAccountOpenId, @@ -553,14 +590,7 @@ public class NotificationService : INotificationService AppId = _weChatOptions.MiniProgram.AppId, PagePath = page }, - Data = new Dictionary - { - ["first"] = new TemplateDataItem { Value = title }, - ["keyword1"] = new TemplateDataItem { Value = name }, - ["keyword2"] = new TemplateDataItem { Value = content }, - ["keyword3"] = new TemplateDataItem { Value = time }, - ["remark"] = new TemplateDataItem { Value = "点击查看详情" } - } + Data = data }; var success = await _weChatService.SendServiceAccountTemplateMessageAsync(request); @@ -576,6 +606,37 @@ public class NotificationService : INotificationService } } + /// + /// 获取服务号模板字段映射 + /// + private async Task?> GetServiceAccountFieldMappingAsync(NotificationTemplateType templateType) + { + string? configKey = templateType switch + { + NotificationTemplateType.Unlock => SystemConfigService.SaUnlockFieldMappingKey, + NotificationTemplateType.Favorite => SystemConfigService.SaFavoriteFieldMappingKey, + NotificationTemplateType.FirstMessage => SystemConfigService.SaMessageFieldMappingKey, + NotificationTemplateType.MessageReminder => SystemConfigService.SaMessageFieldMappingKey, + NotificationTemplateType.DailyRecommend => SystemConfigService.SaDailyRecommendFieldMappingKey, + _ => null + }; + + if (configKey == null) return null; + + var json = await _configService.GetConfigValueAsync(configKey); + if (string.IsNullOrEmpty(json)) return null; + + try + { + return System.Text.Json.JsonSerializer.Deserialize>(json); + } + catch + { + _logger.LogWarning("解析字段映射失败: Key={Key}, Value={Value}", configKey, json); + return null; + } + } + #endregion diff --git a/server/src/XiangYi.Application/Services/SystemConfigService.cs b/server/src/XiangYi.Application/Services/SystemConfigService.cs index 888ea75..b468092 100644 --- a/server/src/XiangYi.Application/Services/SystemConfigService.cs +++ b/server/src/XiangYi.Application/Services/SystemConfigService.cs @@ -101,9 +101,13 @@ public class SystemConfigService : ISystemConfigService public const string SaTokenKey = "sa_token"; public const string SaEncodingAesKeyKey = "sa_encoding_aes_key"; public const string SaUnlockTemplateIdKey = "sa_unlock_template_id"; + public const string SaUnlockFieldMappingKey = "sa_unlock_field_mapping"; public const string SaFavoriteTemplateIdKey = "sa_favorite_template_id"; + public const string SaFavoriteFieldMappingKey = "sa_favorite_field_mapping"; public const string SaMessageTemplateIdKey = "sa_message_template_id"; + public const string SaMessageFieldMappingKey = "sa_message_field_mapping"; public const string SaDailyRecommendTemplateIdKey = "sa_daily_recommend_template_id"; + public const string SaDailyRecommendFieldMappingKey = "sa_daily_recommend_field_mapping"; public SystemConfigService( IRepository configRepository, @@ -360,9 +364,13 @@ public class SystemConfigService : ISystemConfigService Token = await GetConfigValueAsync(SaTokenKey), EncodingAESKey = await GetConfigValueAsync(SaEncodingAesKeyKey), UnlockTemplateId = await GetConfigValueAsync(SaUnlockTemplateIdKey), + UnlockFieldMapping = await GetConfigValueAsync(SaUnlockFieldMappingKey), FavoriteTemplateId = await GetConfigValueAsync(SaFavoriteTemplateIdKey), + FavoriteFieldMapping = await GetConfigValueAsync(SaFavoriteFieldMappingKey), MessageTemplateId = await GetConfigValueAsync(SaMessageTemplateIdKey), - DailyRecommendTemplateId = await GetConfigValueAsync(SaDailyRecommendTemplateIdKey) + MessageFieldMapping = await GetConfigValueAsync(SaMessageFieldMappingKey), + DailyRecommendTemplateId = await GetConfigValueAsync(SaDailyRecommendTemplateIdKey), + DailyRecommendFieldMapping = await GetConfigValueAsync(SaDailyRecommendFieldMappingKey) }; } @@ -377,12 +385,20 @@ public class SystemConfigService : ISystemConfigService await SetConfigValueAsync(SaEncodingAesKeyKey, templates.EncodingAESKey, "服务号消息加解密密钥"); if (!string.IsNullOrEmpty(templates.UnlockTemplateId)) await SetConfigValueAsync(SaUnlockTemplateIdKey, templates.UnlockTemplateId, "服务号解锁通知模板ID"); + if (!string.IsNullOrEmpty(templates.UnlockFieldMapping)) + await SetConfigValueAsync(SaUnlockFieldMappingKey, templates.UnlockFieldMapping, "服务号解锁通知字段映射"); if (!string.IsNullOrEmpty(templates.FavoriteTemplateId)) await SetConfigValueAsync(SaFavoriteTemplateIdKey, templates.FavoriteTemplateId, "服务号收藏通知模板ID"); + if (!string.IsNullOrEmpty(templates.FavoriteFieldMapping)) + await SetConfigValueAsync(SaFavoriteFieldMappingKey, templates.FavoriteFieldMapping, "服务号收藏通知字段映射"); if (!string.IsNullOrEmpty(templates.MessageTemplateId)) await SetConfigValueAsync(SaMessageTemplateIdKey, templates.MessageTemplateId, "服务号消息通知模板ID"); + if (!string.IsNullOrEmpty(templates.MessageFieldMapping)) + await SetConfigValueAsync(SaMessageFieldMappingKey, templates.MessageFieldMapping, "服务号消息通知字段映射"); if (!string.IsNullOrEmpty(templates.DailyRecommendTemplateId)) await SetConfigValueAsync(SaDailyRecommendTemplateIdKey, templates.DailyRecommendTemplateId, "服务号每日推荐通知模板ID"); + if (!string.IsNullOrEmpty(templates.DailyRecommendFieldMapping)) + await SetConfigValueAsync(SaDailyRecommendFieldMappingKey, templates.DailyRecommendFieldMapping, "服务号每日推荐通知字段映射"); return true; } catch (Exception ex) @@ -439,18 +455,38 @@ public class NotificationTemplatesDto ///
public string? UnlockTemplateId { get; set; } + /// + /// 解锁通知字段映射JSON,如 {"thing1":"title","thing2":"name","time3":"time"} + /// + public string? UnlockFieldMapping { get; set; } + /// /// 收藏通知模板ID /// public string? FavoriteTemplateId { get; set; } + /// + /// 收藏通知字段映射JSON + /// + public string? FavoriteFieldMapping { get; set; } + /// /// 消息通知模板ID(首次消息/未回复提醒共用) /// public string? MessageTemplateId { get; set; } + /// + /// 消息通知字段映射JSON + /// + public string? MessageFieldMapping { get; set; } + /// /// 每日推荐通知模板ID /// public string? DailyRecommendTemplateId { get; set; } + + /// + /// 每日推荐通知字段映射JSON + /// + public string? DailyRecommendFieldMapping { get; set; } } From b1daa6c6c8169d4c01747ef64e6b0c8dea5e0851 Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 21:36:21 +0800 Subject: [PATCH 14/20] 21 --- .../Services/NotificationService.cs | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/server/src/XiangYi.Application/Services/NotificationService.cs b/server/src/XiangYi.Application/Services/NotificationService.cs index 31b6135..5a6755e 100644 --- a/server/src/XiangYi.Application/Services/NotificationService.cs +++ b/server/src/XiangYi.Application/Services/NotificationService.cs @@ -546,13 +546,13 @@ public class NotificationService : INotificationService // 从数据库读取字段映射 var fieldMapping = await GetServiceAccountFieldMappingAsync(templateType); + _logger.LogInformation("服务号字段映射: TemplateType={TemplateType}, FieldMapping={FieldMapping}", + templateType, fieldMapping != null ? System.Text.Json.JsonSerializer.Serialize(fieldMapping) : "null"); Dictionary data; if (fieldMapping != null && fieldMapping.Count > 0) { // 使用后台配置的字段映射动态构建 - // 映射格式: {"thing1":"title","thing2":"name","time3":"time","thing4":"content","thing5":"remark"} - // 值映射: title->标题, name->名称, content->内容, time->时间, remark->备注 data = new Dictionary(); foreach (var (fieldName, valueKey) in fieldMapping) { @@ -570,15 +570,8 @@ public class NotificationService : INotificationService } else { - // 兜底:使用传统的 first/keyword 格式 - data = new Dictionary - { - ["first"] = new TemplateDataItem { Value = title }, - ["keyword1"] = new TemplateDataItem { Value = name }, - ["keyword2"] = new TemplateDataItem { Value = content }, - ["keyword3"] = new TemplateDataItem { Value = time }, - ["remark"] = new TemplateDataItem { Value = "点击查看详情" } - }; + // 兜底:根据通知类型使用默认字段映射 + data = BuildDefaultTemplateData(templateType, title, name, content, time); } var request = new ServiceAccountTemplateMessageRequest From bcff4b759c45ae93a015f7c6c73ed1f6fde233a8 Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 21:39:20 +0800 Subject: [PATCH 15/20] feat(wechat): Add default template data builder for notification templates - Add BuildDefaultTemplateData method to construct fallback template data - Map notification fields to WeChat template placeholders (first, keyword1-3, remark) - Provide default template structure when database field mapping is not configured - Ensures template messages can be sent with sensible defaults as safety net --- .../Services/NotificationService.cs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/server/src/XiangYi.Application/Services/NotificationService.cs b/server/src/XiangYi.Application/Services/NotificationService.cs index 5a6755e..add63b1 100644 --- a/server/src/XiangYi.Application/Services/NotificationService.cs +++ b/server/src/XiangYi.Application/Services/NotificationService.cs @@ -630,6 +630,22 @@ public class NotificationService : INotificationService } } + /// + /// 构建默认模板数据(兜底,数据库未配置字段映射时使用) + /// + private static Dictionary BuildDefaultTemplateData( + NotificationTemplateType templateType, string title, string name, string content, string time) + { + return new Dictionary + { + ["first"] = new TemplateDataItem { Value = title }, + ["keyword1"] = new TemplateDataItem { Value = name }, + ["keyword2"] = new TemplateDataItem { Value = content }, + ["keyword3"] = new TemplateDataItem { Value = time }, + ["remark"] = new TemplateDataItem { Value = "点击查看详情" } + }; + } + #endregion From 5db16f9a3081f98e243711c2fd58f6d5812f4917 Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 21:46:40 +0800 Subject: [PATCH 16/20] 2121 --- server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs | 1 + server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs | 3 +++ 2 files changed, 4 insertions(+) diff --git a/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs b/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs index 3ff0642..aa5c139 100644 --- a/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs +++ b/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs @@ -286,6 +286,7 @@ public class SubscribeMessageRequest /// public class TemplateDataItem { + [System.Text.Json.Serialization.JsonPropertyName("value")] public string Value { get; set; } = string.Empty; } diff --git a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs index 9ca5390..f8c5325 100644 --- a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs +++ b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs @@ -466,6 +466,9 @@ public class WeChatService : IWeChatService } var jsonContent = System.Text.Json.JsonSerializer.Serialize(requestBody); + _logger.LogInformation("发送服务号模板消息请求: ToUser={ToUser}, TemplateId={TemplateId}, Body={Body}", + request.ToUser, request.TemplateId, jsonContent); + var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) { Content = new StringContent(jsonContent, Encoding.UTF8, "application/json") From 55d36ffd8eea0d34cbc9ee8f7c23ca73f23d9bec Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 21:58:59 +0800 Subject: [PATCH 17/20] 21 --- .../Services/NotificationService.cs | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/server/src/XiangYi.Application/Services/NotificationService.cs b/server/src/XiangYi.Application/Services/NotificationService.cs index add63b1..f57e1da 100644 --- a/server/src/XiangYi.Application/Services/NotificationService.cs +++ b/server/src/XiangYi.Application/Services/NotificationService.cs @@ -254,7 +254,8 @@ public class NotificationService : INotificationService unlockerUser?.Nickname ?? "有人", $"编号{unlockerUser?.XiangQinNo ?? "未知"}刚刚访问了您", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), - "pages/interact/unlockedMe"); + "pages/interact/unlockedMe", + unlockerUser?.Phone); } // 回退到小程序订阅消息 @@ -313,7 +314,8 @@ public class NotificationService : INotificationService favoriterUser?.Nickname ?? "有人", $"编号{favoriterUser?.XiangQinNo ?? "未知"}收藏了您", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), - "pages/interact/favoritedMe"); + "pages/interact/favoritedMe", + favoriterUser?.Phone); } // 回退到小程序订阅消息 @@ -372,7 +374,8 @@ public class NotificationService : INotificationService senderUser?.Nickname ?? "有人", $"编号{senderUser?.XiangQinNo ?? "未知"}给您发送了一条消息", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), - "pages/chat/index"); + "pages/chat/index", + senderUser?.Phone); } // 回退到小程序订阅消息 @@ -431,7 +434,8 @@ public class NotificationService : INotificationService senderUser?.Nickname ?? "有人", $"编号{senderUser?.XiangQinNo ?? "未知"}正在等待您的回复", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), - "pages/chat/index"); + "pages/chat/index", + senderUser?.Phone); } // 回退到小程序订阅消息 @@ -533,7 +537,8 @@ public class NotificationService : INotificationService string name, string content, string time, - string page) + string page, + string? phone = null) { try { @@ -562,6 +567,7 @@ public class NotificationService : INotificationService "name" => name, "content" => content, "time" => time, + "phone" => phone ?? "", "remark" => "点击查看详情", _ => valueKey ?? "" }; From e9bdf2623aee5d93ace9be71285b4450863cbe68 Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 22:03:15 +0800 Subject: [PATCH 18/20] 123 --- admin/src/api/config.ts | 4 ++ admin/src/views/system/config.vue | 64 +++++++++++++++++-- .../Services/NotificationService.cs | 26 ++++++++ .../Services/SystemConfigService.cs | 40 +++++++++++- 4 files changed, 126 insertions(+), 8 deletions(-) diff --git a/admin/src/api/config.ts b/admin/src/api/config.ts index c7abbd1..28cb7ad 100644 --- a/admin/src/api/config.ts +++ b/admin/src/api/config.ts @@ -193,12 +193,16 @@ export interface NotificationTemplatesConfig { encodingAESKey?: string unlockTemplateId?: string unlockFieldMapping?: string + unlockPage?: string favoriteTemplateId?: string favoriteFieldMapping?: string + favoritePage?: string messageTemplateId?: string messageFieldMapping?: string + messagePage?: string dailyRecommendTemplateId?: string dailyRecommendFieldMapping?: string + dailyRecommendPage?: string } /** diff --git a/admin/src/views/system/config.vue b/admin/src/views/system/config.vue index 663b2db..44a9fea 100644 --- a/admin/src/views/system/config.vue +++ b/admin/src/views/system/config.vue @@ -346,7 +346,17 @@ placeholder='例: {"thing1":"title","thing2":"name","time3":"time"}' clearable /> -
JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, remark=备注
+
JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, phone=手机号, remark=备注
+
+ + + + + + + + + @@ -364,7 +374,17 @@ placeholder='例: {"thing1":"title","thing2":"name","time3":"time"}' clearable /> -
JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, remark=备注
+
JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, phone=手机号, remark=备注
+
+ + + + + + + + + @@ -382,7 +402,17 @@ placeholder='例: {"thing1":"title","thing2":"name","time3":"time"}' clearable /> -
JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, remark=备注
+
JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, phone=手机号, remark=备注
+
+ + + + + + + + + @@ -400,7 +430,17 @@ placeholder='例: {"thing1":"title","thing2":"content","time3":"time"}' clearable /> -
JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, remark=备注
+
JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, phone=手机号, remark=备注
+
+ + + + + + + + + @@ -475,12 +515,16 @@ const templateForm = ref({ encodingAESKey: '', unlockTemplateId: '', unlockFieldMapping: '', + unlockPage: '', favoriteTemplateId: '', favoriteFieldMapping: '', + favoritePage: '', messageTemplateId: '', messageFieldMapping: '', + messagePage: '', dailyRecommendTemplateId: '', - dailyRecommendFieldMapping: '' + dailyRecommendFieldMapping: '', + dailyRecommendPage: '' }) const saving = ref(false) @@ -759,12 +803,16 @@ const loadNotificationTemplates = async () => { templateForm.value.encodingAESKey = res.encodingAESKey || '' templateForm.value.unlockTemplateId = res.unlockTemplateId || '' templateForm.value.unlockFieldMapping = res.unlockFieldMapping || '' + templateForm.value.unlockPage = res.unlockPage || '' templateForm.value.favoriteTemplateId = res.favoriteTemplateId || '' templateForm.value.favoriteFieldMapping = res.favoriteFieldMapping || '' + templateForm.value.favoritePage = res.favoritePage || '' templateForm.value.messageTemplateId = res.messageTemplateId || '' templateForm.value.messageFieldMapping = res.messageFieldMapping || '' + templateForm.value.messagePage = res.messagePage || '' templateForm.value.dailyRecommendTemplateId = res.dailyRecommendTemplateId || '' templateForm.value.dailyRecommendFieldMapping = res.dailyRecommendFieldMapping || '' + templateForm.value.dailyRecommendPage = res.dailyRecommendPage || '' } } catch (error) { console.error('加载模板配置失败:', error) @@ -779,12 +827,16 @@ const saveNotificationTemplates = async () => { encodingAESKey: templateForm.value.encodingAESKey || undefined, unlockTemplateId: templateForm.value.unlockTemplateId || undefined, unlockFieldMapping: templateForm.value.unlockFieldMapping || undefined, + unlockPage: templateForm.value.unlockPage || undefined, favoriteTemplateId: templateForm.value.favoriteTemplateId || undefined, favoriteFieldMapping: templateForm.value.favoriteFieldMapping || undefined, + favoritePage: templateForm.value.favoritePage || undefined, messageTemplateId: templateForm.value.messageTemplateId || undefined, messageFieldMapping: templateForm.value.messageFieldMapping || undefined, + messagePage: templateForm.value.messagePage || undefined, dailyRecommendTemplateId: templateForm.value.dailyRecommendTemplateId || undefined, - dailyRecommendFieldMapping: templateForm.value.dailyRecommendFieldMapping || undefined + dailyRecommendFieldMapping: templateForm.value.dailyRecommendFieldMapping || undefined, + dailyRecommendPage: templateForm.value.dailyRecommendPage || undefined }) ElMessage.success('模板配置保存成功') } catch (error) { diff --git a/server/src/XiangYi.Application/Services/NotificationService.cs b/server/src/XiangYi.Application/Services/NotificationService.cs index f57e1da..78119db 100644 --- a/server/src/XiangYi.Application/Services/NotificationService.cs +++ b/server/src/XiangYi.Application/Services/NotificationService.cs @@ -549,6 +549,13 @@ public class NotificationService : INotificationService return false; } + // 从数据库读取跳转页面配置,有配置则覆盖默认值 + var configuredPage = await GetServiceAccountPageAsync(templateType); + if (!string.IsNullOrEmpty(configuredPage)) + { + page = configuredPage; + } + // 从数据库读取字段映射 var fieldMapping = await GetServiceAccountFieldMappingAsync(templateType); _logger.LogInformation("服务号字段映射: TemplateType={TemplateType}, FieldMapping={FieldMapping}", @@ -636,6 +643,25 @@ public class NotificationService : INotificationService } } + /// + /// 获取服务号通知跳转页面配置 + /// + private async Task GetServiceAccountPageAsync(NotificationTemplateType templateType) + { + string? configKey = templateType switch + { + NotificationTemplateType.Unlock => SystemConfigService.SaUnlockPageKey, + NotificationTemplateType.Favorite => SystemConfigService.SaFavoritePageKey, + NotificationTemplateType.FirstMessage => SystemConfigService.SaMessagePageKey, + NotificationTemplateType.MessageReminder => SystemConfigService.SaMessagePageKey, + NotificationTemplateType.DailyRecommend => SystemConfigService.SaDailyRecommendPageKey, + _ => null + }; + + if (configKey == null) return null; + return await _configService.GetConfigValueAsync(configKey); + } + /// /// 构建默认模板数据(兜底,数据库未配置字段映射时使用) /// diff --git a/server/src/XiangYi.Application/Services/SystemConfigService.cs b/server/src/XiangYi.Application/Services/SystemConfigService.cs index b468092..86762fc 100644 --- a/server/src/XiangYi.Application/Services/SystemConfigService.cs +++ b/server/src/XiangYi.Application/Services/SystemConfigService.cs @@ -102,12 +102,16 @@ public class SystemConfigService : ISystemConfigService public const string SaEncodingAesKeyKey = "sa_encoding_aes_key"; public const string SaUnlockTemplateIdKey = "sa_unlock_template_id"; public const string SaUnlockFieldMappingKey = "sa_unlock_field_mapping"; + public const string SaUnlockPageKey = "sa_unlock_page"; public const string SaFavoriteTemplateIdKey = "sa_favorite_template_id"; public const string SaFavoriteFieldMappingKey = "sa_favorite_field_mapping"; + public const string SaFavoritePageKey = "sa_favorite_page"; public const string SaMessageTemplateIdKey = "sa_message_template_id"; public const string SaMessageFieldMappingKey = "sa_message_field_mapping"; + public const string SaMessagePageKey = "sa_message_page"; public const string SaDailyRecommendTemplateIdKey = "sa_daily_recommend_template_id"; public const string SaDailyRecommendFieldMappingKey = "sa_daily_recommend_field_mapping"; + public const string SaDailyRecommendPageKey = "sa_daily_recommend_page"; public SystemConfigService( IRepository configRepository, @@ -365,12 +369,16 @@ public class SystemConfigService : ISystemConfigService EncodingAESKey = await GetConfigValueAsync(SaEncodingAesKeyKey), UnlockTemplateId = await GetConfigValueAsync(SaUnlockTemplateIdKey), UnlockFieldMapping = await GetConfigValueAsync(SaUnlockFieldMappingKey), + UnlockPage = await GetConfigValueAsync(SaUnlockPageKey), FavoriteTemplateId = await GetConfigValueAsync(SaFavoriteTemplateIdKey), FavoriteFieldMapping = await GetConfigValueAsync(SaFavoriteFieldMappingKey), + FavoritePage = await GetConfigValueAsync(SaFavoritePageKey), MessageTemplateId = await GetConfigValueAsync(SaMessageTemplateIdKey), MessageFieldMapping = await GetConfigValueAsync(SaMessageFieldMappingKey), + MessagePage = await GetConfigValueAsync(SaMessagePageKey), DailyRecommendTemplateId = await GetConfigValueAsync(SaDailyRecommendTemplateIdKey), - DailyRecommendFieldMapping = await GetConfigValueAsync(SaDailyRecommendFieldMappingKey) + DailyRecommendFieldMapping = await GetConfigValueAsync(SaDailyRecommendFieldMappingKey), + DailyRecommendPage = await GetConfigValueAsync(SaDailyRecommendPageKey) }; } @@ -387,18 +395,26 @@ public class SystemConfigService : ISystemConfigService await SetConfigValueAsync(SaUnlockTemplateIdKey, templates.UnlockTemplateId, "服务号解锁通知模板ID"); if (!string.IsNullOrEmpty(templates.UnlockFieldMapping)) await SetConfigValueAsync(SaUnlockFieldMappingKey, templates.UnlockFieldMapping, "服务号解锁通知字段映射"); + if (!string.IsNullOrEmpty(templates.UnlockPage)) + await SetConfigValueAsync(SaUnlockPageKey, templates.UnlockPage, "服务号解锁通知跳转页面"); if (!string.IsNullOrEmpty(templates.FavoriteTemplateId)) await SetConfigValueAsync(SaFavoriteTemplateIdKey, templates.FavoriteTemplateId, "服务号收藏通知模板ID"); if (!string.IsNullOrEmpty(templates.FavoriteFieldMapping)) await SetConfigValueAsync(SaFavoriteFieldMappingKey, templates.FavoriteFieldMapping, "服务号收藏通知字段映射"); + if (!string.IsNullOrEmpty(templates.FavoritePage)) + await SetConfigValueAsync(SaFavoritePageKey, templates.FavoritePage, "服务号收藏通知跳转页面"); if (!string.IsNullOrEmpty(templates.MessageTemplateId)) await SetConfigValueAsync(SaMessageTemplateIdKey, templates.MessageTemplateId, "服务号消息通知模板ID"); if (!string.IsNullOrEmpty(templates.MessageFieldMapping)) await SetConfigValueAsync(SaMessageFieldMappingKey, templates.MessageFieldMapping, "服务号消息通知字段映射"); + if (!string.IsNullOrEmpty(templates.MessagePage)) + await SetConfigValueAsync(SaMessagePageKey, templates.MessagePage, "服务号消息通知跳转页面"); if (!string.IsNullOrEmpty(templates.DailyRecommendTemplateId)) await SetConfigValueAsync(SaDailyRecommendTemplateIdKey, templates.DailyRecommendTemplateId, "服务号每日推荐通知模板ID"); if (!string.IsNullOrEmpty(templates.DailyRecommendFieldMapping)) await SetConfigValueAsync(SaDailyRecommendFieldMappingKey, templates.DailyRecommendFieldMapping, "服务号每日推荐通知字段映射"); + if (!string.IsNullOrEmpty(templates.DailyRecommendPage)) + await SetConfigValueAsync(SaDailyRecommendPageKey, templates.DailyRecommendPage, "服务号每日推荐通知跳转页面"); return true; } catch (Exception ex) @@ -456,10 +472,15 @@ public class NotificationTemplatesDto public string? UnlockTemplateId { get; set; } /// - /// 解锁通知字段映射JSON,如 {"thing1":"title","thing2":"name","time3":"time"} + /// 解锁通知字段映射JSON /// public string? UnlockFieldMapping { get; set; } + /// + /// 解锁通知跳转页面 + /// + public string? UnlockPage { get; set; } + /// /// 收藏通知模板ID /// @@ -470,6 +491,11 @@ public class NotificationTemplatesDto /// public string? FavoriteFieldMapping { get; set; } + /// + /// 收藏通知跳转页面 + /// + public string? FavoritePage { get; set; } + /// /// 消息通知模板ID(首次消息/未回复提醒共用) /// @@ -480,6 +506,11 @@ public class NotificationTemplatesDto /// public string? MessageFieldMapping { get; set; } + /// + /// 消息通知跳转页面 + /// + public string? MessagePage { get; set; } + /// /// 每日推荐通知模板ID /// @@ -489,4 +520,9 @@ public class NotificationTemplatesDto /// 每日推荐通知字段映射JSON /// public string? DailyRecommendFieldMapping { get; set; } + + /// + /// 每日推荐通知跳转页面 + /// + public string? DailyRecommendPage { get; set; } } From b63139b5ae346a9746577224b1f753bacabdd3be Mon Sep 17 00:00:00 2001 From: zpc Date: Sun, 29 Mar 2026 22:05:51 +0800 Subject: [PATCH 19/20] 21 --- .drone.yml | 2 ++ admin/Dockerfile | 10 +++++----- admin/package.json | 3 ++- admin/vite.config.ts | 1 + 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.drone.yml b/.drone.yml index 299a2f8..79587da 100644 --- a/.drone.yml +++ b/.drone.yml @@ -52,6 +52,8 @@ steps: tags: - latest - ${DRONE_COMMIT_SHA:0:8} + cache_from: + - 192.168.195.25:19900/xiangyixiangqin/admin-web:latest username: from_secret: harbor_username password: diff --git a/admin/Dockerfile b/admin/Dockerfile index 07c1b55..b7c18a0 100644 --- a/admin/Dockerfile +++ b/admin/Dockerfile @@ -3,13 +3,13 @@ FROM 192.168.195.25:19900/library/node:20-alpine AS build WORKDIR /app -# 复制 package 文件 -COPY package*.json ./ +# 先复制依赖文件,利用 Docker 层缓存 +COPY package.json package-lock.json ./ -# 删除 lock 文件并重新安装 -RUN rm -f package-lock.json && npm install +# 设置淘宝镜像源加速下载,使用 npm ci 严格按 lock 文件安装 +RUN npm config set registry https://registry.npmmirror.com && npm ci -# 复制源代码 +# 再复制源代码(依赖没变时上面的层会命中缓存) COPY . . # 构建应用 diff --git a/admin/package.json b/admin/package.json index 5af7cb9..a2cd063 100644 --- a/admin/package.json +++ b/admin/package.json @@ -5,7 +5,8 @@ "type": "module", "scripts": { "dev": "vite", - "build": "vue-tsc -b && vite build", + "build": "vite build", + "build:check": "vue-tsc -b && vite build", "preview": "vite preview", "lint": "eslint . --fix", "format": "prettier --write src/", diff --git a/admin/vite.config.ts b/admin/vite.config.ts index 087ed7f..677455b 100644 --- a/admin/vite.config.ts +++ b/admin/vite.config.ts @@ -50,6 +50,7 @@ export default defineConfig(({ mode }) => { css: { preprocessorOptions: { scss: { + api: 'modern-compiler', additionalData: `@use "@/assets/styles/variables.scss" as *;` } } From 10b551ed6ba1ed9f823e8d47ea7b3025fb58d99e Mon Sep 17 00:00:00 2001 From: zpc Date: Wed, 1 Apr 2026 14:58:22 +0800 Subject: [PATCH 20/20] feat(wechat): Enhance subscription message sending with improved error handling and logging - Update notification templates to include action context in user nicknames (unlock, favorite, message, reminder) - Refactor subscription message request to use manual HttpRequestMessage for better control - Add empty response validation with specific warning log - Enhance error logging to include ToUser identifier for better diagnostics - Improve exception handling with ToUser context in error logs - These changes provide better visibility into subscription message failures and clearer user action context in notifications --- .../Services/NotificationService.cs | 8 +++---- .../WeChat/WeChatService.cs | 22 +++++++++++++++---- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/server/src/XiangYi.Application/Services/NotificationService.cs b/server/src/XiangYi.Application/Services/NotificationService.cs index 78119db..deaf336 100644 --- a/server/src/XiangYi.Application/Services/NotificationService.cs +++ b/server/src/XiangYi.Application/Services/NotificationService.cs @@ -251,7 +251,7 @@ public class NotificationService : INotificationService targetUser.ServiceAccountOpenId, NotificationTemplateType.Unlock, "来访通知", - unlockerUser?.Nickname ?? "有人", + (unlockerUser?.Nickname ?? "有人") + " 解锁了您", $"编号{unlockerUser?.XiangQinNo ?? "未知"}刚刚访问了您", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), "pages/interact/unlockedMe", @@ -311,7 +311,7 @@ public class NotificationService : INotificationService targetUser.ServiceAccountOpenId, NotificationTemplateType.Favorite, "收藏通知", - favoriterUser?.Nickname ?? "有人", + (favoriterUser?.Nickname ?? "有人") + " 收藏了您", $"编号{favoriterUser?.XiangQinNo ?? "未知"}收藏了您", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), "pages/interact/favoritedMe", @@ -371,7 +371,7 @@ public class NotificationService : INotificationService targetUser.ServiceAccountOpenId, NotificationTemplateType.FirstMessage, "消息通知", - senderUser?.Nickname ?? "有人", + (senderUser?.Nickname ?? "有人") + " 联系了您", $"编号{senderUser?.XiangQinNo ?? "未知"}给您发送了一条消息", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), "pages/chat/index", @@ -431,7 +431,7 @@ public class NotificationService : INotificationService targetUser.ServiceAccountOpenId, NotificationTemplateType.MessageReminder, "消息提醒", - senderUser?.Nickname ?? "有人", + (senderUser?.Nickname ?? "有人") + " 等待您回复", $"编号{senderUser?.XiangQinNo ?? "未知"}正在等待您的回复", DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"), "pages/chat/index", diff --git a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs index f8c5325..62c438b 100644 --- a/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs +++ b/server/src/XiangYi.Infrastructure/WeChat/WeChatService.cs @@ -346,12 +346,26 @@ public class WeChatService : IWeChatService miniprogram_state = request.MiniprogramState }; - var response = await _httpClient.PostAsJsonAsync(url, requestBody); - var result = await response.Content.ReadFromJsonAsync(); + var jsonContent = System.Text.Json.JsonSerializer.Serialize(requestBody); + var httpRequest = new HttpRequestMessage(HttpMethod.Post, url) + { + Content = new StringContent(jsonContent, Encoding.UTF8, "application/json") + }; + + var response = await _httpClient.SendAsync(httpRequest); + var responseContent = await response.Content.ReadAsStringAsync(); + + if (string.IsNullOrWhiteSpace(responseContent)) + { + _logger.LogWarning("发送订阅消息失败: 微信返回空响应, StatusCode={StatusCode}, ToUser={ToUser}", (int)response.StatusCode, request.ToUser); + return false; + } + + var result = System.Text.Json.JsonSerializer.Deserialize(responseContent); if (result?.ErrCode != 0) { - _logger.LogWarning("发送订阅消息失败: {ErrCode} - {ErrMsg}", result?.ErrCode, result?.ErrMsg); + _logger.LogWarning("发送订阅消息失败: {ErrCode} - {ErrMsg}, ToUser={ToUser}", result?.ErrCode, result?.ErrMsg, request.ToUser); return false; } @@ -360,7 +374,7 @@ public class WeChatService : IWeChatService } catch (Exception ex) { - _logger.LogError(ex, "发送订阅消息异常"); + _logger.LogError(ex, "发送订阅消息异常: ToUser={ToUser}", request.ToUser); return false; } }