Merge branch 'master' of http://192.168.195.14:3000/outsource/xiangyixiangqin
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
18631081161 2026-04-06 14:51:05 +08:00
commit 495e021c83
26 changed files with 1826 additions and 88 deletions

79
.drone.yml Normal file
View File

@ -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

613
CI-CD部署文档.md Normal file
View File

@ -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 配置信任内网 HarborHTTP
由于 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`,找到对应仓库和构建编号,点击查看每个步骤的日志。

137
README.md Normal file
View File

@ -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) | 产品需求说明 |

View File

@ -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

View File

@ -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

View File

@ -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;

View File

@ -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/",

View File

@ -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)
}

View File

@ -301,6 +301,155 @@
/>
</div>
</el-tab-pane>
<!-- 消息模板配置 -->
<el-tab-pane label="消息模板" name="notificationTemplates">
<el-form :model="templateForm" label-width="160px" class="config-form">
<el-alert
title="服务号模板消息配置"
description="在微信公众平台 → 模板消息 中获取模板ID填入下方对应字段。模板ID格式类似1WwIIY4NoPWE972HfSgjm..."
type="info"
:closable="false"
style="margin-bottom: 24px;"
/>
<el-form-item label="服务号Token">
<el-input
v-model="templateForm.token"
placeholder="微信公众平台配置的Token"
clearable
/>
<div class="template-tip">在微信公众平台 基本配置 服务器配置中设置的Token需保持一致</div>
</el-form-item>
<el-form-item label="EncodingAESKey">
<el-input
v-model="templateForm.encodingAESKey"
placeholder="消息加解密密钥43位字符"
clearable
/>
<div class="template-tip">微信公众平台 基本配置 服务器配置中的消息加解密密钥明文模式可不填</div>
</el-form-item>
<el-form-item label="解锁通知模板ID">
<el-input
v-model="templateForm.unlockTemplateId"
placeholder="用户解锁我时发送的通知模板ID"
clearable
/>
<div class="template-tip">有用户解锁我时服务号发送相应通知</div>
</el-form-item>
<el-form-item label="解锁通知字段映射">
<el-input
v-model="templateForm.unlockFieldMapping"
placeholder='例: {"thing1":"title","thing2":"name","time3":"time"}'
clearable
/>
<div class="template-tip">JSON格式key为模板字段名value为数据类型title=标题, name=名称, content=内容, time=时间, phone=手机号, remark=备注</div>
</el-form-item>
<el-form-item label="解锁通知跳转页面">
<el-select v-model="templateForm.unlockPage" placeholder="默认: pages/interact/unlockedMe" clearable style="width: 100%;">
<el-option label="解锁我 (pages/interact/unlockedMe)" value="pages/interact/unlockedMe" />
<el-option label="收藏我 (pages/interact/favoritedMe)" value="pages/interact/favoritedMe" />
<el-option label="看过我 (pages/interact/viewedMe)" value="pages/interact/viewedMe" />
<el-option label="聊天 (pages/chat/index)" value="pages/chat/index" />
<el-option label="首页 (pages/index/index)" value="pages/index/index" />
</el-select>
</el-form-item>
<el-form-item label="收藏通知模板ID">
<el-input
v-model="templateForm.favoriteTemplateId"
placeholder="用户收藏我时发送的通知模板ID"
clearable
/>
<div class="template-tip">有用户收藏我时服务号发送相应通知</div>
</el-form-item>
<el-form-item label="收藏通知字段映射">
<el-input
v-model="templateForm.favoriteFieldMapping"
placeholder='例: {"thing1":"title","thing2":"name","time3":"time"}'
clearable
/>
<div class="template-tip">JSON格式key为模板字段名value为数据类型title=标题, name=名称, content=内容, time=时间, phone=手机号, remark=备注</div>
</el-form-item>
<el-form-item label="收藏通知跳转页面">
<el-select v-model="templateForm.favoritePage" placeholder="默认: pages/interact/favoritedMe" clearable style="width: 100%;">
<el-option label="收藏我 (pages/interact/favoritedMe)" value="pages/interact/favoritedMe" />
<el-option label="解锁我 (pages/interact/unlockedMe)" value="pages/interact/unlockedMe" />
<el-option label="看过我 (pages/interact/viewedMe)" value="pages/interact/viewedMe" />
<el-option label="聊天 (pages/chat/index)" value="pages/chat/index" />
<el-option label="首页 (pages/index/index)" value="pages/index/index" />
</el-select>
</el-form-item>
<el-form-item label="消息通知模板ID">
<el-input
v-model="templateForm.messageTemplateId"
placeholder="首次聊天 / 5分钟未回复时发送的通知模板ID"
clearable
/>
<div class="template-tip">首次沟通通知5分钟未回复提醒共用此模板</div>
</el-form-item>
<el-form-item label="消息通知字段映射">
<el-input
v-model="templateForm.messageFieldMapping"
placeholder='例: {"thing1":"title","thing2":"name","time3":"time"}'
clearable
/>
<div class="template-tip">JSON格式key为模板字段名value为数据类型title=标题, name=名称, content=内容, time=时间, phone=手机号, remark=备注</div>
</el-form-item>
<el-form-item label="消息通知跳转页面">
<el-select v-model="templateForm.messagePage" placeholder="默认: pages/chat/index" clearable style="width: 100%;">
<el-option label="聊天 (pages/chat/index)" value="pages/chat/index" />
<el-option label="收藏我 (pages/interact/favoritedMe)" value="pages/interact/favoritedMe" />
<el-option label="解锁我 (pages/interact/unlockedMe)" value="pages/interact/unlockedMe" />
<el-option label="看过我 (pages/interact/viewedMe)" value="pages/interact/viewedMe" />
<el-option label="首页 (pages/index/index)" value="pages/index/index" />
</el-select>
</el-form-item>
<el-form-item label="每日推荐通知模板ID">
<el-input
v-model="templateForm.dailyRecommendTemplateId"
placeholder="每日推荐列表更新时发送的通知模板ID"
clearable
/>
<div class="template-tip">每天早上8~10点随机时间发送推荐更新通知</div>
</el-form-item>
<el-form-item label="每日推荐字段映射">
<el-input
v-model="templateForm.dailyRecommendFieldMapping"
placeholder='例: {"thing1":"title","thing2":"content","time3":"time"}'
clearable
/>
<div class="template-tip">JSON格式key为模板字段名value为数据类型title=标题, name=名称, content=内容, time=时间, phone=手机号, remark=备注</div>
</el-form-item>
<el-form-item label="每日推荐跳转页面">
<el-select v-model="templateForm.dailyRecommendPage" placeholder="默认: pages/index/index" clearable style="width: 100%;">
<el-option label="首页 (pages/index/index)" value="pages/index/index" />
<el-option label="收藏我 (pages/interact/favoritedMe)" value="pages/interact/favoritedMe" />
<el-option label="解锁我 (pages/interact/unlockedMe)" value="pages/interact/unlockedMe" />
<el-option label="看过我 (pages/interact/viewedMe)" value="pages/interact/viewedMe" />
<el-option label="聊天 (pages/chat/index)" value="pages/chat/index" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="saveNotificationTemplates" :loading="savingTemplates">
保存模板配置
</el-button>
</el-form-item>
</el-form>
</el-tab-pane>
</el-tabs>
</el-card>
</div>
@ -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()
})
</script>
@ -863,4 +1084,11 @@ onMounted(() => {
color: #606266;
font-size: 14px;
}
.template-tip {
color: #909399;
font-size: 12px;
margin-top: 4px;
line-height: 1.6;
}
</style>

View File

@ -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 *;`
}
}

