Merge branch 'master' of http://192.168.195.14:3000/outsource/xiangyixiangqin
All checks were successful
continuous-integration/drone/push Build is passing
All checks were successful
continuous-integration/drone/push Build is passing
This commit is contained in:
commit
495e021c83
79
.drone.yml
Normal file
79
.drone.yml
Normal 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
613
CI-CD部署文档.md
Normal 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 配置信任内网 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`,找到对应仓库和构建编号,点击查看每个步骤的日志。
|
||||
137
README.md
Normal file
137
README.md
Normal 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) | 产品需求说明 |
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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/",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
57
deploy/docker-compose.yml
Normal 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
62
docker-compose.yml
Normal 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
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name" : "相宜亲家",
|
||||
"appid" : "__UNI__39EAECC",
|
||||
"appid" : "__UNI__85044B9",
|
||||
"description" : "",
|
||||
"versionName" : "1.0.0",
|
||||
"versionCode" : "100",
|
||||
|
|
|
|||
31
scripts/push-base-images.sh
Normal file
31
scripts/push-base-images.sh
Normal 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 "=== 所有基础镜像已推送完毕 ==="
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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", "."]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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", "."]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user