diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..79587da --- /dev/null +++ b/.drone.yml @@ -0,0 +1,79 @@ +--- +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} + cache_from: + - 192.168.195.25:19900/xiangyixiangqin/admin-web:latest + 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/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/.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/Dockerfile b/admin/Dockerfile index f1d4772..b7c18a0 100644 --- a/admin/Dockerfile +++ b/admin/Dockerfile @@ -1,22 +1,22 @@ # 构建阶段 -FROM node:20-alpine AS build +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 . . # 构建应用 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/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/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/src/api/config.ts b/admin/src/api/config.ts index fcc3693..28cb7ad 100644 --- a/admin/src/api/config.ts +++ b/admin/src/api/config.ts @@ -184,3 +184,37 @@ export function getRealNamePrice() { export function setRealNamePrice(price: number) { return request.post('/admin/config/realNamePrice', { price }) } + +/** + * 服务号通知模板配置 + */ +export interface NotificationTemplatesConfig { + token?: string + 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 +} + +/** + * 获取服务号通知模板配置 + */ +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..44a9fea 100644 --- a/admin/src/views/system/config.vue +++ b/admin/src/views/system/config.vue @@ -301,6 +301,155 @@ /> + + + + + + + + + 在微信公众平台 → 基本配置 → 服务器配置中设置的Token,需保持一致 + + + + + 微信公众平台 → 基本配置 → 服务器配置中的消息加解密密钥,明文模式可不填 + + + + + 有用户解锁我时,服务号发送相应通知 + + + + + JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, phone=手机号, remark=备注 + + + + + + + + + + + + + + + 有用户收藏我时,服务号发送相应通知 + + + + + JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, phone=手机号, remark=备注 + + + + + + + + + + + + + + + 首次沟通通知、5分钟未回复提醒共用此模板 + + + + + JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, phone=手机号, remark=备注 + + + + + + + + + + + + + + + 每天早上8~10点随机时间发送推荐更新通知 + + + + + JSON格式,key为模板字段名,value为数据类型:title=标题, name=名称, content=内容, time=时间, phone=手机号, remark=备注 + + + + + + + + + + + + + + + 保存模板配置 + + + + @@ -330,7 +479,9 @@ import { getMemberEntryImage, setMemberEntryImage, getRealNamePrice, - setRealNamePrice + setRealNamePrice, + getNotificationTemplates, + setNotificationTemplates } from '@/api/config' import { useUserStore } from '@/stores/user' @@ -359,9 +510,27 @@ const agreementForm = ref({ privacyPolicy: '' }) +const templateForm = ref({ + token: '', + encodingAESKey: '', + unlockTemplateId: '', + unlockFieldMapping: '', + unlockPage: '', + favoriteTemplateId: '', + favoriteFieldMapping: '', + favoritePage: '', + messageTemplateId: '', + messageFieldMapping: '', + messagePage: '', + dailyRecommendTemplateId: '', + dailyRecommendFieldMapping: '', + dailyRecommendPage: '' +}) + const saving = ref(false) const savingAgreement = ref(false) const savingPolicy = ref(false) +const savingTemplates = ref(false) const uploadUrl = computed(() => `${apiBaseUrl}/admin/upload`) @@ -626,9 +795,61 @@ const savePrivacyPolicy = async () => { } } +const loadNotificationTemplates = async () => { + try { + const res = await getNotificationTemplates() + if (res) { + templateForm.value.token = res.token || '' + 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) + } +} + +const saveNotificationTemplates = async () => { + savingTemplates.value = true + try { + await setNotificationTemplates({ + token: templateForm.value.token || undefined, + 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, + dailyRecommendPage: templateForm.value.dailyRecommendPage || undefined + }) + ElMessage.success('模板配置保存成功') + } catch (error) { + console.error('保存模板配置失败:', error) + } finally { + savingTemplates.value = false + } +} + onMounted(() => { loadConfig() loadAgreements() + loadNotificationTemplates() }) @@ -863,4 +1084,11 @@ onMounted(() => { color: #606266; font-size: 14px; } + +.template-tip { + color: #909399; + font-size: 12px; + margin-top: 4px; + line-height: 1.6; +} diff --git a/admin/vite.config.ts b/admin/vite.config.ts index b949eff..677455b 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() // 暂时禁用自动导入插件来解决兼容性问题 @@ -50,6 +50,7 @@ export default defineConfig(({ mode }) => { css: { preprocessorOptions: { scss: { + api: 'modern-compiler', additionalData: `@use "@/assets/styles/variables.scss" as *;` } } diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..e10572b --- /dev/null +++ b/deploy/docker-compose.yml @@ -0,0 +1,57 @@ +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 + networks: + - xyqj-network + + 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.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 + - ./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: + - xyqj-network + + 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 + networks: + - xyqj-network + +networks: + xyqj-network: + driver: bridge 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/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 = { 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/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/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.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/Controllers/WeChatEventController.cs b/server/src/XiangYi.AppApi/Controllers/WeChatEventController.cs index 7985f04..9930087 100644 --- a/server/src/XiangYi.AppApi/Controllers/WeChatEventController.cs +++ b/server/src/XiangYi.AppApi/Controllers/WeChatEventController.cs @@ -3,8 +3,10 @@ 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; +using XiangYi.Infrastructure.WeChat; namespace XiangYi.AppApi.Controllers; @@ -19,30 +21,33 @@ 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; + private readonly IWeChatService _weChatService; public WeChatEventController( IRepository userRepository, ILogger logger, - IConfiguration configuration) + IConfiguration configuration, + ISystemConfigService configService, + IWeChatService weChatService) { _userRepository = userRepository; _logger = logger; _configuration = configuration; + _configService = configService; + _weChatService = weChatService; } /// /// 微信服务器验证(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); @@ -108,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); } } @@ -169,9 +173,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.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", "."] 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.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..deaf336 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; } @@ -248,10 +251,11 @@ 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"); + "pages/interact/unlockedMe", + unlockerUser?.Phone); } // 回退到小程序订阅消息 @@ -307,10 +311,11 @@ 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"); + "pages/interact/favoritedMe", + favoriterUser?.Phone); } // 回退到小程序订阅消息 @@ -366,10 +371,11 @@ 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"); + "pages/chat/index", + senderUser?.Phone); } // 回退到小程序订阅消息 @@ -425,10 +431,11 @@ 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"); + "pages/chat/index", + senderUser?.Phone); } // 回退到小程序订阅消息 @@ -530,17 +537,56 @@ public class NotificationService : INotificationService string name, string content, string time, - string page) + string page, + string? phone = null) { try { - var templateId = GetServiceAccountTemplateId(templateType); + var templateId = await GetServiceAccountTemplateIdAsync(templateType); if (string.IsNullOrEmpty(templateId)) { _logger.LogWarning("服务号模板ID未配置: TemplateType={TemplateType}", templateType); return false; } + // 从数据库读取跳转页面配置,有配置则覆盖默认值 + var configuredPage = await GetServiceAccountPageAsync(templateType); + if (!string.IsNullOrEmpty(configuredPage)) + { + page = configuredPage; + } + + // 从数据库读取字段映射 + 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) + { + // 使用后台配置的字段映射动态构建 + data = new Dictionary(); + foreach (var (fieldName, valueKey) in fieldMapping) + { + var value = valueKey?.ToLower() switch + { + "title" => title, + "name" => name, + "content" => content, + "time" => time, + "phone" => phone ?? "", + "remark" => "点击查看详情", + _ => valueKey ?? "" + }; + data[fieldName] = new TemplateDataItem { Value = value }; + } + } + else + { + // 兜底:根据通知类型使用默认字段映射 + data = BuildDefaultTemplateData(templateType, title, name, content, time); + } + var request = new ServiceAccountTemplateMessageRequest { ToUser = serviceAccountOpenId, @@ -550,14 +596,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); @@ -573,6 +612,72 @@ 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; + } + } + + /// + /// 获取服务号通知跳转页面配置 + /// + 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); + } + + /// + /// 构建默认模板数据(兜底,数据库未配置字段映射时使用) + /// + 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 @@ -648,12 +753,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..86762fc 100644 --- a/server/src/XiangYi.Application/Services/SystemConfigService.cs +++ b/server/src/XiangYi.Application/Services/SystemConfigService.cs @@ -98,6 +98,21 @@ 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 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, ILogger logger) @@ -344,6 +359,70 @@ public class SystemConfigService : ISystemConfigService { return await SetConfigValueAsync(RealNamePriceKey, price.ToString(), "实名认证费用(元)"); } + + /// + public async Task GetNotificationTemplatesAsync() + { + return new NotificationTemplatesDto + { + Token = await GetConfigValueAsync(SaTokenKey), + 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), + DailyRecommendPage = await GetConfigValueAsync(SaDailyRecommendPageKey) + }; + } + + /// + public async Task SetNotificationTemplatesAsync(NotificationTemplatesDto templates) + { + 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.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) + { + _logger.LogError(ex, "设置通知模板配置失败"); + return false; + } + } } /// @@ -371,3 +450,79 @@ public class MemberIconsDto /// public string? TimeLimitedMemberIcon { get; set; } } + +/// +/// 服务号通知模板配置DTO +/// +public class NotificationTemplatesDto +{ + /// + /// 服务号验证Token(微信公众平台配置的Token) + /// + public string? Token { get; set; } + + /// + /// 服务号消息加解密密钥(EncodingAESKey) + /// + public string? EncodingAESKey { get; set; } + + /// + /// 解锁通知模板ID + /// + public string? UnlockTemplateId { get; set; } + + /// + /// 解锁通知字段映射JSON + /// + public string? UnlockFieldMapping { get; set; } + + /// + /// 解锁通知跳转页面 + /// + public string? UnlockPage { get; set; } + + /// + /// 收藏通知模板ID + /// + public string? FavoriteTemplateId { get; set; } + + /// + /// 收藏通知字段映射JSON + /// + public string? FavoriteFieldMapping { get; set; } + + /// + /// 收藏通知跳转页面 + /// + public string? FavoritePage { get; set; } + + /// + /// 消息通知模板ID(首次消息/未回复提醒共用) + /// + public string? MessageTemplateId { get; set; } + + /// + /// 消息通知字段映射JSON + /// + public string? MessageFieldMapping { get; set; } + + /// + /// 消息通知跳转页面 + /// + public string? MessagePage { get; set; } + + /// + /// 每日推荐通知模板ID + /// + public string? DailyRecommendTemplateId { get; set; } + + /// + /// 每日推荐通知字段映射JSON + /// + public string? DailyRecommendFieldMapping { get; set; } + + /// + /// 每日推荐通知跳转页面 + /// + public string? DailyRecommendPage { get; set; } +} 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/IWeChatService.cs b/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs index b05ced4..aa5c139 100644 --- a/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs +++ b/server/src/XiangYi.Infrastructure/WeChat/IWeChatService.cs @@ -67,8 +67,16 @@ public interface IWeChatService /// /// 获取服务号AccessToken /// + /// 是否强制刷新(忽略缓存) /// AccessToken - Task GetServiceAccountAccessTokenAsync(); + Task GetServiceAccountAccessTokenAsync(bool forceRefresh = false); + + /// + /// 获取服务号关注用户信息(包含UnionId) + /// + /// 用户在服务号的OpenId + /// 用户信息 + Task GetServiceAccountUserInfoAsync(string openId); } /// @@ -278,6 +286,7 @@ public class SubscribeMessageRequest /// public class TemplateDataItem { + [System.Text.Json.Serialization.JsonPropertyName("value")] public string Value { get; set; } = string.Empty; } @@ -328,3 +337,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..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; } } @@ -371,7 +385,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 +395,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 +409,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 +421,8 @@ public class WeChatService : IWeChatService return null; } + _logger.LogInformation("获取服务号AccessToken成功"); + // 缓存AccessToken,提前5分钟过期 var expireSeconds = response.ExpiresIn - 300; await _cache.SetStringAsync( @@ -416,13 +437,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; } @@ -451,25 +479,108 @@ public class WeChatService : IWeChatService }; } - var response = await _httpClient.PostAsJsonAsync(url, requestBody); - var result = await response.Content.ReadFromJsonAsync(); + var jsonContent = System.Text.Json.JsonSerializer.Serialize(requestBody); + _logger.LogInformation("发送服务号模板消息请求: ToUser={ToUser}, TemplateId={TemplateId}, Body={Body}", + request.ToUser, request.TemplateId, jsonContent); - if (result?.ErrCode != 0) + 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 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 失效,强制刷新后重试一次 + 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("发送服务号模板消息失败: {ErrCode} - {ErrMsg}", result?.ErrCode, result?.ErrMsg); return false; } - _logger.LogInformation("发送服务号模板消息成功: {ToUser}", request.ToUser); + 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={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={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; } } + 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 +863,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 }