57
deploy/docker-compose.yml Normal file
View File

@ -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

62
docker-compose.yml Normal file
View File

@ -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

View File

@ -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 = {

View File

@ -1,6 +1,6 @@
{
"name" : "相宜亲家",
"appid" : "__UNI__39EAECC",
"appid" : "__UNI__85044B9",
"description" : "",
"versionName" : "1.0.0",
"versionCode" : "100",

View File

@ -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 "=== 所有基础镜像已推送完毕 ==="

View File

@ -362,6 +362,26 @@ public class AdminConfigController : ControllerBase
var result = await _configService.SetRealNamePriceAsync(request.Price);
return result ? ApiResponse.Success("设置成功") : ApiResponse.Error(40001, "设置失败");
}
/// <summary>
/// 获取服务号通知模板配置
/// </summary>
[HttpGet("notificationTemplates")]
public async Task<ApiResponse<NotificationTemplatesDto>> GetNotificationTemplates()
{
var templates = await _configService.GetNotificationTemplatesAsync();
return ApiResponse<NotificationTemplatesDto>.Success(templates);
}
/// <summary>
/// 设置服务号通知模板配置
/// </summary>
[HttpPost("notificationTemplates")]
public async Task<ApiResponse> SetNotificationTemplates([FromBody] NotificationTemplatesDto request)
{
var result = await _configService.SetNotificationTemplatesAsync(request);
return result ? ApiResponse.Success("设置成功") : ApiResponse.Error(40001, "设置失败");
}
}
/// <summary>

View File

@ -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", "."]

View File

@ -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<User> _userRepository;
private readonly ILogger<WeChatEventController> _logger;
private readonly IConfiguration _configuration;
// 服务号Token需要在微信公众平台配置
private const string Token = "xiangyi2024";
private readonly ISystemConfigService _configService;
private readonly IWeChatService _weChatService;
public WeChatEventController(
IRepository<User> userRepository,
ILogger<WeChatEventController> logger,
IConfiguration configuration)
IConfiguration configuration,
ISystemConfigService configService,
IWeChatService weChatService)
{
_userRepository = userRepository;
_logger = logger;
_configuration = configuration;
_configService = configService;
_weChatService = weChatService;
}
/// <summary>
/// 微信服务器验证GET请求
/// </summary>
[HttpGet]
public IActionResult Verify(string signature, string timestamp, string nonce, string echostr)
public async Task<IActionResult> 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
/// <summary>
/// 验证微信签名
/// </summary>
private bool CheckSignature(string signature, string timestamp, string nonce)
private async Task<bool> 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);

View File

@ -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", "."]

View File

@ -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",

View File

@ -141,4 +141,14 @@ public interface ISystemConfigService
/// 设置实名认证费用
/// </summary>
Task<bool> SetRealNamePriceAsync(decimal price);
/// <summary>
/// 获取服务号通知模板配置
/// </summary>
Task<NotificationTemplatesDto> GetNotificationTemplatesAsync();
/// <summary>
/// 设置服务号通知模板配置
/// </summary>
Task<bool> SetNotificationTemplatesAsync(NotificationTemplatesDto templates);
}

View File

@ -19,6 +19,7 @@ public class NotificationService : INotificationService
private readonly IRepository<User> _userRepository;
private readonly IWeChatService _weChatService;
private readonly WeChatOptions _weChatOptions;
private readonly ISystemConfigService _configService;
private readonly ILogger<NotificationService> _logger;
/// <summary>
@ -47,6 +48,7 @@ public class NotificationService : INotificationService
IRepository<User> userRepository,
IWeChatService weChatService,
IOptions<WeChatOptions> weChatOptions,
ISystemConfigService configService,
ILogger<NotificationService> 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<string, TemplateDataItem> data;
if (fieldMapping != null && fieldMapping.Count > 0)
{
// 使用后台配置的字段映射动态构建
data = new Dictionary<string, TemplateDataItem>();
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<string, TemplateDataItem>
{
["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
}
}
/// <summary>
/// 获取服务号模板字段映射
/// </summary>
private async Task<Dictionary<string, string>?> 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<Dictionary<string, string>>(json);
}
catch
{
_logger.LogWarning("解析字段映射失败: Key={Key}, Value={Value}", configKey, json);
return null;
}
}
/// <summary>
/// 获取服务号通知跳转页面配置
/// </summary>
private async Task<string?> 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);
}
/// <summary>
/// 构建默认模板数据(兜底,数据库未配置字段映射时使用)
/// </summary>
private static Dictionary<string, TemplateDataItem> BuildDefaultTemplateData(
NotificationTemplateType templateType, string title, string name, string content, string time)
{
return new Dictionary<string, TemplateDataItem>
{
["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
}
/// <summary>
/// 获取服务号模板ID
/// 获取服务号模板ID优先从数据库配置读取回退到appsettings.json
/// </summary>
/// <param name="templateType">模板类型</param>
/// <returns>模板ID</returns>
private string GetServiceAccountTemplateId(NotificationTemplateType templateType)
private async Task<string> 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,

View File

@ -98,6 +98,21 @@ public class SystemConfigService : ISystemConfigService
/// </summary>
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<SystemConfig> configRepository,
ILogger<SystemConfigService> logger)
@ -344,6 +359,70 @@ public class SystemConfigService : ISystemConfigService
{
return await SetConfigValueAsync(RealNamePriceKey, price.ToString(), "实名认证费用(元)");
}
/// <inheritdoc />
public async Task<NotificationTemplatesDto> 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)
};
}
/// <inheritdoc />
public async Task<bool> 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;
}
}
}
/// <summary>
@ -371,3 +450,79 @@ public class MemberIconsDto
/// </summary>
public string? TimeLimitedMemberIcon { get; set; }
}
/// <summary>
/// 服务号通知模板配置DTO
/// </summary>
public class NotificationTemplatesDto
{
/// <summary>
/// 服务号验证Token微信公众平台配置的Token
/// </summary>
public string? Token { get; set; }
/// <summary>
/// 服务号消息加解密密钥EncodingAESKey
/// </summary>
public string? EncodingAESKey { get; set; }
/// <summary>
/// 解锁通知模板ID
/// </summary>
public string? UnlockTemplateId { get; set; }
/// <summary>
/// 解锁通知字段映射JSON
/// </summary>
public string? UnlockFieldMapping { get; set; }
/// <summary>
/// 解锁通知跳转页面
/// </summary>
public string? UnlockPage { get; set; }
/// <summary>
/// 收藏通知模板ID
/// </summary>
public string? FavoriteTemplateId { get; set; }
/// <summary>
/// 收藏通知字段映射JSON
/// </summary>
public string? FavoriteFieldMapping { get; set; }
/// <summary>
/// 收藏通知跳转页面
/// </summary>
public string? FavoritePage { get; set; }
/// <summary>
/// 消息通知模板ID首次消息/未回复提醒共用)
/// </summary>
public string? MessageTemplateId { get; set; }
/// <summary>
/// 消息通知字段映射JSON
/// </summary>
public string? MessageFieldMapping { get; set; }
/// <summary>
/// 消息通知跳转页面
/// </summary>
public string? MessagePage { get; set; }
/// <summary>
/// 每日推荐通知模板ID
/// </summary>
public string? DailyRecommendTemplateId { get; set; }
/// <summary>
/// 每日推荐通知字段映射JSON
/// </summary>
public string? DailyRecommendFieldMapping { get; set; }
/// <summary>
/// 每日推荐通知跳转页面
/// </summary>
public string? DailyRecommendPage { get; set; }
}

View File

@ -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 =>
{

View File

@ -67,8 +67,16 @@ public interface IWeChatService
/// <summary>
/// 获取服务号AccessToken
/// </summary>
/// <param name="forceRefresh">是否强制刷新(忽略缓存)</param>
/// <returns>AccessToken</returns>
Task<string?> GetServiceAccountAccessTokenAsync();
Task<string?> GetServiceAccountAccessTokenAsync(bool forceRefresh = false);
/// <summary>
/// 获取服务号关注用户信息包含UnionId
/// </summary>
/// <param name="openId">用户在服务号的OpenId</param>
/// <returns>用户信息</returns>
Task<ServiceAccountUserInfo?> GetServiceAccountUserInfoAsync(string openId);
}
/// <summary>
@ -278,6 +286,7 @@ public class SubscribeMessageRequest
/// </summary>
public class TemplateDataItem
{
[System.Text.Json.Serialization.JsonPropertyName("value")]
public string Value { get; set; } = string.Empty;
}
@ -328,3 +337,24 @@ public class MiniProgramInfo
/// </summary>
public string? PagePath { get; set; }
}
/// <summary>
/// 服务号关注用户信息
/// </summary>
public class ServiceAccountUserInfo
{
/// <summary>
/// 是否关注0未关注1已关注
/// </summary>
public int Subscribe { get; set; }
/// <summary>
/// 用户OpenId
/// </summary>
public string OpenId { get; set; } = string.Empty;
/// <summary>
/// UnionId绑定开放平台后才有
/// </summary>
public string? UnionId { get; set; }
}

View File

@ -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<WeChatApiResponse>();
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<WeChatApiResponse>(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<string?> GetServiceAccountAccessTokenAsync()
public async Task<string?> 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<AccessTokenResponse>(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<bool> SendServiceAccountTemplateMessageAsync(ServiceAccountTemplateMessageRequest request)
{
return await SendServiceAccountTemplateMessageInternalAsync(request, isRetry: false);
}
private async Task<bool> 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<WeChatApiResponse>();
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<WeChatApiResponse>(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<ServiceAccountUserInfo?> 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<ServiceAccountUserInfoResponse>(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
}