Compare commits
15 Commits
sxsy80
...
czleilei24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ede01c06d2 | ||
|
|
9b4020d44f | ||
|
|
8c3ff509fc | ||
|
|
93d6a58a2b | ||
|
|
cef4398a5a | ||
|
|
fb76270882 | ||
|
|
a89825e23c | ||
|
|
ccb70ca10e | ||
|
|
ee32ecc995 | ||
|
|
fd4255d982 | ||
|
|
cf2918cfe2 | ||
|
|
e2d52ba4cc | ||
|
|
d8ad6cde20 | ||
|
|
49900919c6 | ||
|
|
5cbca4ba76 |
17
.gitignore
vendored
17
.gitignore
vendored
@@ -20,3 +20,20 @@ backend/**/logs/
|
||||
backend/**/*.log
|
||||
backend/.idea/
|
||||
backend/crmebimage/
|
||||
|
||||
# Dashboard frontend
|
||||
dashboard-frontend/node_modules/
|
||||
dashboard-frontend/npm-debug.log*
|
||||
dashboard-frontend/**/*.log
|
||||
|
||||
# 独立子仓库(各自有独立 git,不纳入根 repo)
|
||||
integral-resell/
|
||||
single-shop-22/
|
||||
MER-2.2_2601/
|
||||
db/
|
||||
|
||||
# 敏感运行时配置(不入库)
|
||||
deploy/docker/scripts/server.env
|
||||
deploy/docker/step1-integral/.env
|
||||
deploy/docker/step1-integral/houtai.env
|
||||
deploy/docker/step2-single-shop/.env
|
||||
|
||||
469
DOCKER_DEPLOY.md
Normal file
469
DOCKER_DEPLOY.md
Normal file
@@ -0,0 +1,469 @@
|
||||
# Docker 部署方案(寄卖商城 + 积分商城前后端)
|
||||
|
||||
> 本方案覆盖:
|
||||
> - **integral-resell(积分商城)**:Webman PHP 后端二进制 + H5 静态站
|
||||
> - **single-shop-22(寄卖商城)**:CRMEB Spring Boot 双 jar 后端 + Vue 管理后台 + uni-app H5
|
||||
> - **Redis**:每个子栈独立容器(两套互不干扰)
|
||||
>
|
||||
> 不包含:`MER-2.2_2601` 多商户、Kafka 同步、MySQL(使用 **阿里云 RDS for MySQL**)。
|
||||
|
||||
---
|
||||
|
||||
## 两步独立部署(推荐)
|
||||
|
||||
从 `czleilei240` 分支开始,两个商城**各自独立**部署,互不依赖。
|
||||
|
||||
| | 步骤一 寄卖商城 | 步骤二 积分商城 |
|
||||
|---|---|---|
|
||||
| 项目 | `integral-resell` | `single-shop-22` |
|
||||
| 目录 | `deploy/docker/step1-integral/` | `deploy/docker/step2-single-shop/` |
|
||||
| 服务 | redis · integral-houtai · integral-h5 | redis · single-admin-api · single-front-api · single-admin-web · single-h5 |
|
||||
| 宿主机端口 | `18080` | `18081`(管理后台) `18082`(H5) |
|
||||
| compose name | `resell-czleilei240` | `jifenmall-czleilei240` |
|
||||
| 详细说明 | [README](deploy/docker/step1-integral/README.md) | [README](deploy/docker/step2-single-shop/README.md) |
|
||||
|
||||
### 步骤一 — 寄卖商城(integral-resell)
|
||||
|
||||
```bash
|
||||
cd deploy/docker/step1-integral
|
||||
cp .env.example .env && cp houtai.env.example houtai.env
|
||||
# 填写 .env 中的 REDIS_PASSWORD
|
||||
# 填写 houtai.env 中的 DB_PASSWORD 和 REDIS_PASSWORD(两者须一致)
|
||||
docker compose --env-file .env up -d --build
|
||||
```
|
||||
|
||||
### 步骤二 — 积分商城(single-shop-22)
|
||||
|
||||
```bash
|
||||
cd deploy/docker/step2-single-shop
|
||||
cp .env.example .env
|
||||
# 填写 RDS_PASSWORD、REDIS_PASSWORD
|
||||
docker compose --env-file .env up -d --build
|
||||
# Java 首次构建约 10-20 分钟,可用 logs -f 观察进度
|
||||
```
|
||||
|
||||
> 下方章节描述的是历史上合并部署时的 `docker-compose.yml`(`deploy/docker/docker-compose.yml`),供参考。两步拆分方案已取代合并方案。
|
||||
|
||||
---
|
||||
|
||||
## 一、架构总览
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
user((用户)) -->|H5/管理后台| EdgeLB[(阿里云 SLB / Nginx)]
|
||||
|
||||
subgraph Host [Docker 主机]
|
||||
direction TB
|
||||
|
||||
subgraph integralStack [积分商城]
|
||||
integral_h5[integral-h5\nNginx :80]
|
||||
integral_houtai[integral-houtai\nwebman.bin :8787]
|
||||
end
|
||||
|
||||
subgraph singleStack [寄卖商城]
|
||||
single_admin_web[single-admin-web\nNginx :80]
|
||||
single_h5[single-h5\nNginx :80]
|
||||
single_admin_api[single-admin-api\nminiao-admin :30032]
|
||||
single_front_api[single-front-api\nminiao-front :30031]
|
||||
end
|
||||
|
||||
redis[(redis :6379)]
|
||||
end
|
||||
|
||||
EdgeLB -- jf-h5.* --> single_h5
|
||||
EdgeLB -- jfadmin.* --> single_admin_web
|
||||
EdgeLB -- admin.* --> integral_h5
|
||||
|
||||
integral_h5 -- /api/ --> integral_houtai
|
||||
single_admin_web -- /api/ --> single_admin_api
|
||||
single_h5 -- /api/ --> single_front_api
|
||||
|
||||
integral_houtai --> redis
|
||||
single_admin_api --> redis
|
||||
single_front_api --> redis
|
||||
|
||||
integral_houtai --> RDS[(阿里云 RDS MySQL)]
|
||||
single_admin_api --> RDS
|
||||
single_front_api --> RDS
|
||||
```
|
||||
|
||||
### 服务清单
|
||||
|
||||
| 服务 | 镜像构建来源 | 容器内端口 | 默认宿主机端口 | 说明 |
|
||||
|------|--------------|------------|----------------|------|
|
||||
| `redis` | 官方 `redis:6.2-alpine` | 6379 | 6379 (可不暴露) | 持久化 AOF,挂卷 `redis-data` |
|
||||
| `integral-houtai` | `integral-resell/houtai/` | 8787 | (不暴露) | Webman 静态 ELF;同容器写 `runtime/` 与 `public/upload/` |
|
||||
| `integral-h5` | `integral-resell/h5/` | 80 | 18080 | 服务静态站;`/api/` 反代到 `integral-houtai`;启动时根据 env 重写 `static/configs.js` |
|
||||
| `single-admin-api` | `single-shop-22/backend/crmeb-admin` | 30032 | (不暴露) | 多阶段 Maven 构建出 `miao-admin-2.2.jar` |
|
||||
| `single-front-api` | `single-shop-22/backend/crmeb-front` | 30031 | (不暴露) | 多阶段 Maven 构建出 `miao-front-2.2.jar` |
|
||||
| `single-admin-web` | `single-shop-22/backend-adminend` | 80 | 18081 | 多阶段 Node 构建 `dist`;`/api/` 反代到 `single-admin-api` |
|
||||
| `single-h5` | `single-shop-22/single_uniapp22miao` | 80 | 18082 | 多阶段 Node 构建 `unpackage/dist/build/h5`;`/api/` 反代到 `single-front-api` |
|
||||
|
||||
> **域名/端口策略**:建议在 docker 主机前再放一层 Nginx 或阿里云 SLB,按域名分发到 18080/18081/18082;下面的 compose 默认把这三个静态站暴露在宿主机不同端口。
|
||||
|
||||
### 共享资源 / 卷
|
||||
|
||||
| 卷 / 目录 | 容器内挂载点 | 用途 |
|
||||
|-----------|-------------|------|
|
||||
| `redis-data` | `/data` | Redis AOF 持久化 |
|
||||
| `integral-runtime` | `/app/runtime` | Webman 运行时(session、views 缓存等) |
|
||||
| `integral-upload` | `/app/public/upload` | 积分商城上传图片 |
|
||||
| `single-images` | `/usr/local/crmeb/crmebimage` | 寄卖商城 `imagePath`,admin/front 两个 jar **共享** |
|
||||
| `single-logs` | `/app/log` | Spring Boot 日志(按需保留) |
|
||||
|
||||
### 与外部资源的关系
|
||||
|
||||
| 资源 | 地址来源 | 说明 |
|
||||
|------|---------|------|
|
||||
| 阿里云 RDS MySQL | `.env` 中 `RDS_*` | RDS 白名单需放通宿主机出口 IP;建议同地域 VPC |
|
||||
| OSS(可选) | `.env` 中 `OSS_*` | 不配置时积分商城走本地 `public/upload`;寄卖商城用 admin 后台界面里配置 |
|
||||
| 短信(可选) | `.env` 中 `SMS_*` | 仅积分商城 `.env` 用到 |
|
||||
|
||||
---
|
||||
|
||||
## 二、目录布局
|
||||
|
||||
```
|
||||
integral-shop/
|
||||
├── DOCKER_DEPLOY.md ← 本文档
|
||||
├── deploy/
|
||||
│ └── docker/
|
||||
│ ├── docker-compose.yml
|
||||
│ ├── .env.example
|
||||
│ ├── README.md ← 一键启动说明(简版)
|
||||
│ ├── redis/
|
||||
│ │ └── redis.conf
|
||||
│ ├── integral-resell/
|
||||
│ │ ├── houtai.Dockerfile
|
||||
│ │ ├── h5.Dockerfile
|
||||
│ │ ├── nginx-h5.conf
|
||||
│ │ ├── .env.template ← Webman 后端运行时 .env 模板
|
||||
│ │ └── docker-entrypoint-h5.sh ← 启动时根据 env 改写 configs.js
|
||||
│ └── single-shop/
|
||||
│ ├── admin-api.Dockerfile
|
||||
│ ├── front-api.Dockerfile
|
||||
│ ├── admin-web.Dockerfile
|
||||
│ ├── h5.Dockerfile
|
||||
│ ├── application-docker.yml ← Spring Boot docker profile
|
||||
│ ├── nginx-admin-web.conf
|
||||
│ └── nginx-h5.conf
|
||||
├── integral-resell/ ← 源码(不改动)
|
||||
└── single-shop-22/ ← 源码(不改动)
|
||||
```
|
||||
|
||||
> 所有 Dockerfile / compose 都**只读访问**源码目录,不会修改它们。
|
||||
|
||||
---
|
||||
|
||||
## 三、配置与环境变量
|
||||
|
||||
复制一份 `deploy/docker/.env.example` 为 `deploy/docker/.env` 并按下面表格修改。
|
||||
|
||||
| 变量 | 用途 | 示例 |
|
||||
|------|------|------|
|
||||
| `TZ` | 容器时区 | `Asia/Shanghai` |
|
||||
| `RDS_HOST` | 阿里云 RDS 外网/内网地址 | `rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com` |
|
||||
| `RDS_PORT` | RDS 端口 | `3306` |
|
||||
| `RDS_INTEGRAL_DB` | 积分商城数据库 | `yangtangyoupin` |
|
||||
| `RDS_INTEGRAL_USER` / `RDS_INTEGRAL_PASS` | 积分商城账号 | 默认 `yangtangyoupin` / 来自原 sxsy80 |
|
||||
| `RDS_SINGLE_DB` | 寄卖商城数据库(与积分共库) | `yangtangyoupin` |
|
||||
| `RDS_SINGLE_USER` / `RDS_SINGLE_PASS` | 寄卖商城账号 | 同上 |
|
||||
| `REDIS_PASSWORD` | 容器 Redis 密码 | 123456 |
|
||||
| `REDIS_INTEGRAL_DB` | 积分商城使用的 Redis db | `0` |
|
||||
| `REDIS_SINGLE_ADMIN_DB` | 寄卖 admin Redis db | `25` |
|
||||
| `REDIS_SINGLE_FRONT_DB` | 寄卖 front Redis db | `26` |
|
||||
| `INTEGRAL_API_PUBLIC_URL` | H5 调用后端的对外 URL | `https://admin.example.com` |
|
||||
| `INTEGRAL_IMG_PUBLIC_URL` | H5 引用图片的对外 URL | `https://admin.example.com` |
|
||||
| `INTEGRAL_H5_PUBLIC_URL` | H5 自身对外 URL | `https://h5.example.com/` |
|
||||
| `INTEGRAL_SN_ID` | `configs.js` 中 `sn_id` | `17533260260405` |
|
||||
| `INTEGRAL_APP_STR` | `configs.js` 中 `appStr`(**与寄卖 APP_SECRET 必须一致**) | `ZFyTNQTWEkCBczKzyUDJWE9Ecx260405` |
|
||||
| `SINGLE_ADMIN_BASE_API` | 寄卖管理后台调用 API 的域名/路径 | 留空走同域 `/api/` 即可 |
|
||||
| `SINGLE_H5_DOMAIN` | uni-app H5 调用 API 的域名 | 留空走同域 `/api/` 即可 |
|
||||
| `SYNC_SOURCE_ID` / `SYNC_TARGET_MER_ID` | 多商户同步配置(无 MER 时留空) | `""` / `0` |
|
||||
|
||||
> **关键一致性**:积分商城 `configs.js#appStr` ↔ 积分商城 Webman `.env#APP_SECRET` ↔ 寄卖商城 admin 后台中的 `appStr` 必须一致;否则双向调用会鉴权失败。
|
||||
|
||||
---
|
||||
|
||||
## 四、镜像构建策略
|
||||
|
||||
### 0. 基础镜像选型(与主机对齐)
|
||||
|
||||
| 服务 | 基础镜像 | libc | 选择理由 |
|
||||
|------|----------|------|----------|
|
||||
| `redis` | `redis:6.2-alpine` | musl | Redis 官方推荐,无 native 依赖 |
|
||||
| `integral-houtai` | `php:8.0-cli-bullseye` (Debian 11) | **glibc** | 与宝塔 PHP 8.0.26 版本对齐;已装 pdo_mysql / redis / gd / imagick 等 webman 常用扩展 |
|
||||
| `integral-h5` | `nginx:1.25-alpine` | musl | 纯静态文件 + nginx,无字体/native 依赖 |
|
||||
| `single-admin-api` / `single-front-api` 构建 | `maven:3.8.8-eclipse-temurin-17` (Debian) | **glibc** | 与部署环境 OpenJDK 17.0.x 对齐;pom.xml source/target=1.8,Java 17 编译器向下兼容 |
|
||||
| `single-admin-api` / `single-front-api` 运行 | `eclipse-temurin:17-jre-jammy` (Ubuntu 22.04) | **glibc** | 匹配主机 openjdk 17.0.18;Spring Boot 2.2.6 + Java 17 需额外 `--add-opens` JVM 参数(已写入 JAVA_OPTS);预装 `fonts-dejavu` + `fonts-wqy-zenhei` |
|
||||
| `single-admin-web` / `single-h5` 构建 | `node:16-alpine` | musl | 仅打包 JS/CSS,无 native 依赖 |
|
||||
| `single-admin-web` / `single-h5` 运行 | `nginx:1.25-alpine` | musl | 同上 |
|
||||
|
||||
> **主机环境**:Debian-class(Linux kernel 6.1.164)/ x86_64 / Docker + BuildKit。
|
||||
> 所有 `--platform=linux/amd64` 在主机上都是原生执行,**无 QEMU 模拟**;只有在 Apple Silicon 上本地 build 时才会走 QEMU。
|
||||
> Java 后端最终镜像在 glibc 上运行,与主机 OS 完全同源;这样可避免 Alpine + musl 时验证码 / 中文字体 / POI 个别场景的兼容性问题。
|
||||
|
||||
### 1. integral-houtai(PHP Webman 二进制)
|
||||
- 基础镜像:`debian:bookworm-slim`(webman.bin 为 ELF x86_64,静态链接,glibc 兼容)。
|
||||
- 直接拷贝 `webman.bin`、`public/`、`runtime/views` 模板进镜像。
|
||||
- `.env` **不打进镜像**,通过 `docker-compose` 把 `deploy/docker/integral-resell/.env.template` 渲染后挂进 `/app/.env`。
|
||||
- 启动命令:`./webman.bin start -d`(业主指定的守护模式),entrypoint 在后台 `tail -F runtime/logs/*.log` 把日志接到容器 stdout,并监听主进程退出。收到 `SIGTERM` 时执行 `./webman.bin stop` 优雅退出。
|
||||
|
||||
### 2. integral-h5(静态站)
|
||||
- 基础镜像:`nginx:1.25-alpine`。
|
||||
- 直接拷贝 `integral-resell/h5/` 到 `/usr/share/nginx/html/`。
|
||||
- `docker-entrypoint-h5.sh` 在启动时根据环境变量重写 `static/configs.js`:把 `BASE_URL/IMG_URL/H5_URL/sn_id/appStr` 替换为部署值,**无需重新构建前端**。
|
||||
|
||||
### 3. single-admin-api / single-front-api(Spring Boot)
|
||||
- **多阶段构建**:
|
||||
- 构建阶段:`maven:3.8.8-eclipse-temurin-17` → `mvn package -pl crmeb-admin -am -DskipTests`(front 同理)。pom.xml 已配置 `source/target=1.8`,Java 17 编译器向下兼容,产物字节码级别不变。
|
||||
- 运行阶段:`eclipse-temurin:17-jre-jammy`(Ubuntu 22.04 / glibc),与部署环境 `openjdk 17.0.18` 对齐;预装 `fontconfig + fonts-dejavu + fonts-wqy-zenhei`。
|
||||
- **Java 17 兼容性**:Spring Boot 2.2.6 使用 cglib、Druid、Quartz 等大量反射,Java 17 强模块封装会报 `InaccessibleObjectException`。已在 `JAVA_OPTS` 中加入 7 条 `--add-opens`,覆盖已知触发点(`java.lang / reflect / util / io / math / sun.net.util / net`)。若运行时出现新的 `InaccessibleObjectException`,按报错包名继续追加 `--add-opens`。
|
||||
- 通过 `--spring.profiles.active=docker` + `--spring.config.additional-location=file:/config/` 加载 `application-docker.yml`,所有 DB/Redis/端口参数从 `.env` 注入。
|
||||
- `imagePath` 指向 `/usr/local/crmeb/crmebimage/`(挂卷 `single-images`,两个 jar 共享)。
|
||||
|
||||
### 4. single-admin-web(Vue 管理后台)
|
||||
- **多阶段构建**:
|
||||
- 构建阶段:`node:16-alpine` → `npm ci && npm run build:prod`(`VUE_APP_BASE_API` 设为空,走同域 `/api/`)。
|
||||
- 运行阶段:`nginx:1.25-alpine`,`/api/` 反代到 `single-admin-api:30032`。
|
||||
- 若想**直接复用源码里现成的 `dist/`**,可改用「fast」分支(见 Dockerfile 注释)。
|
||||
|
||||
### 5. single-h5(uni-app H5)
|
||||
- 同样多阶段:`node:16-alpine` → `npm install && npm run build:h5`,再 nginx 服务。
|
||||
- `config/app.js` 里 `domain` 硬编码,需在构建前注入:通过 ARG `H5_API_DOMAIN` 做字符串替换,或留空让同域生效(推荐)。
|
||||
|
||||
---
|
||||
|
||||
## 五、Spring Boot 接 RDS / Redis 的细节
|
||||
|
||||
寄卖商城原本通过 `spring.profiles.active=byjyw149` / `miao80` 等加载不同 yml。**Docker 部署用全新 profile `docker`**:
|
||||
|
||||
```yaml
|
||||
# deploy/docker/single-shop/application-docker.yml
|
||||
spring:
|
||||
datasource:
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT:3306}/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false
|
||||
username: ${MYSQL_USERNAME}
|
||||
password: ${MYSQL_PASSWORD}
|
||||
redis:
|
||||
host: ${REDIS_HOST}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DATABASE:0}
|
||||
```
|
||||
|
||||
通过 docker-compose 注入:
|
||||
```
|
||||
MYSQL_HOST=${RDS_HOST}
|
||||
MYSQL_DATABASE=${RDS_SINGLE_DB}
|
||||
MYSQL_USERNAME=${RDS_SINGLE_USER}
|
||||
MYSQL_PASSWORD=${RDS_SINGLE_PASS}
|
||||
REDIS_HOST=redis
|
||||
REDIS_PASSWORD=${REDIS_PASSWORD}
|
||||
REDIS_DATABASE=${REDIS_SINGLE_ADMIN_DB}
|
||||
```
|
||||
|
||||
> **数据库初始化**:首次部署前请用 `db/` 下的 SQL(推荐 `db/yangtangyoupin.sql`、`db/shop22-v2.sql` 等)在 RDS 中初始化对应库;本方案不在容器中建库,**避免误覆盖现网数据**。
|
||||
|
||||
---
|
||||
|
||||
## 六、首次部署步骤
|
||||
|
||||
```bash
|
||||
# 1. 准备环境变量
|
||||
cd integral-shop/deploy/docker
|
||||
cp .env.example .env
|
||||
vim .env # 填入 RDS / Redis / 域名等
|
||||
|
||||
# 2. 准备积分商城 Webman .env(容器运行时挂载)
|
||||
cp integral-resell/.env.template integral-resell/.env
|
||||
vim integral-resell/.env # 至少配置 DB_*、APP_SECRET、SMS_*
|
||||
|
||||
# 3. 在阿里云 RDS 上提前导入数据
|
||||
# - 积分商城:db/yangtangyoupin.sql
|
||||
# - 寄卖商城:db/shop22-v2.sql / db/jjy153-mysql.sql (按实际版本)
|
||||
|
||||
# 4. 构建并启动
|
||||
docker compose --env-file .env build
|
||||
docker compose --env-file .env up -d
|
||||
|
||||
# 5. 健康检查
|
||||
docker compose ps
|
||||
docker compose logs -f single-admin-api # 看到 "Started CrmebAdminApplication" 即成功
|
||||
docker compose logs -f integral-houtai # 看到 "Webman start success" 即成功
|
||||
curl -I http://localhost:18080 # 积分商城 H5
|
||||
curl -I http://localhost:18081 # 寄卖管理后台
|
||||
curl -I http://localhost:18082 # 寄卖用户 H5
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、日常运维
|
||||
|
||||
| 场景 | 操作 |
|
||||
|------|------|
|
||||
| 拉取最新源码后重建 | `docker compose build --no-cache <service>` 然后 `docker compose up -d <service>` |
|
||||
| 只重启某服务 | `docker compose restart single-admin-api` |
|
||||
| 看日志 | `docker compose logs -f --tail 200 single-front-api` |
|
||||
| 进入容器 | `docker compose exec single-admin-api sh` |
|
||||
| 备份用户上传 | `docker run --rm -v integral-upload:/d -v $(pwd):/b alpine tar czf /b/upload.tgz -C /d .` |
|
||||
| 备份寄卖商城图片 | 同上,卷名 `single-images` |
|
||||
| 切换到外部 Redis | 在 `.env` 把 `REDIS_HOST` 改成外部地址,并在 compose 里把 `redis` 服务注释 |
|
||||
| 切换到 Java 17 运行寄卖商城 | 把 `eclipse-temurin:8-jre-alpine` 改成 `17-jre`;本工程经验依旧建议 8 |
|
||||
|
||||
---
|
||||
|
||||
## 八、性能与资源建议
|
||||
|
||||
| 容器 | CPU | 内存(建议) |
|
||||
|------|----:|------------:|
|
||||
| `redis` | 0.2 | 256 MB |
|
||||
| `integral-houtai` | 0.5–1 | 256–512 MB(webman 多 worker 时 ↑) |
|
||||
| `integral-h5` | 0.1 | 64 MB |
|
||||
| `single-admin-api` | 1 | **JVM `-Xmx512m`,容器 limit 768 MB** |
|
||||
| `single-front-api` | 1 | JVM `-Xmx768m`,容器 limit 1 GB |
|
||||
| `single-admin-web` | 0.1 | 64 MB |
|
||||
| `single-h5` | 0.1 | 64 MB |
|
||||
|
||||
最低配宿主机:**2 vCPU / 4 GB**(建议 4 vCPU / 8 GB 起)。
|
||||
|
||||
---
|
||||
|
||||
## 九、上线检查清单
|
||||
|
||||
- [ ] 阿里云 RDS 白名单已放通 Docker 主机出口 IP
|
||||
- [ ] RDS 中已导入对应库的 SQL,账号/密码权限正确
|
||||
- [ ] `.env` 中无明文敏感信息提交到 git(已 `.gitignore`)
|
||||
- [ ] `INTEGRAL_APP_STR` 与寄卖商城 admin 后台 + Webman `.env#APP_SECRET` 三处一致
|
||||
- [ ] 域名 / SSL 在外层 Nginx 或阿里云 SLB 完成 443 卸载
|
||||
- [ ] OSS 与 SMS 等第三方密钥若启用,已写入对应位置
|
||||
- [ ] 备份策略:RDS 自动备份 + `integral-upload` / `single-images` 卷定期 tar 备份
|
||||
|
||||
---
|
||||
|
||||
## 十、远端 116.62.83.240 一键部署
|
||||
|
||||
服务器已具备的环境(来自宝塔软件商店截图):
|
||||
- Docker(节点管理 → Docker 已部署)
|
||||
- Nginx 1.28.1(**未使用**,本方案的 Nginx 在容器内)
|
||||
- MySQL 5.7.44(**未使用**,本方案走阿里云 RDS)
|
||||
- Redis 8.0.5(**默认未使用**,容器内自带 Redis;想复用宿主 Redis 见下方"复用宿主 Redis")
|
||||
- PHP 8.0.26(**未使用**,webman 走静态二进制)
|
||||
- rsync 已安装、SSH `root / A@123456`
|
||||
|
||||
### 1. 本机一次性配置
|
||||
|
||||
```bash
|
||||
cd deploy/docker/scripts
|
||||
cp server.env.example server.env
|
||||
$EDITOR server.env # 默认已写好 116.62.83.240 / root / A@123456
|
||||
```
|
||||
|
||||
> **强烈建议先用 SSH key 替代密码**:`ssh-copy-id root@116.62.83.240` 后把 `server.env` 里的 `SSHPASS=` 注释掉即可。脚本会自动切回 SSH key 通道。
|
||||
|
||||
### 2. 全量同步并启动
|
||||
|
||||
```bash
|
||||
./sync-to-server.sh up
|
||||
```
|
||||
|
||||
脚本流程:
|
||||
1. SSH 登录 116.62.83.240,确保 `/root/integral-shop` 目录存在;
|
||||
2. rsync 增量同步整个工程(自动排除 `node_modules / target / .git / runtime/logs / MER-2.2_2601` 等大目录);
|
||||
3. 在远端 `cd /root/integral-shop/deploy/docker && docker compose build && docker compose up -d`;
|
||||
4. 输出 `docker compose ps` 结果。
|
||||
|
||||
首次会要求你先在远端把 `.env / integral-resell/.env` 改成真实值(如未做,运行下面这一步):
|
||||
|
||||
```bash
|
||||
./bootstrap-remote-env.sh # 在远端基于模板创建 .env,再 ssh 上去填写
|
||||
```
|
||||
|
||||
### 3. 日常运维(无需登录服务器)
|
||||
|
||||
```bash
|
||||
./remote-up.sh ps # 服务状态
|
||||
./remote-up.sh logs single-admin-api # 跟随日志
|
||||
./remote-up.sh restart single-front-api # 重启某服务
|
||||
./remote-up.sh build single-admin-web # 只重建某镜像
|
||||
./remote-up.sh exec single-admin-api sh # 进容器
|
||||
./remote-up.sh ssh # 直接登录服务器
|
||||
./sync-to-server.sh up # 改了代码后再同步+重启
|
||||
```
|
||||
|
||||
### 4. 端口规划与宝塔放行
|
||||
|
||||
宿主机暴露端口(来自 `.env.example`,可改):
|
||||
|
||||
| 端口 | 服务 | 用途 |
|
||||
|------|------|------|
|
||||
| 18080 | `integral-h5` | 积分商城 H5 |
|
||||
| 18081 | `single-admin-web` | 寄卖管理后台 |
|
||||
| 18082 | `single-h5` | 寄卖用户 H5 |
|
||||
| 6379 | `redis` | (生产建议关闭)`REDIS_HOST_PORT=` 留空即不暴露 |
|
||||
|
||||
**安全组 / 防火墙**:阿里云安全组放通 18080-18082;宝塔面板 → 安全 → 放行同样端口。**强烈建议在前面再挂一层域名 + SSL**(宝塔自带 Nginx 反代 / 阿里云 SLB / Cloudflare 都行)。
|
||||
|
||||
### 5. 复用宿主已有 Redis 8.0.5(可选)
|
||||
|
||||
若想节省一份 Redis,直接用宿主机的 Redis:
|
||||
|
||||
```bash
|
||||
# 1) 远端编辑 deploy/docker/.env
|
||||
REDIS_PASSWORD=宿主-Redis-的密码
|
||||
|
||||
# 2) 远端编辑 deploy/docker/docker-compose.yml
|
||||
# a. 注释掉 redis: 整个服务块(包括 healthcheck)
|
||||
# b. single-admin-api / single-front-api 把 REDIS_HOST: redis 改成 172.17.0.1
|
||||
# (docker bridge 网关;宝塔默认端口 6379;先确保 Redis 监听 0.0.0.0 且防火墙放行 6379 to docker subnet)
|
||||
# c. integral-resell/.env 把 REDIS_HOST=redis 同步改为 172.17.0.1
|
||||
|
||||
# 3) 同步重启
|
||||
./sync-to-server.sh up
|
||||
```
|
||||
|
||||
### 6. 阿里云 RDS 白名单
|
||||
|
||||
到 RDS 控制台 → 白名单 → 加入 `116.62.83.240`(如果 RDS 与 ECS 同 VPC 则用内网地址 + 内网白名单更优)。
|
||||
|
||||
### 7. 数据库初始化
|
||||
|
||||
```bash
|
||||
# 本地把 SQL 上传到服务器(默认 db/ 已被 rsync 同步过去)
|
||||
./remote-up.sh ssh
|
||||
# 远端
|
||||
mysql -h <RDS_HOST> -u <RDS_USER> -p<RDS_PASS> < /root/integral-shop/db/yangtangyoupin.sql
|
||||
mysql -h <RDS_HOST> -u <RDS_USER> -p<RDS_PASS> < /root/integral-shop/db/shop22-v2.sql
|
||||
```
|
||||
|
||||
也可以用宝塔自带的 phpMyAdmin 直接连 RDS 导入。
|
||||
|
||||
---
|
||||
|
||||
## 十一、各文件清单一览
|
||||
|
||||
参见 `deploy/docker/` 下:
|
||||
|
||||
- `docker-compose.yml` ← 编排入口
|
||||
- `.env.example` ← 环境变量模板
|
||||
- `redis/redis.conf`
|
||||
- `integral-resell/houtai.Dockerfile`
|
||||
- `integral-resell/h5.Dockerfile`
|
||||
- `integral-resell/nginx-h5.conf`
|
||||
- `integral-resell/.env.template`
|
||||
- `integral-resell/docker-entrypoint-h5.sh`
|
||||
- `single-shop/admin-api.Dockerfile`
|
||||
- `single-shop/front-api.Dockerfile`
|
||||
- `single-shop/admin-web.Dockerfile`
|
||||
- `single-shop/h5.Dockerfile`
|
||||
- `single-shop/application-docker.yml`
|
||||
- `single-shop/nginx-admin-web.conf`
|
||||
- `single-shop/nginx-h5.conf`
|
||||
- `scripts/server.env.example` ← 远端 SSH 配置模板
|
||||
- `scripts/sync-to-server.sh` ← rsync 增量同步 + 触发远端部署
|
||||
- `scripts/remote-up.sh` ← 远端 `docker compose` 操作(up/build/restart/logs/ps/exec/ssh)
|
||||
- `scripts/bootstrap-remote-env.sh` ← 首次在远端生成 .env 模板
|
||||
|
||||
每个文件都已生成可直接使用的版本。
|
||||
@@ -3,16 +3,18 @@ ENV = 'development'
|
||||
|
||||
# base api
|
||||
# VUE_APP_BASE_API = '/dev-api'
|
||||
# VUE_APP_BASE_API = 'http://127.0.0.1:30032'
|
||||
# VUE_APP_BASE_API = 'http://127.0.0.1:30032'
|
||||
# VUE_APP_BASE_API = 'https://jfadmin.suzhouyuqi.com'
|
||||
# VUE_APP_BASE_API = 'http://jfanyueadmin.szxingming.com'
|
||||
# VUE_APP_BASE_API = 'http://jfadmin.wenjinhui.com'
|
||||
# VUE_APP_BASE_API = 'http://jfadmin-bsy.bosenyuan.com'
|
||||
# sxsy80 项目(太原树英商贸)
|
||||
VUE_APP_BASE_API = 'https://sxsy-jf.cichude.com'
|
||||
# byjyw149 项目(宝应金雅文商贸)
|
||||
# VUE_APP_BASE_API = 'https://jf.jinyawen.com'
|
||||
# shjjy153 项目
|
||||
# VUE_APP_BASE_API = 'http://jjy-jfadmin.fwxgpt.com'
|
||||
|
||||
# czcf82 项目(池州春芳商贸)
|
||||
# VUE_APP_BASE_API = 'https://czcf-jf.uj345.com'
|
||||
# czleilei240 项目
|
||||
VUE_APP_BASE_API = 'https://leilei-jf.czchunfang.com'
|
||||
|
||||
# hapr191 项目(淮安鹏然商贸)
|
||||
# VUE_APP_BASE_API = 'http://jfadmin.hapengran.com'
|
||||
|
||||
@@ -6,13 +6,15 @@ ENV = 'production'
|
||||
# VUE_APP_BASE_API = 'http://127.0.0.1:8080'
|
||||
# VUE_APP_BASE_API = 'https://jf.suzhouyuqi.com'
|
||||
# miao33 项目
|
||||
# VUE_APP_BASE_API = 'http://jfadmin.xiashengjun.com'
|
||||
# VUE_APP_BASE_API = 'http://jfadmin.xiashengjun.com'
|
||||
|
||||
# sxsy80 项目(太原树英商贸)
|
||||
VUE_APP_BASE_API = 'https://sxsy-jf.cichude.com'
|
||||
# byjyw149 项目(宝应金雅文商贸)
|
||||
# VUE_APP_BASE_API = 'https://jf.jinyawen.com'
|
||||
# shjjy153 项目
|
||||
# VUE_APP_BASE_API = 'http://jjy-jfadmin.fwxgpt.com'
|
||||
|
||||
# czcf82 项目(池州春芳商贸)
|
||||
# VUE_APP_BASE_API = 'https://czcf-jf.uj345.com'
|
||||
# czleilei240 项目
|
||||
VUE_APP_BASE_API = 'https://leilei-jf.czchunfang.com'
|
||||
|
||||
# hapr191 项目(淮安鹏然商贸)
|
||||
# VUE_APP_BASE_API = 'http://jfadmin.hapengran.com'
|
||||
@@ -20,6 +22,5 @@ VUE_APP_BASE_API = 'https://sxsy-jf.cichude.com'
|
||||
# shccd159 项目
|
||||
# VUE_APP_BASE_API = 'http://ccd-jfadmin.cichude.com'
|
||||
|
||||
|
||||
# VUE_APP_BASE_API = 'http://jfanyueadmin.szxingming.com'
|
||||
# VUE_APP_BASE_API = 'http://jfadmin-bsy.bosenyuan.com'
|
||||
|
||||
@@ -39,3 +39,59 @@ export function getExternalIntegralLog(data) {
|
||||
data: body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 寄卖订单 列表(免认证)
|
||||
*/
|
||||
export function getExternalWaOrderList(params) {
|
||||
return requestNoAuth({
|
||||
url: 'external/integral/wa-order/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 寄卖订单 详情(免认证)
|
||||
*/
|
||||
export function getExternalWaOrderInfo(id) {
|
||||
return requestNoAuth({
|
||||
url: 'external/integral/wa-order/info',
|
||||
method: 'get',
|
||||
params: { id },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队每日对账报表(免认证)
|
||||
*/
|
||||
export function getExternalTeamDailyReport(params) {
|
||||
return requestNoAuth({
|
||||
url: 'external/integral/team-report/daily',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队每日对账报表 - Excel 导出(免认证)
|
||||
*/
|
||||
export function exportExternalTeamDailyReport(params) {
|
||||
return requestNoAuth({
|
||||
url: 'external/integral/team-report/daily/export',
|
||||
method: 'get',
|
||||
params,
|
||||
responseType: 'blob',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表(免认证)
|
||||
*/
|
||||
export function getExternalGrabUserList(params) {
|
||||
return requestNoAuth({
|
||||
url: 'external/integral/grab-user/list',
|
||||
method: 'get',
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,6 +24,24 @@ const integralExternalRouter = {
|
||||
name: 'IntegralExternalUserDetail',
|
||||
meta: { title: '用户积分明细' },
|
||||
},
|
||||
{
|
||||
path: 'wa-order',
|
||||
component: () => import('@/views/integral-external/wa-order/index'),
|
||||
name: 'IntegralExternalWaOrder',
|
||||
meta: { title: '寄卖订单' },
|
||||
},
|
||||
{
|
||||
path: 'team-report',
|
||||
component: () => import('@/views/integral-external/team-report/index'),
|
||||
name: 'IntegralExternalTeamReport',
|
||||
meta: { title: '团队日报' },
|
||||
},
|
||||
{
|
||||
path: 'grab-user',
|
||||
component: () => import('@/views/integral-external/grab-user/index'),
|
||||
name: 'IntegralExternalGrabUser',
|
||||
meta: { title: '今日抢单用户' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,11 @@ service.interceptors.request.use(
|
||||
// 响应拦截器 — 不拦截 401 跳转
|
||||
service.interceptors.response.use(
|
||||
(response) => {
|
||||
// Blob / arraybuffer 等二进制响应直接透传,不做业务码拆包
|
||||
const responseType = response.config && response.config.responseType;
|
||||
if (responseType === 'blob' || responseType === 'arraybuffer') {
|
||||
return response.data;
|
||||
}
|
||||
const res = response.data;
|
||||
if (res.code !== 0 && res.code !== 200) {
|
||||
Message({
|
||||
|
||||
@@ -121,6 +121,27 @@ export default {
|
||||
url: '/integral-external/user/integral-detail',
|
||||
alwaysShow: true,
|
||||
},
|
||||
{
|
||||
bgColor: '#F56C6C',
|
||||
icon: 'icondingdanguanli',
|
||||
title: '寄卖订单',
|
||||
url: '/integral-external/wa-order',
|
||||
alwaysShow: true,
|
||||
},
|
||||
{
|
||||
bgColor: '#67C23A',
|
||||
icon: 'iconfenxiaoguanli',
|
||||
title: '团队日报',
|
||||
url: '/integral-external/team-report',
|
||||
alwaysShow: true,
|
||||
},
|
||||
{
|
||||
bgColor: '#E6A23C',
|
||||
icon: 'iconhuiyuanguanli',
|
||||
title: '今日抢单用户',
|
||||
url: '/integral-external/grab-user',
|
||||
alwaysShow: true,
|
||||
},
|
||||
],
|
||||
statisticData: [
|
||||
{ title: '待发货订单', num: 0, path: '/order/index', perms: ['admin:order:list'] },
|
||||
|
||||
297
backend-adminend/src/views/integral-external/grab-user/index.vue
Normal file
297
backend-adminend/src/views/integral-external/grab-user/index.vue
Normal file
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<div class="divBox relative">
|
||||
<!-- 顶部搜索区 -->
|
||||
<el-card class="box-card">
|
||||
<div class="container">
|
||||
<el-form
|
||||
ref="searchForm"
|
||||
:model="tableFrom"
|
||||
inline
|
||||
size="small"
|
||||
label-width="100px"
|
||||
>
|
||||
<el-form-item label="用户ID:">
|
||||
<el-input
|
||||
v-model="tableFrom.uid"
|
||||
placeholder="请输入用户ID"
|
||||
class="selWidth"
|
||||
clearable
|
||||
@keyup.enter.native="seachList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系方式:">
|
||||
<el-input
|
||||
v-model="tableFrom.mobile"
|
||||
placeholder="请输入手机号 / 账号"
|
||||
class="selWidth"
|
||||
clearable
|
||||
@keyup.enter.native="seachList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="上级ID:">
|
||||
<el-input
|
||||
v-model="tableFrom.pid"
|
||||
placeholder="请输入上级ID"
|
||||
class="selWidth"
|
||||
clearable
|
||||
@keyup.enter.native="seachList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" @click="seachList">查询</el-button>
|
||||
<el-button icon="el-icon-refresh-left" @click="resetHandler">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 主表 -->
|
||||
<el-card class="box-card mt10">
|
||||
<div class="header-actions">
|
||||
<el-button size="mini" icon="el-icon-refresh" circle @click="getList" />
|
||||
<el-button size="mini" icon="el-icon-upload2" circle @click="exportCsv" />
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="listLoading"
|
||||
:data="tableData.data"
|
||||
size="mini"
|
||||
class="table"
|
||||
highlight-current-row
|
||||
:header-cell-style="{ fontWeight: 'bold' }"
|
||||
>
|
||||
<el-table-column prop="id" label="用户ID" min-width="80" />
|
||||
<el-table-column prop="username" label="账号" min-width="130">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.username || scope.row.mobile || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="nickname" label="昵称" min-width="100" show-overflow-tooltip>
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.nickname || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="昨日卖/今日买" min-width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.prevSellCnt || 0 }}/{{ scope.row.todayBuyCnt || 0 }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="合同" min-width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-link
|
||||
v-if="scope.row.contract"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
@click="openContract(scope.row.contract)"
|
||||
>查看</el-link>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mobile" label="联系方式" min-width="130" />
|
||||
<el-table-column label="上级ID" min-width="90" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.pid || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最高可抢单数" min-width="110" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.maxOrder ? scope.row.maxOrder : '未设置' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="todayBuyAmount" label="今日购买总金额" min-width="130" align="right" />
|
||||
<el-table-column prop="todaySellAmount" label="今日卖出总金额" min-width="130" align="right" />
|
||||
<el-table-column label="用户等级" min-width="100" align="center">
|
||||
<template slot-scope="scope">
|
||||
<span :style="{ color: levelColor(scope.row.level) }">
|
||||
{{ scope.row.levelName || '普通用户' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="money" label="余额" min-width="100" align="right" />
|
||||
<el-table-column prop="coupon" label="优惠券" min-width="100" align="right" />
|
||||
<el-table-column prop="selfBonus" label="个人奖金" min-width="100" align="right" />
|
||||
<el-table-column prop="shareBonus" label="推广奖金" min-width="100" align="right" />
|
||||
<el-table-column label="状态" min-width="80" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="scope.row.status === 1 ? 'success' : 'danger'" size="mini">
|
||||
{{ scope.row.statusStr || (scope.row.status === 1 ? '正常' : '禁用') }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="updatedAt" label="更新时间" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="操作" min-width="160" fixed="right" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button type="text" size="small" @click="onEditPid(scope.row)">修改上级</el-button>
|
||||
<el-button type="text" size="small" @click="onContractRenew(scope.row)">合同重签</el-button>
|
||||
<el-button type="text" size="small" @click="onEdit(scope.row)">编辑</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="block mt20">
|
||||
<el-pagination
|
||||
:page-sizes="[15, 30, 45, 60]"
|
||||
:page-size="tableFrom.limit"
|
||||
:current-page="tableFrom.page"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="tableData.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 写操作提示对话框(外部页面默认不直接修改资金类字段) -->
|
||||
<el-dialog title="提示" :visible.sync="writeTipVisible" width="380px" append-to-body>
|
||||
<span>{{ writeTipText }}</span>
|
||||
<span slot="footer">
|
||||
<el-button type="primary" size="small" @click="writeTipVisible = false">知道了</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getExternalGrabUserList } from '@/api/integralExternal';
|
||||
|
||||
export default {
|
||||
name: 'IntegralExternalGrabUser',
|
||||
data() {
|
||||
return {
|
||||
listLoading: false,
|
||||
tableData: { data: [], total: 0 },
|
||||
tableFrom: {
|
||||
uid: '',
|
||||
mobile: '',
|
||||
pid: '',
|
||||
page: 1,
|
||||
limit: 15,
|
||||
},
|
||||
writeTipVisible: false,
|
||||
writeTipText: '',
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.getList();
|
||||
},
|
||||
methods: {
|
||||
getList() {
|
||||
this.listLoading = true;
|
||||
const params = { ...this.tableFrom };
|
||||
// 空字段不发送
|
||||
Object.keys(params).forEach((k) => {
|
||||
if (params[k] === '' || params[k] === null) delete params[k];
|
||||
});
|
||||
getExternalGrabUserList(params)
|
||||
.then((res) => {
|
||||
this.tableData.data = res.list || [];
|
||||
this.tableData.total = res.total || 0;
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
this.listLoading = false;
|
||||
});
|
||||
},
|
||||
seachList() {
|
||||
this.tableFrom.page = 1;
|
||||
this.getList();
|
||||
},
|
||||
resetHandler() {
|
||||
this.tableFrom = { uid: '', mobile: '', pid: '', page: 1, limit: 15 };
|
||||
this.getList();
|
||||
},
|
||||
handleSizeChange(val) {
|
||||
this.tableFrom.limit = val;
|
||||
this.getList();
|
||||
},
|
||||
handleCurrentChange(val) {
|
||||
this.tableFrom.page = val;
|
||||
this.getList();
|
||||
},
|
||||
openContract(url) {
|
||||
if (!url) return;
|
||||
window.open(url, '_blank');
|
||||
},
|
||||
levelColor(level) {
|
||||
switch (level) {
|
||||
case 2:
|
||||
return '#E6A23C';
|
||||
case 3:
|
||||
return '#67C23A';
|
||||
default:
|
||||
return '#409EFF';
|
||||
}
|
||||
},
|
||||
onEditPid() {
|
||||
this.writeTipText = '请在管理后台「用户管理」中修改上级;本页面不支持写操作。';
|
||||
this.writeTipVisible = true;
|
||||
},
|
||||
onContractRenew() {
|
||||
this.writeTipText = '请在管理后台「用户管理」中进行合同重签;本页面不支持写操作。';
|
||||
this.writeTipVisible = true;
|
||||
},
|
||||
onEdit() {
|
||||
this.writeTipText = '请在管理后台「用户管理」中编辑;本页面不支持写操作。';
|
||||
this.writeTipVisible = true;
|
||||
},
|
||||
/**
|
||||
* 简易 CSV 导出(仅当前页)
|
||||
* 生产环境如需全量导出建议改为后端 /grab-user/list/export
|
||||
*/
|
||||
exportCsv() {
|
||||
const rows = this.tableData.data || [];
|
||||
if (rows.length === 0) {
|
||||
this.$message.info('当前页没有数据可导出');
|
||||
return;
|
||||
}
|
||||
const headers = [
|
||||
'用户ID', '账号', '昵称', '昨日卖/今日买', '联系方式', '上级ID',
|
||||
'最高可抢单数', '今日购买总金额', '今日卖出总金额', '用户等级',
|
||||
'余额', '优惠券', '个人奖金', '推广奖金', '状态', '更新时间',
|
||||
];
|
||||
const lines = [headers.join(',')];
|
||||
rows.forEach((r) => {
|
||||
const fields = [
|
||||
r.id,
|
||||
r.username || r.mobile || '',
|
||||
r.nickname || '',
|
||||
`${r.prevSellCnt || 0}/${r.todayBuyCnt || 0}`,
|
||||
r.mobile || '',
|
||||
r.pid || '',
|
||||
r.maxOrder || '',
|
||||
r.todayBuyAmount || '0',
|
||||
r.todaySellAmount || '0',
|
||||
r.levelName || '普通用户',
|
||||
r.money || '0',
|
||||
r.coupon || '0',
|
||||
r.selfBonus || '0',
|
||||
r.shareBonus || '0',
|
||||
r.statusStr || (r.status === 1 ? '正常' : '禁用'),
|
||||
r.updatedAt || '',
|
||||
].map((v) => `"${String(v).replace(/"/g, '""')}"`);
|
||||
lines.push(fields.join(','));
|
||||
});
|
||||
const blob = new Blob(['' + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `今日抢单用户_${new Date().toISOString().slice(0, 10)}.csv`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mt10 { margin-top: 10px; }
|
||||
.mt20 { margin-top: 20px; }
|
||||
.selWidth { width: 220px; }
|
||||
.header-actions {
|
||||
text-align: right;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.block { text-align: right; }
|
||||
</style>
|
||||
@@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<div class="divBox relative">
|
||||
<!-- 条件区 -->
|
||||
<el-card class="box-card">
|
||||
<el-form
|
||||
ref="searchForm"
|
||||
:model="tableFrom"
|
||||
inline
|
||||
size="small"
|
||||
label-width="120px"
|
||||
>
|
||||
<el-form-item label="团队长 ID:">
|
||||
<el-input
|
||||
v-model.number="tableFrom.leaderId"
|
||||
placeholder="留空=全部"
|
||||
class="leaderIdWidth"
|
||||
clearable
|
||||
@keyup.enter.native="seachList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="日期 D:">
|
||||
<el-date-picker
|
||||
v-model="tableFrom.date"
|
||||
type="date"
|
||||
value-format="yyyy-MM-dd"
|
||||
placeholder="缺省=昨天"
|
||||
class="dateWidth"
|
||||
:picker-options="pickerOptions"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="含禁用成员:">
|
||||
<el-switch v-model="tableFrom.includeDisabled" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" @click="seachList">查询</el-button>
|
||||
<el-button icon="el-icon-refresh-left" @click="resetHandler">重置</el-button>
|
||||
<el-button
|
||||
icon="el-icon-download"
|
||||
:disabled="!tableFrom.leaderId"
|
||||
@click="exportExcel"
|
||||
>导出 Excel</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<div v-if="!tableFrom.leaderId" class="hint">
|
||||
Tips: 团队长 ID 留空时返回所有团队的日报,导出 Excel 仅支持单团队。
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 跨团队总计(仅在多团队模式下显示) -->
|
||||
<el-card v-if="report && report.teams && report.teams.length > 1" class="box-card mt10">
|
||||
<div class="report-header">
|
||||
<span class="title">
|
||||
全部团队总计 · {{ report.teamCount }} 个团队 · 共 {{ report.totalMemberCount }} 人 · {{ report.date }}
|
||||
</span>
|
||||
<span class="rate-info">
|
||||
服务费率 {{ report.serviceRate }} · E 积分率 {{ report.eScoreRate }}
|
||||
</span>
|
||||
</div>
|
||||
<el-row :gutter="16" class="summary-row">
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">{{ report.previousDate }} 买单合计</div>
|
||||
<div class="summary-value">{{ formatNum(report.grandSummary.prevBuy) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">{{ report.date }} 卖单合计</div>
|
||||
<div class="summary-value">{{ formatNum(report.grandSummary.todaySell) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">{{ report.date }} 买单合计</div>
|
||||
<div class="summary-value">{{ formatNum(report.grandSummary.todayBuy) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">实际收付合计</div>
|
||||
<div
|
||||
class="summary-value"
|
||||
:class="actualClass(report.grandSummary.actual)"
|
||||
>{{ formatNum(report.grandSummary.actual) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</el-card>
|
||||
|
||||
<!-- 各团队子报表 -->
|
||||
<template v-if="report && report.teams && report.teams.length">
|
||||
<el-card
|
||||
v-for="team in report.teams"
|
||||
:key="team.leaderId"
|
||||
class="box-card mt10 team-card"
|
||||
>
|
||||
<div class="report-header">
|
||||
<span class="title">
|
||||
团队长 {{ team.leaderNickname || team.teamCode || '-' }}
|
||||
· {{ team.memberCount }} 人 · {{ team.date }}
|
||||
</span>
|
||||
<el-button
|
||||
v-if="!tableFrom.leaderId"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-download"
|
||||
@click="exportTeamExcel(team)"
|
||||
>导出该团队</el-button>
|
||||
</div>
|
||||
|
||||
<!-- 团队级 4 卡片摘要(仅在单团队模式 / 想看每个团队也可以看;保留显示) -->
|
||||
<el-row v-if="report.teams.length === 1" :gutter="16" class="summary-row">
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">{{ team.previousDate }} 买单合计</div>
|
||||
<div class="summary-value">{{ formatNum(team.summary.prevBuy) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">{{ team.date }} 卖单合计</div>
|
||||
<div class="summary-value">{{ formatNum(team.summary.todaySell) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">{{ team.date }} 买单合计</div>
|
||||
<div class="summary-value">{{ formatNum(team.summary.todayBuy) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div class="summary-card">
|
||||
<div class="summary-label">实际收付合计</div>
|
||||
<div
|
||||
class="summary-value"
|
||||
:class="actualClass(team.summary.actual)"
|
||||
>{{ formatNum(team.summary.actual) }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-table
|
||||
:data="team.rows"
|
||||
size="mini"
|
||||
class="table"
|
||||
border
|
||||
highlight-current-row
|
||||
:header-cell-style="headerCellStyle"
|
||||
:show-summary="true"
|
||||
:summary-method="(meta) => getTeamSummaries(meta, team)"
|
||||
>
|
||||
<el-table-column label="昵称" prop="nickname" min-width="120">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ scope.row.nickname || '-' }}</span>
|
||||
<el-tag v-if="scope.row.status === 0" size="mini" type="info" style="margin-left: 4px">已禁用</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="prevBuyLabel(team)" prop="prevBuy" min-width="110" align="right">
|
||||
<template slot-scope="scope">{{ formatNum(scope.row.prevBuy) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="todaySellLabel(team)" prop="todaySell" min-width="110" align="right">
|
||||
<template slot-scope="scope">{{ formatNum(scope.row.todaySell) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="todayBuyLabel(team)" prop="todayBuy" min-width="110" align="right">
|
||||
<template slot-scope="scope">{{ formatNum(scope.row.todayBuy) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="serviceFeeLabel(team)" prop="serviceFee" min-width="120" align="right">
|
||||
<template slot-scope="scope">{{ formatNum(scope.row.serviceFee) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="E积分" prop="eScore" min-width="90" align="right">
|
||||
<template slot-scope="scope">{{ formatNum(scope.row.eScore) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="实际收付" prop="actual" min-width="110" align="right">
|
||||
<template slot-scope="scope">
|
||||
<span :class="actualClass(scope.row.actual)">{{ formatNum(scope.row.actual) }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="团队" min-width="120" align="center">
|
||||
<template>
|
||||
<span>{{ team.leaderNickname || team.teamCode || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="备注" min-width="180">
|
||||
<template slot-scope="scope">
|
||||
<el-input
|
||||
v-model="scope.row.remark"
|
||||
size="mini"
|
||||
placeholder="可填写(仅当前会话有效)"
|
||||
clearable
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!team.rows || team.rows.length === 0" class="empty-tip">该团队当日无成员数据</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<el-card v-else-if="report" class="box-card mt10">
|
||||
<div class="empty-tip">未查询到任何团队数据。</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { saveAs } from 'file-saver';
|
||||
import {
|
||||
getExternalTeamDailyReport,
|
||||
exportExternalTeamDailyReport,
|
||||
} from '@/api/integralExternal';
|
||||
|
||||
export default {
|
||||
name: 'IntegralExternalTeamReport',
|
||||
data() {
|
||||
return {
|
||||
listLoading: false,
|
||||
report: null,
|
||||
tableFrom: {
|
||||
leaderId: '',
|
||||
date: this.yesterdayStr(),
|
||||
includeDisabled: false,
|
||||
},
|
||||
pickerOptions: {
|
||||
disabledDate(t) {
|
||||
return t.getTime() > Date.now();
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
// 进入页面默认拉取所有团队(不传 leaderId)
|
||||
this.seachList();
|
||||
},
|
||||
methods: {
|
||||
yesterdayStr() {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - 1);
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${m}-${day}`;
|
||||
},
|
||||
headerCellStyle() {
|
||||
return {
|
||||
background: '#C6EFCE',
|
||||
color: '#000',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
};
|
||||
},
|
||||
seachList() {
|
||||
this.listLoading = true;
|
||||
const params = {
|
||||
date: this.tableFrom.date,
|
||||
includeDisabled: this.tableFrom.includeDisabled,
|
||||
};
|
||||
if (this.tableFrom.leaderId) {
|
||||
params.leaderId = this.tableFrom.leaderId;
|
||||
}
|
||||
getExternalTeamDailyReport(params)
|
||||
.then((res) => {
|
||||
this.report = res || null;
|
||||
})
|
||||
.catch(() => {
|
||||
this.report = null;
|
||||
})
|
||||
.finally(() => {
|
||||
this.listLoading = false;
|
||||
});
|
||||
},
|
||||
resetHandler() {
|
||||
this.tableFrom = {
|
||||
leaderId: '',
|
||||
date: this.yesterdayStr(),
|
||||
includeDisabled: false,
|
||||
};
|
||||
this.seachList();
|
||||
},
|
||||
formatNum(v) {
|
||||
if (v === null || v === undefined || v === '') return '0';
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return v;
|
||||
const sign = n < 0 ? '-' : '';
|
||||
const abs = Math.abs(n);
|
||||
const fixed = abs.toFixed(2);
|
||||
const [intPart, decPart] = fixed.split('.');
|
||||
const withSep = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return `${sign}${withSep}.${decPart}`;
|
||||
},
|
||||
actualClass(v) {
|
||||
const n = Number(v);
|
||||
if (Number.isNaN(n)) return '';
|
||||
if (n < 0) return 'text-danger';
|
||||
if (n === 0) return 'text-muted';
|
||||
return '';
|
||||
},
|
||||
prevBuyLabel(team) {
|
||||
return team && team.previousDate ? `${team.previousDate.slice(5)} 买单` : 'D-1 买单';
|
||||
},
|
||||
todaySellLabel(team) {
|
||||
return team && team.date ? `${team.date.slice(5)} 卖单` : 'D 卖单';
|
||||
},
|
||||
todayBuyLabel(team) {
|
||||
return team && team.date ? `${team.date.slice(5)} 买单` : 'D 买单';
|
||||
},
|
||||
serviceFeeLabel(team) {
|
||||
return team && team.serviceRate
|
||||
? `服务费*${team.serviceRate}`
|
||||
: `服务费*${this.report ? this.report.serviceRate : '0.02'}`;
|
||||
},
|
||||
/** 团队子表的小计行(直接取 team.summary,不在前端逐行累加) */
|
||||
getTeamSummaries({ columns }, team) {
|
||||
const result = new Array(columns.length).fill('');
|
||||
if (!team || !team.summary) return result;
|
||||
const s = team.summary;
|
||||
const map = {
|
||||
nickname: '小计',
|
||||
prevBuy: s.prevBuy,
|
||||
todaySell: s.todaySell,
|
||||
todayBuy: s.todayBuy,
|
||||
serviceFee:s.serviceFee,
|
||||
eScore: s.eScore,
|
||||
actual: s.actual,
|
||||
};
|
||||
columns.forEach((col, idx) => {
|
||||
const v = map[col.property];
|
||||
if (v !== undefined && v !== null) {
|
||||
result[idx] = col.property === 'nickname' ? v : this.formatNum(v);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
},
|
||||
/** 工具栏里的"导出 Excel"按钮:使用当前 tableFrom 的 leaderId */
|
||||
exportExcel() {
|
||||
if (!this.tableFrom.leaderId) {
|
||||
this.$message.warning('Excel 导出需要指定团队长 ID');
|
||||
return;
|
||||
}
|
||||
this.doExport(this.tableFrom.leaderId, '');
|
||||
},
|
||||
/** 多团队卡片右上角的"导出该团队"按钮 */
|
||||
exportTeamExcel(team) {
|
||||
if (!team || !team.leaderId) return;
|
||||
this.doExport(team.leaderId, team.leaderNickname || team.teamCode || '');
|
||||
},
|
||||
doExport(leaderId, nickname) {
|
||||
this.listLoading = true;
|
||||
exportExternalTeamDailyReport({
|
||||
leaderId,
|
||||
date: this.tableFrom.date,
|
||||
includeDisabled: this.tableFrom.includeDisabled,
|
||||
})
|
||||
.then((blob) => {
|
||||
const file = blob instanceof Blob ? blob : new Blob([blob], {
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
});
|
||||
const date = this.tableFrom.date || this.yesterdayStr();
|
||||
const name = nickname || `leader${leaderId}`;
|
||||
saveAs(file, `团队${name}_${date}.xlsx`);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
this.listLoading = false;
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mt10 { margin-top: 10px; }
|
||||
.selWidth { width: 240px; }
|
||||
.leaderIdWidth { width: 100px; }
|
||||
.dateWidth { width: 160px; }
|
||||
.hint {
|
||||
margin-top: 4px;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.team-card {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 12px;
|
||||
.title {
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
}
|
||||
.rate-info {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
.summary-row {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.summary-card {
|
||||
background: #f5f7fa;
|
||||
border: 1px solid #ebeef5;
|
||||
border-radius: 4px;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.summary-label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.summary-value {
|
||||
font-size: 22px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
.text-danger { color: #C00000; font-weight: bold; }
|
||||
.text-muted { color: #909399; }
|
||||
.empty-tip {
|
||||
text-align: center;
|
||||
color: #909399;
|
||||
padding: 30px 0;
|
||||
}
|
||||
</style>
|
||||
747
backend-adminend/src/views/integral-external/wa-order/index.vue
Normal file
747
backend-adminend/src/views/integral-external/wa-order/index.vue
Normal file
@@ -0,0 +1,747 @@
|
||||
<template>
|
||||
<div class="divBox relative">
|
||||
<!-- 顶部搜索区 -->
|
||||
<el-card class="box-card">
|
||||
<el-form size="small" inline label-width="100px">
|
||||
<el-form-item label="订单状态:">
|
||||
<el-radio-group v-model="uiStatus" type="button" size="mini" @change="onStatusChange">
|
||||
<el-radio-button label="all">全部</el-radio-button>
|
||||
<el-radio-button label="unPaid">待付款</el-radio-button>
|
||||
<el-radio-button label="paid">已支付</el-radio-button>
|
||||
<el-radio-button label="complete">交易完成</el-radio-button>
|
||||
<el-radio-button label="cancel">已取消</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="时间字段:">
|
||||
<el-select
|
||||
v-model="timeField"
|
||||
placeholder="选择时间维度"
|
||||
style="width: 140px"
|
||||
size="mini"
|
||||
@change="seachList"
|
||||
>
|
||||
<el-option label="抢购时间" value="buyTime" />
|
||||
<el-option label="支付时间" value="payTime" />
|
||||
<el-option label="完成时间" value="confirmTime" />
|
||||
<el-option label="创建时间" value="createdAt" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="日期范围:">
|
||||
<el-date-picker
|
||||
v-model="timeVal"
|
||||
value-format="yyyy-MM-dd"
|
||||
format="yyyy-MM-dd"
|
||||
size="mini"
|
||||
type="daterange"
|
||||
placeholder="自定义时间"
|
||||
style="width: 220px"
|
||||
@change="onTimeChange"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="订单号:">
|
||||
<el-input
|
||||
v-model="tableFrom.orderSn"
|
||||
placeholder="请输入订单号"
|
||||
clearable
|
||||
class="selWidth"
|
||||
size="mini"
|
||||
@keyup.enter.native="seachList"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="买家ID:">
|
||||
<el-input
|
||||
v-model.number="tableFrom.buyerId"
|
||||
placeholder="买家用户ID"
|
||||
clearable
|
||||
class="selWidth"
|
||||
size="mini"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="卖家ID:">
|
||||
<el-input
|
||||
v-model.number="tableFrom.sellerId"
|
||||
placeholder="卖家ID(0=平台)"
|
||||
clearable
|
||||
class="selWidth"
|
||||
size="mini"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="是否转拍:">
|
||||
<el-select
|
||||
v-model="tableFrom.isResell"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
style="width: 100px"
|
||||
size="mini"
|
||||
@change="seachList"
|
||||
>
|
||||
<el-option :value="1" label="是" />
|
||||
<el-option :value="0" label="否" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="seachList">查询</el-button>
|
||||
<el-button icon="el-icon-refresh-left" size="mini" @click="resetHandler">重置</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<!-- 操作按钮区 -->
|
||||
<el-card class="box-card mt10">
|
||||
<div class="header-actions">
|
||||
<el-popover
|
||||
placement="bottom-end"
|
||||
width="300"
|
||||
trigger="click"
|
||||
v-model="columnPopoverVisible"
|
||||
>
|
||||
<div class="column-setting">
|
||||
<div class="column-setting-actions">
|
||||
<el-button size="mini" type="text" @click="selectAllColumns">全选</el-button>
|
||||
<el-button size="mini" type="text" @click="invertColumns">反选</el-button>
|
||||
<el-button size="mini" type="text" @click="resetColumns">恢复默认</el-button>
|
||||
</div>
|
||||
<el-checkbox-group v-model="visibleColumns" class="column-checkbox-group">
|
||||
<el-checkbox
|
||||
v-for="col in columnsConfig"
|
||||
:key="col.prop"
|
||||
:label="col.prop"
|
||||
:disabled="col.fixed"
|
||||
>{{ col.label }}</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
<el-button slot="reference" size="mini" icon="el-icon-setting">列设置</el-button>
|
||||
</el-popover>
|
||||
<el-button size="mini" icon="el-icon-refresh" @click="getList" />
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="listLoading"
|
||||
:data="tableData.data"
|
||||
size="mini"
|
||||
class="table"
|
||||
highlight-current-row
|
||||
:header-cell-style="{ fontWeight: 'bold' }"
|
||||
>
|
||||
<template v-for="col in columnsConfig">
|
||||
<el-table-column
|
||||
v-if="col.prop !== 'actions' && visibleColumns.includes(col.prop)"
|
||||
:key="col.prop"
|
||||
:label="col.label"
|
||||
:min-width="col.width"
|
||||
:show-overflow-tooltip="col.tooltip"
|
||||
:align="col.align || 'left'"
|
||||
>
|
||||
<template slot-scope="scope">
|
||||
<!-- 商品图片单元格使用 el-image,其他文本走 formatCell -->
|
||||
<el-image
|
||||
v-if="col.prop === 'merchandiseImage' && scope.row.merchandiseImage"
|
||||
:src="scope.row.merchandiseImage"
|
||||
:preview-src-list="[scope.row.merchandiseImage]"
|
||||
style="width:48px;height:48px;border-radius:4px"
|
||||
fit="cover"
|
||||
/>
|
||||
<span v-else-if="col.prop === 'merchandiseImage'">-</span>
|
||||
<span v-else v-html="formatCell(col.prop, scope.row)" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
</template>
|
||||
<el-table-column label="操作" min-width="120" fixed="right" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-button type="text" size="small" @click="openDetail(scope.row.id)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="block mt20">
|
||||
<el-pagination
|
||||
:page-sizes="[15, 30, 45, 60]"
|
||||
:page-size="tableFrom.limit"
|
||||
:current-page="tableFrom.page"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:total="tableData.total"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="handleCurrentChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<el-drawer
|
||||
:visible.sync="drawerVisible"
|
||||
direction="rtl"
|
||||
size="680px"
|
||||
:destroy-on-close="true"
|
||||
:with-header="false"
|
||||
append-to-body
|
||||
custom-class="wa-order-drawer"
|
||||
@close="onDrawerClose"
|
||||
>
|
||||
<div v-loading="detailLoading" class="drawer-body">
|
||||
<!-- 顶部摘要:订单号 + 状态徽标 + 关闭 -->
|
||||
<div class="drawer-header">
|
||||
<div class="header-left">
|
||||
<div class="order-sn-row">
|
||||
<span class="order-sn-label">订单号</span>
|
||||
<span class="order-sn">{{ detail ? detail.orderSn : '-' }}</span>
|
||||
<el-button
|
||||
v-if="detail"
|
||||
size="mini"
|
||||
type="text"
|
||||
icon="el-icon-document-copy"
|
||||
@click="copyOrderSn"
|
||||
>复制</el-button>
|
||||
</div>
|
||||
<div class="tag-row">
|
||||
<el-tag v-if="detail" :type="statusTagType(detail)" size="small">{{ statusLabel(detail) }}</el-tag>
|
||||
<el-tag v-if="detail && detail.isResell === 1" type="warning" size="small" effect="plain">转拍</el-tag>
|
||||
<el-tag v-if="detail && detail.isCancel === 1" type="danger" size="small" effect="plain">已取消</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<el-button
|
||||
class="close-btn"
|
||||
icon="el-icon-close"
|
||||
type="text"
|
||||
@click="drawerVisible = false"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="detail" class="drawer-content">
|
||||
<!-- 时间链路 -->
|
||||
<el-card shadow="never" class="block-card">
|
||||
<div slot="header" class="block-title">订单时间链路</div>
|
||||
<el-steps :active="stepActive(detail)" finish-status="success" align-center>
|
||||
<el-step title="抢购下单" :description="detail.buyTime || '-'" />
|
||||
<el-step
|
||||
v-if="detail.isResell === 1"
|
||||
title="转拍生成"
|
||||
:description="detail.createdAt || '-'"
|
||||
/>
|
||||
<el-step title="支付" :description="detail.payTime || '-'" />
|
||||
<el-step title="完成" :description="detail.confirmTime || '-'" />
|
||||
</el-steps>
|
||||
</el-card>
|
||||
|
||||
<!-- 订单基础 -->
|
||||
<el-card shadow="never" class="block-card">
|
||||
<div slot="header" class="block-title">订单基础</div>
|
||||
<el-descriptions :column="2" :colon="false" size="small" class="info-desc">
|
||||
<el-descriptions-item label="订单ID">{{ detail.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="订单金额">
|
||||
<span class="price">¥{{ detail.totalMoney }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="商品ID">{{ detail.merchandiseId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品名称">{{ detail.merchandiseTitle || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="是否显示">{{ detail.isShow === 1 ? '显示' : '隐藏' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="原订单ID">{{ detail.oldId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="商品图片" :span="2">
|
||||
<el-image
|
||||
v-if="detail.merchandiseImage"
|
||||
:src="detail.merchandiseImage"
|
||||
:preview-src-list="[detail.merchandiseImage]"
|
||||
style="width:80px;height:80px;border-radius:4px"
|
||||
fit="cover"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 买家 -->
|
||||
<el-card shadow="never" class="block-card">
|
||||
<div slot="header" class="block-title">买家信息</div>
|
||||
<el-descriptions :column="2" :colon="false" size="small" class="info-desc">
|
||||
<el-descriptions-item label="买家ID">{{ detail.buyerId || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="买家昵称">{{ detail.buyerName || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收货人">{{ detail.consignee || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收货电话">{{ detail.phone || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="收货地区" :span="2">{{ joinArea(detail) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="详细地址" :span="2">{{ detail.address || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 卖家 -->
|
||||
<el-card shadow="never" class="block-card">
|
||||
<div slot="header" class="block-title">卖家信息</div>
|
||||
<el-descriptions :column="2" :colon="false" size="small" class="info-desc">
|
||||
<el-descriptions-item label="卖家ID">
|
||||
{{ detail.sellerId === 0 ? '0(平台)' : detail.sellerId }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="卖家昵称">
|
||||
{{ detail.sellerId === 0 ? '平台' : (detail.sellerName || '-') }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<!-- 支付与日志 -->
|
||||
<el-card shadow="never" class="block-card">
|
||||
<div slot="header" class="block-title">支付与日志</div>
|
||||
<el-descriptions :column="2" :colon="false" size="small" class="info-desc">
|
||||
<el-descriptions-item label="抢购时间">{{ detail.buyTime || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="支付时间">{{ detail.payTime || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="完成时间">{{ detail.confirmTime || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">{{ detail.createdAt || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="下单IP">{{ detail.buyIp || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item v-if="detail.isCancel === 1" label="取消IP">
|
||||
{{ detail.cancelIp || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="支付凭证" :span="2">
|
||||
<el-image
|
||||
v-if="detail.payImg"
|
||||
:src="detail.payImg"
|
||||
:preview-src-list="[detail.payImg]"
|
||||
style="width:80px;height:80px;border-radius:4px"
|
||||
fit="cover"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<div class="drawer-footer">
|
||||
<el-button @click="drawerVisible = false">关 闭</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {
|
||||
getExternalWaOrderList,
|
||||
getExternalWaOrderInfo,
|
||||
} from '@/api/integralExternal';
|
||||
|
||||
const STORAGE_KEY = 'waOrderColumns:external';
|
||||
|
||||
const COLUMN_DEFS = [
|
||||
{ prop: 'id', label: '订单ID', width: 80, defaultVisible: true, fixed: true, tooltip: false, align: 'left' },
|
||||
{ prop: 'orderSn', label: '订单号', width: 210, defaultVisible: true, fixed: true, tooltip: true },
|
||||
{ prop: 'merchandiseId', label: '商品ID', width: 90, defaultVisible: true, align: 'left' },
|
||||
{ prop: 'merchandiseTitle', label: '商品名称', width: 200, defaultVisible: true, tooltip: true },
|
||||
{ prop: 'merchandiseImage', label: '商品图片', width: 90, defaultVisible: false, align: 'center' },
|
||||
{ prop: 'totalMoney', label: '订单金额', width: 110, defaultVisible: true, align: 'right' },
|
||||
{ prop: 'statusStr', label: '订单状态', width: 110, defaultVisible: true, align: 'center' },
|
||||
{ prop: 'isResell', label: '是否转拍', width: 90, defaultVisible: false, align: 'center' },
|
||||
{ prop: 'buyerName', label: '买家昵称', width: 120, defaultVisible: true, tooltip: true },
|
||||
{ prop: 'buyerId', label: '买家ID', width: 90, defaultVisible: false },
|
||||
{ prop: 'sellerName', label: '卖家昵称', width: 120, defaultVisible: true, tooltip: true },
|
||||
{ prop: 'sellerId', label: '卖家ID', width: 90, defaultVisible: false },
|
||||
{ prop: 'consignee', label: '收货人', width: 100, defaultVisible: false },
|
||||
{ prop: 'phone', label: '收货电话', width: 130, defaultVisible: false },
|
||||
{ prop: 'areaText', label: '收货地区', width: 180, defaultVisible: false, tooltip: true },
|
||||
{ prop: 'address', label: '详细地址', width: 220, defaultVisible: false, tooltip: true },
|
||||
{ prop: 'buyTime', label: '抢购时间', width: 160, defaultVisible: true },
|
||||
{ prop: 'createdAt', label: '转拍/创建时间', width: 160, defaultVisible: false },
|
||||
{ prop: 'payTime', label: '支付时间', width: 160, defaultVisible: true },
|
||||
{ prop: 'confirmTime', label: '完成时间', width: 160, defaultVisible: true },
|
||||
{ prop: 'updatedAt', label: '更新时间', width: 160, defaultVisible: false },
|
||||
{ prop: 'buyIp', label: '下单IP', width: 130, defaultVisible: false },
|
||||
{ prop: 'cancelIp', label: '取消IP', width: 130, defaultVisible: false },
|
||||
{ prop: 'isShow', label: '显示', width: 80, defaultVisible: false, align: 'center' },
|
||||
];
|
||||
|
||||
export default {
|
||||
name: 'IntegralExternalWaOrder',
|
||||
data() {
|
||||
return {
|
||||
listLoading: false,
|
||||
tableData: { data: [], total: 0 },
|
||||
tableFrom: {
|
||||
orderSn: '',
|
||||
buyerId: '',
|
||||
sellerId: '',
|
||||
status: null,
|
||||
isCancel: null,
|
||||
isResell: null,
|
||||
buyTimeStart: '',
|
||||
buyTimeEnd: '',
|
||||
confirmTimeStart: '',
|
||||
confirmTimeEnd: '',
|
||||
page: 1,
|
||||
limit: 15,
|
||||
},
|
||||
uiStatus: 'all',
|
||||
timeField: 'buyTime',
|
||||
timeVal: [],
|
||||
columnsConfig: COLUMN_DEFS.concat([{ prop: 'actions', label: '操作', fixed: true }]),
|
||||
visibleColumns: this.loadColumns(),
|
||||
columnPopoverVisible: false,
|
||||
drawerVisible: false,
|
||||
detail: null,
|
||||
detailLoading: false,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
// 支持 ?detailId=xxx 直接打开抽屉
|
||||
if (this.$route && this.$route.query && this.$route.query.detailId) {
|
||||
this.openDetail(this.$route.query.detailId);
|
||||
}
|
||||
this.getList();
|
||||
},
|
||||
methods: {
|
||||
loadColumns() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const arr = JSON.parse(raw);
|
||||
if (Array.isArray(arr) && arr.length) {
|
||||
const validProps = COLUMN_DEFS.map((c) => c.prop);
|
||||
// 1) 与最新字段定义做交集;2) 锁定列必须存在;3) 新增的 defaultVisible 列自动并入
|
||||
const next = arr.filter((p) => validProps.includes(p));
|
||||
COLUMN_DEFS.forEach((c) => {
|
||||
if ((c.fixed || c.defaultVisible) && !next.includes(c.prop)) {
|
||||
next.push(c.prop);
|
||||
}
|
||||
});
|
||||
return next;
|
||||
}
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return COLUMN_DEFS.filter((c) => c.defaultVisible || c.fixed).map((c) => c.prop);
|
||||
},
|
||||
persistColumns() {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.visibleColumns));
|
||||
} catch (e) { /* ignore */ }
|
||||
},
|
||||
selectAllColumns() {
|
||||
this.visibleColumns = COLUMN_DEFS.map((c) => c.prop);
|
||||
this.persistColumns();
|
||||
},
|
||||
invertColumns() {
|
||||
const all = COLUMN_DEFS.map((c) => c.prop);
|
||||
const next = all.filter((p) => !this.visibleColumns.includes(p));
|
||||
// 锁定列必须保留
|
||||
COLUMN_DEFS.filter((c) => c.fixed).forEach((c) => {
|
||||
if (!next.includes(c.prop)) next.push(c.prop);
|
||||
});
|
||||
this.visibleColumns = next;
|
||||
this.persistColumns();
|
||||
},
|
||||
resetColumns() {
|
||||
this.visibleColumns = COLUMN_DEFS.filter((c) => c.defaultVisible || c.fixed).map((c) => c.prop);
|
||||
this.persistColumns();
|
||||
},
|
||||
onStatusChange() {
|
||||
this.applyUiStatus();
|
||||
this.seachList();
|
||||
},
|
||||
applyUiStatus() {
|
||||
// UI 标签 → status / isCancel 组合
|
||||
switch (this.uiStatus) {
|
||||
case 'unPaid':
|
||||
this.tableFrom.status = 0;
|
||||
this.tableFrom.isCancel = 0;
|
||||
break;
|
||||
case 'paid':
|
||||
this.tableFrom.status = 1;
|
||||
this.tableFrom.isCancel = 0;
|
||||
break;
|
||||
case 'complete':
|
||||
this.tableFrom.status = 2;
|
||||
this.tableFrom.isCancel = 0;
|
||||
break;
|
||||
case 'cancel':
|
||||
this.tableFrom.status = null;
|
||||
this.tableFrom.isCancel = 1;
|
||||
break;
|
||||
default:
|
||||
this.tableFrom.status = null;
|
||||
this.tableFrom.isCancel = null;
|
||||
}
|
||||
},
|
||||
onTimeChange(val) {
|
||||
// 清掉所有时间字段
|
||||
this.tableFrom.buyTimeStart = '';
|
||||
this.tableFrom.buyTimeEnd = '';
|
||||
this.tableFrom.confirmTimeStart = '';
|
||||
this.tableFrom.confirmTimeEnd = '';
|
||||
if (val && val.length === 2) {
|
||||
const startKey = `${this.timeField}Start`;
|
||||
const endKey = `${this.timeField}End`;
|
||||
// WaOrderSearchRequest 仅支持 buyTime / confirmTime;其余维度回退到 buyTime
|
||||
if (startKey in this.tableFrom) {
|
||||
this.tableFrom[startKey] = `${val[0]} 00:00:00`;
|
||||
this.tableFrom[endKey] = `${val[1]} 23:59:59`;
|
||||
} else {
|
||||
this.tableFrom.buyTimeStart = `${val[0]} 00:00:00`;
|
||||
this.tableFrom.buyTimeEnd = `${val[1]} 23:59:59`;
|
||||
}
|
||||
}
|
||||
this.seachList();
|
||||
},
|
||||
seachList() {
|
||||
this.tableFrom.page = 1;
|
||||
this.getList();
|
||||
},
|
||||
resetHandler() {
|
||||
this.tableFrom = {
|
||||
orderSn: '', buyerId: '', sellerId: '',
|
||||
status: null, isCancel: null, isResell: null,
|
||||
buyTimeStart: '', buyTimeEnd: '', confirmTimeStart: '', confirmTimeEnd: '',
|
||||
page: 1, limit: 15,
|
||||
};
|
||||
this.uiStatus = 'all';
|
||||
this.timeField = 'buyTime';
|
||||
this.timeVal = [];
|
||||
this.getList();
|
||||
},
|
||||
getList() {
|
||||
this.listLoading = true;
|
||||
const params = { ...this.tableFrom };
|
||||
// null/空字段不发
|
||||
Object.keys(params).forEach((k) => {
|
||||
if (params[k] === '' || params[k] === null) delete params[k];
|
||||
});
|
||||
getExternalWaOrderList(params)
|
||||
.then((res) => {
|
||||
this.tableData.data = res.list || [];
|
||||
this.tableData.total = res.total || 0;
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
this.listLoading = false;
|
||||
});
|
||||
},
|
||||
handleSizeChange(val) {
|
||||
this.tableFrom.limit = val;
|
||||
this.getList();
|
||||
},
|
||||
handleCurrentChange(val) {
|
||||
this.tableFrom.page = val;
|
||||
this.getList();
|
||||
},
|
||||
formatCell(prop, row) {
|
||||
if (!row) return '-';
|
||||
switch (prop) {
|
||||
case 'totalMoney':
|
||||
return row.totalMoney != null ? `¥${row.totalMoney}` : '-';
|
||||
case 'statusStr': {
|
||||
const status = this.statusLabel(row);
|
||||
const type = this.statusTagType(row);
|
||||
return `<span class="el-tag el-tag--${type} el-tag--mini">${status}</span>`;
|
||||
}
|
||||
case 'isResell':
|
||||
return row.isResell === 1
|
||||
? '<span style="color:#E6A23C">是</span>'
|
||||
: '否';
|
||||
case 'sellerId':
|
||||
return row.sellerId === 0 ? '0(平台)' : row.sellerId;
|
||||
case 'sellerName':
|
||||
return row.sellerId === 0 ? '平台' : (row.sellerName || '-');
|
||||
case 'areaText':
|
||||
return this.joinArea(row);
|
||||
case 'isShow':
|
||||
return row.isShow === 1 ? '是' : '否';
|
||||
case 'merchandiseTitle':
|
||||
return row.merchandiseTitle || row.productName || '-';
|
||||
default: {
|
||||
const v = row[prop];
|
||||
return v == null || v === '' ? '-' : v;
|
||||
}
|
||||
}
|
||||
},
|
||||
statusLabel(row) {
|
||||
if (!row) return '-';
|
||||
if (row.isCancel === 1) return '已取消';
|
||||
if (row.isResell === 1 && row.status === 0) return '转拍中';
|
||||
switch (row.status) {
|
||||
case 0: return '待付款';
|
||||
case 1: return '已支付';
|
||||
case 2: return '交易完成';
|
||||
default: return '-';
|
||||
}
|
||||
},
|
||||
statusTagType(row) {
|
||||
if (!row) return 'info';
|
||||
if (row.isCancel === 1) return 'info';
|
||||
switch (row.status) {
|
||||
case 0: return 'warning';
|
||||
case 1: return 'primary';
|
||||
case 2: return 'success';
|
||||
default: return 'info';
|
||||
}
|
||||
},
|
||||
stepActive(row) {
|
||||
if (!row) return 0;
|
||||
if (row.confirmTime) return row.isResell === 1 ? 4 : 3;
|
||||
if (row.payTime) return row.isResell === 1 ? 3 : 2;
|
||||
if (row.isResell === 1 && row.createdAt) return 2;
|
||||
if (row.buyTime) return 1;
|
||||
return 0;
|
||||
},
|
||||
joinArea(row) {
|
||||
if (!row) return '-';
|
||||
const parts = [row.province, row.city, row.area].filter(Boolean);
|
||||
return parts.length ? parts.join(' / ') : '-';
|
||||
},
|
||||
openDetail(id) {
|
||||
if (!id) return;
|
||||
this.drawerVisible = true;
|
||||
this.detailLoading = true;
|
||||
this.detail = null;
|
||||
getExternalWaOrderInfo(id)
|
||||
.then((res) => {
|
||||
this.detail = res || null;
|
||||
})
|
||||
.catch(() => {
|
||||
this.$message.error('订单详情加载失败');
|
||||
this.drawerVisible = false;
|
||||
})
|
||||
.finally(() => {
|
||||
this.detailLoading = false;
|
||||
});
|
||||
},
|
||||
onDrawerClose() {
|
||||
this.detail = null;
|
||||
// 清掉 URL 上的 detailId
|
||||
if (this.$route && this.$route.query && this.$route.query.detailId) {
|
||||
const q = { ...this.$route.query };
|
||||
delete q.detailId;
|
||||
this.$router.replace({ path: this.$route.path, query: q });
|
||||
}
|
||||
},
|
||||
copyOrderSn() {
|
||||
if (!this.detail || !this.detail.orderSn) return;
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = this.detail.orderSn;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); this.$message.success('已复制订单号'); }
|
||||
catch (e) { this.$message.error('复制失败'); }
|
||||
document.body.removeChild(ta);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
visibleColumns: {
|
||||
deep: true,
|
||||
handler() { this.persistColumns(); },
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mt10 { margin-top: 10px; }
|
||||
.mt20 { margin-top: 20px; }
|
||||
.selWidth { width: 180px; }
|
||||
.header-actions {
|
||||
text-align: right;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.column-setting {
|
||||
.column-setting-actions {
|
||||
margin-bottom: 8px;
|
||||
border-bottom: 1px dashed #ebeef5;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
}
|
||||
.column-checkbox-group {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 6px 0;
|
||||
}
|
||||
.block { text-align: right; }
|
||||
|
||||
/* ===== 抽屉详情样式 ===== */
|
||||
.drawer-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.drawer-header {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
padding: 16px 20px;
|
||||
background: #f5f7fa;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
.header-left { flex: 1; min-width: 0; }
|
||||
.order-sn-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
.order-sn-label {
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
.order-sn {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
.tag-row {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.close-btn {
|
||||
font-size: 18px;
|
||||
color: #909399;
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
.drawer-content {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.drawer-footer {
|
||||
flex: 0 0 auto;
|
||||
text-align: right;
|
||||
padding: 12px 20px;
|
||||
border-top: 1px solid #ebeef5;
|
||||
background: #fafafa;
|
||||
}
|
||||
.block-card {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid #ebeef5;
|
||||
::v-deep .el-card__header {
|
||||
padding: 10px 14px;
|
||||
background: #fafbfc;
|
||||
}
|
||||
::v-deep .el-card__body {
|
||||
padding: 12px 14px;
|
||||
}
|
||||
}
|
||||
.block-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #303133;
|
||||
position: relative;
|
||||
padding-left: 8px;
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 3px;
|
||||
width: 3px; height: 14px;
|
||||
background: #409EFF;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
.info-desc {
|
||||
::v-deep .el-descriptions__label {
|
||||
color: #909399;
|
||||
font-weight: normal;
|
||||
width: 90px;
|
||||
}
|
||||
::v-deep .el-descriptions__content {
|
||||
color: #303133;
|
||||
}
|
||||
}
|
||||
.price {
|
||||
color: #f56c6c;
|
||||
font-weight: 600;
|
||||
}
|
||||
::v-deep .wa-order-drawer .el-drawer__body {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -3,20 +3,35 @@ package com.zbkj.admin.controller;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.zbkj.common.page.CommonPage;
|
||||
import com.zbkj.common.request.*;
|
||||
import com.zbkj.common.response.ExternalGrabUserResponse;
|
||||
import com.zbkj.common.response.StoreOrderDetailResponse;
|
||||
import com.zbkj.common.response.TeamDailyMultiReportResponse;
|
||||
import com.zbkj.common.response.TeamDailyReportResponse;
|
||||
import com.zbkj.common.response.UserIntegralRecordResponse;
|
||||
import com.zbkj.common.response.UserResponse;
|
||||
import com.zbkj.common.response.WaOrderResponse;
|
||||
import com.zbkj.common.result.CommonResult;
|
||||
import com.zbkj.service.service.ExternalGrabUserService;
|
||||
import com.zbkj.service.service.StoreOrderService;
|
||||
import com.zbkj.service.service.TeamReportExternalService;
|
||||
import com.zbkj.service.service.UserIntegralRecordService;
|
||||
import com.zbkj.service.service.UserService;
|
||||
import com.zbkj.service.service.WaOrderAdminService;
|
||||
import io.swagger.annotations.Api;
|
||||
import io.swagger.annotations.ApiOperation;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 积分模块外部免认证接口 Controller
|
||||
* 供管理后台外部页面(/integral-external/*)调用,跳过登录验证。
|
||||
@@ -40,6 +55,15 @@ public class ExternalIntegralController {
|
||||
@Autowired
|
||||
private UserService userService;
|
||||
|
||||
@Autowired
|
||||
private WaOrderAdminService waOrderAdminService;
|
||||
|
||||
@Autowired
|
||||
private TeamReportExternalService teamReportExternalService;
|
||||
|
||||
@Autowired
|
||||
private ExternalGrabUserService externalGrabUserService;
|
||||
|
||||
/**
|
||||
* 积分明细分页列表(免认证)
|
||||
* 复用 UserIntegralRecordService.findAdminList,与 /admin/user/integral/list 逻辑完全一致。
|
||||
@@ -93,4 +117,90 @@ public class ExternalIntegralController {
|
||||
}
|
||||
return CommonResult.success(restPage);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 寄卖订单管理(wa_order)
|
||||
// 复用 WaOrderAdminService(仅读路径),不使用 @PreAuthorize。
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 寄卖订单分页列表(免认证)
|
||||
*/
|
||||
@ApiOperation(value = "寄卖订单分页列表(免认证)")
|
||||
@GetMapping(value = "/wa-order/list")
|
||||
public CommonResult<CommonPage<WaOrderResponse>> waOrderList(
|
||||
@ModelAttribute @Validated WaOrderSearchRequest request,
|
||||
@Validated PageParamRequest pageParamRequest) {
|
||||
CommonPage<WaOrderResponse> restPage =
|
||||
CommonPage.restPage(waOrderAdminService.getAdminList(request, pageParamRequest));
|
||||
return CommonResult.success(restPage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 寄卖订单详情(免认证)
|
||||
*/
|
||||
@ApiOperation(value = "寄卖订单详情(免认证)")
|
||||
@GetMapping(value = "/wa-order/info")
|
||||
public CommonResult<WaOrderResponse> waOrderInfo(@RequestParam Integer id) {
|
||||
return CommonResult.success(waOrderAdminService.getDetailById(id));
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 团队每日对账日报
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 团队每日对账日报(免认证)。
|
||||
* - leaderId 非空:返回该团队对账(teams 仅 1 项)
|
||||
* - leaderId 为空:按团队长分组返回所有团队对账 + 跨团队总计
|
||||
*/
|
||||
@ApiOperation(value = "团队每日对账(免认证)")
|
||||
@GetMapping(value = "/team-report/daily")
|
||||
public CommonResult<TeamDailyMultiReportResponse> teamDailyReport(
|
||||
@ModelAttribute @Validated TeamDailyReportRequest request) {
|
||||
return CommonResult.success(teamReportExternalService.getMultiDailyReport(request));
|
||||
}
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 - Excel 导出(免认证;仅支持单团队,必须传 leaderId)
|
||||
*/
|
||||
@ApiOperation(value = "团队每日对账 Excel 导出(免认证)")
|
||||
@GetMapping(value = "/team-report/daily/export")
|
||||
public ResponseEntity<byte[]> teamDailyReportExport(
|
||||
@ModelAttribute @Validated TeamDailyReportRequest request) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
||||
TeamDailyReportResponse data = teamReportExternalService.exportDailyReport(request, out);
|
||||
|
||||
String fileName = String.format("团队%s_%s.xlsx",
|
||||
data.getTeamCode() == null ? "" : data.getTeamCode(),
|
||||
data.getDate());
|
||||
String encoded = URLEncoder.encode(fileName, StandardCharsets.UTF_8.name())
|
||||
.replaceAll("\\+", "%20");
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.parseMediaType(
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"));
|
||||
headers.add(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + encoded + "\"; filename*=UTF-8''" + encoded);
|
||||
|
||||
return new ResponseEntity<>(out.toByteArray(), headers, org.springframework.http.HttpStatus.OK);
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// 今日抢单用户列表
|
||||
// ========================================================================
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表(免认证)
|
||||
* 过滤口径:今日购买总金额 > 0;已在 SQL 层落实。
|
||||
*/
|
||||
@ApiOperation(value = "今日抢单用户分页列表(免认证)")
|
||||
@GetMapping(value = "/grab-user/list")
|
||||
public CommonResult<CommonPage<ExternalGrabUserResponse>> grabUserList(
|
||||
@ModelAttribute @Validated ExternalGrabUserRequest request,
|
||||
@Validated PageParamRequest pageParamRequest) {
|
||||
CommonPage<ExternalGrabUserResponse> restPage =
|
||||
CommonPage.restPage(externalGrabUserService.list(request, pageParamRequest));
|
||||
return CommonResult.success(restPage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,24 +6,25 @@ crmeb:
|
||||
server:
|
||||
port: 30032
|
||||
|
||||
# 订单同步配置(每个单商户实例需要配置不同的source-id和target-mer-id)
|
||||
sync:
|
||||
source-id: shop_13
|
||||
target-mer-id: 13
|
||||
source-id: shop_15
|
||||
target-mer-id: 15
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://121.43.134.82:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
url: jdbc:mysql://rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 121.43.134.82 #地址
|
||||
host: 116.62.83.240 #地址
|
||||
port: 6379 #端口
|
||||
password: '123456'
|
||||
password: 'UthinkCloud2017'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 2 #默认数据库
|
||||
database: 25 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
@@ -32,7 +33,7 @@ spring:
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 2 # 微信accessToken存储库
|
||||
database: 25 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
@@ -42,7 +43,7 @@ logging:
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./logs
|
||||
path: ./crmeb_log
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
@@ -1,59 +0,0 @@
|
||||
# CRMEB 相关配置
|
||||
crmeb:
|
||||
captchaOn: false # 是否开启行为验证码
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
port: 30032
|
||||
|
||||
sync:
|
||||
source-id: shop_12
|
||||
target-mer-id: 12
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://101.37.101.6:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 101.37.101.6 #地址
|
||||
port: 6379 #端口
|
||||
password: '123456'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 2 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-idle: 10 # 连接池中的最大空闲连接
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 2 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: debug
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./logs
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
# 配置sql打印日志
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
#swagger 配置
|
||||
swagger:
|
||||
basic:
|
||||
enable: true #是否开启界面
|
||||
check: false #是否打开验证
|
||||
username: crmeb #访问swagger的账号
|
||||
password: crmeb.com #访问swagger的密码
|
||||
@@ -1,59 +0,0 @@
|
||||
# CRMEB 相关配置
|
||||
crmeb:
|
||||
captchaOn: false # 是否开启行为验证码
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
port: 30032
|
||||
|
||||
sync:
|
||||
source-id: shop_14
|
||||
target-mer-id: 14
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://106.14.132.80:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 106.14.132.80 #地址
|
||||
port: 6379 #端口
|
||||
password: '123456'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 2 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-idle: 10 # 连接池中的最大空闲连接
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 2 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: debug
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./logs
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
# 配置sql打印日志
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
#swagger 配置
|
||||
swagger:
|
||||
basic:
|
||||
enable: true #是否开启界面
|
||||
check: false #是否打开验证
|
||||
username: crmeb #访问swagger的账号
|
||||
password: crmeb.com #访问swagger的密码
|
||||
@@ -38,7 +38,7 @@ server:
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: sxsy80
|
||||
active: czleilei240
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB #设置单个文件大小
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.zbkj.common.request;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表 查询请求(外部免认证)
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "ExternalGrabUserRequest 对象", description = "今日抢单用户列表查询请求")
|
||||
public class ExternalGrabUserRequest implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "用户ID(精确)")
|
||||
private Integer uid;
|
||||
|
||||
@ApiModelProperty(value = "联系方式(模糊匹配 mobile / username)")
|
||||
private String mobile;
|
||||
|
||||
@ApiModelProperty(value = "上级ID(精确)")
|
||||
private Integer pid;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.zbkj.common.request;
|
||||
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 查询请求(外部免认证)
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "TeamDailyReportRequest 对象", description = "团队每日对账查询请求")
|
||||
public class TeamDailyReportRequest implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "团队长 ID(wa_users.id);为空时按团队长分组返回所有团队")
|
||||
private Integer leaderId;
|
||||
|
||||
@ApiModelProperty(value = "查询日期 D(yyyy-MM-dd),缺省为昨天")
|
||||
private String date;
|
||||
|
||||
@ApiModelProperty(value = "是否包含禁用成员,默认 false")
|
||||
private Boolean includeDisabled = Boolean.FALSE;
|
||||
|
||||
@ApiModelProperty(value = "限定成员 ID 列表(前端勾选过滤)")
|
||||
private List<Integer> memberIds;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.zbkj.common.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表 响应对象(外部免认证)
|
||||
* 字段保留 3 位小数(与上传参考图一致),由 Service 层格式化为字符串。
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "ExternalGrabUserResponse 对象", description = "今日抢单用户列表响应")
|
||||
public class ExternalGrabUserResponse implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "用户ID")
|
||||
private Integer id;
|
||||
|
||||
@ApiModelProperty(value = "账号 / 用户名")
|
||||
private String username;
|
||||
|
||||
@ApiModelProperty(value = "昵称")
|
||||
private String nickname;
|
||||
|
||||
@ApiModelProperty(value = "手机号 / 联系方式")
|
||||
private String mobile;
|
||||
|
||||
@ApiModelProperty(value = "合同 URL(为空表示未上传)")
|
||||
private String contract;
|
||||
|
||||
@ApiModelProperty(value = "上级ID")
|
||||
private Integer pid;
|
||||
|
||||
@ApiModelProperty(value = "最高可抢单数")
|
||||
private Integer maxOrder;
|
||||
|
||||
@ApiModelProperty(value = "用户等级(数值)")
|
||||
private Integer level;
|
||||
|
||||
@ApiModelProperty(value = "用户等级文案")
|
||||
private String levelName;
|
||||
|
||||
@ApiModelProperty(value = "余额(保留 3 位小数)")
|
||||
private String money;
|
||||
|
||||
@ApiModelProperty(value = "优惠券(保留 3 位小数)")
|
||||
private String coupon;
|
||||
|
||||
@ApiModelProperty(value = "个人奖金(保留 3 位小数)")
|
||||
private String selfBonus;
|
||||
|
||||
@ApiModelProperty(value = "推广奖金(保留 3 位小数)")
|
||||
private String shareBonus;
|
||||
|
||||
@ApiModelProperty(value = "状态:0=禁用,1=正常")
|
||||
private Integer status;
|
||||
|
||||
@ApiModelProperty(value = "状态文案")
|
||||
private String statusStr;
|
||||
|
||||
@ApiModelProperty(value = "更新时间")
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
|
||||
private Date updatedAt;
|
||||
|
||||
@ApiModelProperty(value = "今日购买总金额(保留 3 位小数)")
|
||||
private String todayBuyAmount;
|
||||
|
||||
@ApiModelProperty(value = "今日卖出总金额(保留 3 位小数)")
|
||||
private String todaySellAmount;
|
||||
|
||||
@ApiModelProperty(value = "今日买单数")
|
||||
private Integer todayBuyCnt;
|
||||
|
||||
@ApiModelProperty(value = "昨日卖单数")
|
||||
private Integer prevSellCnt;
|
||||
|
||||
/** 内部使用:SQL 聚合后由 Service 二次处理为格式化字符串;不输出到 JSON */
|
||||
@JsonIgnore
|
||||
@ApiModelProperty(hidden = true)
|
||||
private BigDecimal todayBuyAmountRaw;
|
||||
|
||||
@JsonIgnore
|
||||
@ApiModelProperty(hidden = true)
|
||||
private BigDecimal todaySellAmountRaw;
|
||||
|
||||
@JsonIgnore
|
||||
@ApiModelProperty(hidden = true)
|
||||
private BigDecimal moneyRaw;
|
||||
|
||||
@JsonIgnore
|
||||
@ApiModelProperty(hidden = true)
|
||||
private BigDecimal couponRaw;
|
||||
|
||||
@JsonIgnore
|
||||
@ApiModelProperty(hidden = true)
|
||||
private BigDecimal selfBonusRaw;
|
||||
|
||||
@JsonIgnore
|
||||
@ApiModelProperty(hidden = true)
|
||||
private BigDecimal shareBonusRaw;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.zbkj.common.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 - 单成员行
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "TeamDailyMemberRow 对象", description = "团队每日对账 - 单成员行")
|
||||
public class TeamDailyMemberRow implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty(value = "成员用户 ID")
|
||||
private Integer userId;
|
||||
|
||||
@ApiModelProperty(value = "成员昵称")
|
||||
private String nickname;
|
||||
|
||||
@ApiModelProperty(value = "团队代号")
|
||||
private String teamCode;
|
||||
|
||||
@ApiModelProperty(value = "成员状态:1=启用,0=禁用")
|
||||
private Integer status;
|
||||
|
||||
/** 内部使用:所属团队长 ID(即 wa_users.pid);不输出到 JSON */
|
||||
@JsonIgnore
|
||||
@ApiModelProperty(hidden = true)
|
||||
private Integer leaderId;
|
||||
|
||||
@ApiModelProperty(value = "D-1 买单合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal prevBuy;
|
||||
|
||||
@ApiModelProperty(value = "D 卖单合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal todaySell;
|
||||
|
||||
@ApiModelProperty(value = "D 买单合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal todayBuy;
|
||||
|
||||
@ApiModelProperty(value = "服务费 = D买单 × service_rate")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal serviceFee;
|
||||
|
||||
@ApiModelProperty(value = "E 积分 = D买单 × e_score_rate")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal eScore;
|
||||
|
||||
@ApiModelProperty(value = "实际收付 = D卖单 − D买单 − 服务费 − E积分")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal actual;
|
||||
|
||||
@ApiModelProperty(value = "备注(前端会话级,非持久化)")
|
||||
private String remark;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.zbkj.common.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 — 多团队聚合响应(外部免认证)。
|
||||
*
|
||||
* 当前端不传 leaderId 时返回此结构,按团队长分组列出全部团队的报表,
|
||||
* 并附带跨团队的总计。当 leaderId 传入时 teams 仅含 1 项。
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "TeamDailyMultiReportResponse 对象", description = "多团队每日对账响应")
|
||||
public class TeamDailyMultiReportResponse implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty("查询日期 D(yyyy-MM-dd)")
|
||||
private String date;
|
||||
|
||||
@ApiModelProperty("D-1 日期(yyyy-MM-dd)")
|
||||
private String previousDate;
|
||||
|
||||
@ApiModelProperty("服务费率")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal serviceRate;
|
||||
|
||||
@ApiModelProperty("E 积分率")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal eScoreRate;
|
||||
|
||||
@ApiModelProperty("团队数")
|
||||
private Integer teamCount;
|
||||
|
||||
@ApiModelProperty("成员合计(跨团队)")
|
||||
private Integer totalMemberCount;
|
||||
|
||||
@ApiModelProperty("各团队报表(按 leaderId 分组)")
|
||||
private List<TeamDailyReportResponse> teams;
|
||||
|
||||
@ApiModelProperty("跨团队总计")
|
||||
private TeamDailySummary grandSummary;
|
||||
|
||||
@ApiModelProperty("警告信息(如非法 memberIds)")
|
||||
private List<String> warnings;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.zbkj.common.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 响应(外部免认证)
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "TeamDailyReportResponse 对象", description = "团队每日对账日报响应")
|
||||
public class TeamDailyReportResponse implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty("团队长 ID")
|
||||
private Integer leaderId;
|
||||
|
||||
@ApiModelProperty("团队长昵称")
|
||||
private String leaderNickname;
|
||||
|
||||
@ApiModelProperty("团队代号")
|
||||
private String teamCode;
|
||||
|
||||
@ApiModelProperty("成员人数")
|
||||
private Integer memberCount;
|
||||
|
||||
@ApiModelProperty("查询日期 D(yyyy-MM-dd)")
|
||||
private String date;
|
||||
|
||||
@ApiModelProperty("D-1 日期(yyyy-MM-dd)")
|
||||
private String previousDate;
|
||||
|
||||
@ApiModelProperty("服务费率")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal serviceRate;
|
||||
|
||||
@ApiModelProperty("E 积分率")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal eScoreRate;
|
||||
|
||||
@ApiModelProperty("成员行")
|
||||
private List<TeamDailyMemberRow> rows;
|
||||
|
||||
@ApiModelProperty("小计")
|
||||
private TeamDailySummary summary;
|
||||
|
||||
@ApiModelProperty("警告信息(如非法 memberIds)")
|
||||
private List<String> warnings;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.zbkj.common.response;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import io.swagger.annotations.ApiModel;
|
||||
import io.swagger.annotations.ApiModelProperty;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 - 小计
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = false)
|
||||
@Accessors(chain = true)
|
||||
@ApiModel(value = "TeamDailySummary 对象", description = "团队每日对账 - 小计")
|
||||
public class TeamDailySummary implements Serializable {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@ApiModelProperty("D-1 买单合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal prevBuy = BigDecimal.ZERO;
|
||||
|
||||
@ApiModelProperty("D 卖单合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal todaySell = BigDecimal.ZERO;
|
||||
|
||||
@ApiModelProperty("D 买单合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal todayBuy = BigDecimal.ZERO;
|
||||
|
||||
@ApiModelProperty("服务费合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal serviceFee = BigDecimal.ZERO;
|
||||
|
||||
@ApiModelProperty("E 积分合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal eScore = BigDecimal.ZERO;
|
||||
|
||||
@ApiModelProperty("实际收付合计")
|
||||
@JsonFormat(shape = JsonFormat.Shape.STRING)
|
||||
private BigDecimal actual = BigDecimal.ZERO;
|
||||
}
|
||||
@@ -93,6 +93,12 @@ public class WaOrderResponse implements Serializable {
|
||||
@ApiModelProperty(value = "寄售商品ID")
|
||||
private Integer merchandiseId;
|
||||
|
||||
@ApiModelProperty(value = "寄售商品标题")
|
||||
private String merchandiseTitle;
|
||||
|
||||
@ApiModelProperty(value = "寄售商品图片")
|
||||
private String merchandiseImage;
|
||||
|
||||
@ApiModelProperty(value = "确认收货时间")
|
||||
private Date confirmTime;
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ public class WaUserController {
|
||||
FileInputStream fileInputStream = null;
|
||||
try {
|
||||
// 读取模板PDF文件
|
||||
Resource resource = new ClassPathResource("pdf/sign_contract_sxsy80.pdf");
|
||||
Resource resource = new ClassPathResource("pdf/sign_contract_czleilei240.pdf");
|
||||
InputStream pdfInputStream = resource.getInputStream();
|
||||
document = PDDocument.load(pdfInputStream);
|
||||
pdfInputStream.close();
|
||||
@@ -199,7 +199,7 @@ public class WaUserController {
|
||||
// user.setContract("https://anyue.szxingming.com/"+pdfResultVo.getUrl());
|
||||
// user.setContract("https://xiashengjun.com/"+pdfResultVo.getUrl());
|
||||
// user.setContract("https://ccd.cichude.com/"+pdfResultVo.getUrl());
|
||||
user.setContract("https://sxsy.cichude.com/"+pdfResultVo.getUrl());
|
||||
user.setContract("https://leilei.czchunfang.com/"+pdfResultVo.getUrl());
|
||||
waUsersDao.updateById(user);
|
||||
}
|
||||
return CommonResult.success(pdfResultVo);
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
crmeb:
|
||||
imagePath: /www/wwwroot/czchunfang.com/ # 池州春芳商贸服务器图片路径 斜杠结尾
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
port: 30031
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://121.43.134.82:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 121.43.134.82 #地址
|
||||
port: 6379 #端口
|
||||
password: '123456'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 2 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-idle: 10 # 连接池中的最大空闲连接
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 3 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: debug
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./logs
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
# 配置sql打印日志
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
#swagger 配置
|
||||
swagger:
|
||||
basic:
|
||||
enable: true #是否开启界面
|
||||
check: false #是否打开验证
|
||||
username: crmeb #访问swagger的账号
|
||||
password: crmeb.com #访问swagger的密码
|
||||
@@ -1,5 +1,5 @@
|
||||
crmeb:
|
||||
imagePath: /www/wwwroot/czruitang.com/ # 池州瑞棠商贸服务器图片路径 斜杠结尾
|
||||
imagePath: /www/wwwroot/leilei.czchunfang.com/ # 服务器图片路径配置 斜杠结尾
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
@@ -10,15 +10,15 @@ spring:
|
||||
name: yangtangyoupin
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://101.37.101.6:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
url: jdbc:mysql://rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 101.37.101.6 #地址
|
||||
host: 116.62.83.240 #地址
|
||||
port: 6379 #端口
|
||||
password: '123456'
|
||||
password: 'UthinkCloud2017'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 2 #默认数据库
|
||||
database: 25 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
@@ -27,7 +27,7 @@ spring:
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 3 # 微信accessToken存储库
|
||||
database: 25 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
@@ -1,54 +0,0 @@
|
||||
crmeb:
|
||||
imagePath: /www/wwwroot/sxsy.cichude.com/ # 太原树英商贸 服务器图片路径 斜杠结尾
|
||||
asyncConfig: true #是否同步config表数据到redis
|
||||
|
||||
server:
|
||||
port: 30031
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://106.14.132.80:3306/${spring.datasource.name}?useUnicode=true&serverTimezone=GMT%2B8&characterEncoding=utf8
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
redis:
|
||||
host: 106.14.132.80 #地址
|
||||
port: 6379 #端口
|
||||
password: '123456'
|
||||
timeout: 10000 # 连接超时时间(毫秒)
|
||||
database: 2 #默认数据库
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200 # 连接池最大连接数(使用负值表示没有限制)
|
||||
max-wait: -1 # 连接池最大阻塞等待时间(使用负值表示没有限制)
|
||||
max-idle: 10 # 连接池中的最大空闲连接
|
||||
min-idle: 0 # 连接池中的最小空闲连接
|
||||
time-between-eviction-runs: -1 #逐出扫描的时间间隔(毫秒) 如果为负数,则不运行逐出线程, 默认-1
|
||||
second:
|
||||
database: 3 # 微信accessToken存储库
|
||||
|
||||
debug: true
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: debug
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: ./logs
|
||||
|
||||
# mybatis 配置
|
||||
mybatis-plus:
|
||||
# 配置sql打印日志
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
#swagger 配置
|
||||
swagger:
|
||||
basic:
|
||||
enable: true #是否开启界面
|
||||
check: false #是否打开验证
|
||||
username: crmeb #访问swagger的账号
|
||||
password: crmeb.com #访问swagger的密码
|
||||
@@ -32,7 +32,7 @@ server:
|
||||
|
||||
spring:
|
||||
profiles:
|
||||
active: sxsy80
|
||||
active: czleilei240
|
||||
servlet:
|
||||
multipart:
|
||||
max-file-size: 50MB #设置单个文件大小
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -22,6 +22,13 @@
|
||||
<artifactId>crmeb-common</artifactId>
|
||||
<version>${crmeb-common}</version>
|
||||
</dependency>
|
||||
<!-- 单元测试(仅 test 作用域;用于 util 类公式校验等纯函数测试) -->
|
||||
<dependency>
|
||||
<groupId>junit</groupId>
|
||||
<artifactId>junit</artifactId>
|
||||
<version>4.12</version>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.jayway.jsonpath</groupId>
|
||||
<artifactId>json-path</artifactId>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.zbkj.service.dao.consignment;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.zbkj.common.model.consignment.WaUsers;
|
||||
import com.zbkj.common.response.ExternalGrabUserResponse;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 今日抢单用户 DAO(外部免认证)
|
||||
* 通过自定义 SQL 一次性聚合:
|
||||
* - 今日买单合计 / 笔数(INNER JOIN,HAVING SUM>0)
|
||||
* - 今日卖单合计
|
||||
* - 昨日卖单笔数
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Mapper
|
||||
public interface ExternalGrabUserDao extends BaseMapper<WaUsers> {
|
||||
|
||||
/**
|
||||
* 列表查询。
|
||||
*
|
||||
* @param todayStart 今天 00:00:00
|
||||
* @param todayEnd 今天 23:59:59
|
||||
* @param yesterdayStart 昨天 00:00:00
|
||||
* @param yesterdayEnd 昨天 23:59:59
|
||||
* @param uid 用户 ID(精确,可空)
|
||||
* @param mobile 模糊匹配 mobile / username(可空)
|
||||
* @param pid 上级 ID(精确,可空)
|
||||
*/
|
||||
List<ExternalGrabUserResponse> selectGrabUserList(
|
||||
@Param("todayStart") Date todayStart,
|
||||
@Param("todayEnd") Date todayEnd,
|
||||
@Param("yesterdayStart") Date yesterdayStart,
|
||||
@Param("yesterdayEnd") Date yesterdayEnd,
|
||||
@Param("uid") Integer uid,
|
||||
@Param("mobile") String mobile,
|
||||
@Param("pid") Integer pid);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.zbkj.service.dao.consignment;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.zbkj.common.model.consignment.WaUsers;
|
||||
import com.zbkj.common.response.TeamDailyMemberRow;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Select;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 DAO(外部免认证)
|
||||
* 通过自定义 SQL 一次性聚合每个直推下级的:D-1 买单 / D 卖单 / D 买单。
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Mapper
|
||||
public interface TeamReportDao extends BaseMapper<WaUsers> {
|
||||
|
||||
/**
|
||||
* 查询团队成员每日对账原始数据。
|
||||
*
|
||||
* @param leaderId 团队长 ID;为 null 时返回所有有 pid 的成员,由 Service 按 leader_id 分组
|
||||
*/
|
||||
List<TeamDailyMemberRow> selectTeamMemberAggregates(
|
||||
@Param("leaderId") Integer leaderId,
|
||||
@Param("dStart") Date dStart,
|
||||
@Param("dEnd") Date dEnd,
|
||||
@Param("prevStart") Date prevStart,
|
||||
@Param("prevEnd") Date prevEnd,
|
||||
@Param("includeDisabled") Boolean includeDisabled,
|
||||
@Param("memberIds") List<Integer> memberIds);
|
||||
|
||||
/**
|
||||
* 读取 wa_setting 中某 key 的字符串值(不存在时返回 null)。
|
||||
* KV 表无独立 Model,使用注解直接查询。
|
||||
*/
|
||||
@Select("SELECT `value` FROM wa_setting WHERE `name` = #{name} LIMIT 1")
|
||||
String selectSettingValue(@Param("name") String name);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.zbkj.service.service;
|
||||
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.zbkj.common.request.ExternalGrabUserRequest;
|
||||
import com.zbkj.common.request.PageParamRequest;
|
||||
import com.zbkj.common.response.ExternalGrabUserResponse;
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表 Service(外部免认证)
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
public interface ExternalGrabUserService {
|
||||
|
||||
/**
|
||||
* 分页查询当日已发生买单且金额 > 0 的用户列表。
|
||||
* 排序:今日购买总金额 DESC,相同时按 id DESC。
|
||||
*
|
||||
* @param request 搜索条件
|
||||
* @param pageParamRequest 分页参数
|
||||
* @return PageInfo<ExternalGrabUserResponse>
|
||||
*/
|
||||
PageInfo<ExternalGrabUserResponse> list(ExternalGrabUserRequest request,
|
||||
PageParamRequest pageParamRequest);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.zbkj.service.service;
|
||||
|
||||
import com.zbkj.common.request.TeamDailyReportRequest;
|
||||
import com.zbkj.common.response.TeamDailyMultiReportResponse;
|
||||
import com.zbkj.common.response.TeamDailyReportResponse;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 Service(外部免认证)
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
public interface TeamReportExternalService {
|
||||
|
||||
/**
|
||||
* 查询某团队某日对账数据(要求 leaderId 非空)。
|
||||
*/
|
||||
TeamDailyReportResponse getDailyReport(TeamDailyReportRequest request);
|
||||
|
||||
/**
|
||||
* 查询多团队对账数据:
|
||||
* - leaderId 为空:按团队长分组返回所有团队
|
||||
* - leaderId 非空:teams 仅含该团队
|
||||
*
|
||||
* @return 多团队报表(含 grandSummary 与每个团队的子报表)
|
||||
*/
|
||||
TeamDailyMultiReportResponse getMultiDailyReport(TeamDailyReportRequest request);
|
||||
|
||||
/**
|
||||
* Excel 导出(仅支持单团队,要求 leaderId 非空)。
|
||||
*/
|
||||
TeamDailyReportResponse exportDailyReport(TeamDailyReportRequest request, OutputStream out) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.zbkj.service.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.zbkj.common.request.ExternalGrabUserRequest;
|
||||
import com.zbkj.common.request.PageParamRequest;
|
||||
import com.zbkj.common.response.ExternalGrabUserResponse;
|
||||
import com.zbkj.service.dao.consignment.ExternalGrabUserDao;
|
||||
import com.zbkj.service.service.ExternalGrabUserService;
|
||||
import com.zbkj.service.util.GrabUserFormatter;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表 Service 实现(外部免认证)
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Service
|
||||
public class ExternalGrabUserServiceImpl implements ExternalGrabUserService {
|
||||
|
||||
private static final ZoneId ZONE_CN = ZoneId.of("Asia/Shanghai");
|
||||
|
||||
@Resource
|
||||
private ExternalGrabUserDao externalGrabUserDao;
|
||||
|
||||
@Override
|
||||
public PageInfo<ExternalGrabUserResponse> list(ExternalGrabUserRequest request,
|
||||
PageParamRequest pageParamRequest) {
|
||||
if (request == null) {
|
||||
request = new ExternalGrabUserRequest();
|
||||
}
|
||||
|
||||
// 时间窗(业务时区 CST)
|
||||
LocalDate today = LocalDate.now(ZONE_CN);
|
||||
Date todayStart = toDate(today.atStartOfDay());
|
||||
Date todayEnd = toDate(today.atTime(LocalTime.MAX));
|
||||
LocalDate yesterday = today.minusDays(1);
|
||||
Date yesterdayStart = toDate(yesterday.atStartOfDay());
|
||||
Date yesterdayEnd = toDate(yesterday.atTime(LocalTime.MAX));
|
||||
|
||||
// 分页(PageHelper 在执行下一条 SQL 时生效)
|
||||
int page = pageParamRequest != null && pageParamRequest.getPage() > 0 ? pageParamRequest.getPage() : 1;
|
||||
int limit = pageParamRequest != null && pageParamRequest.getLimit() > 0 ? Math.min(pageParamRequest.getLimit(), 100) : 15;
|
||||
PageHelper.startPage(page, limit);
|
||||
|
||||
List<ExternalGrabUserResponse> rows = externalGrabUserDao.selectGrabUserList(
|
||||
todayStart, todayEnd, yesterdayStart, yesterdayEnd,
|
||||
request.getUid(),
|
||||
StrUtil.trimToNull(request.getMobile()),
|
||||
request.getPid());
|
||||
|
||||
// 后处理:金额格式化、状态/等级文案
|
||||
for (ExternalGrabUserResponse row : rows) {
|
||||
row.setMoney(GrabUserFormatter.formatAmount(row.getMoneyRaw()));
|
||||
row.setCoupon(GrabUserFormatter.formatAmount(row.getCouponRaw()));
|
||||
row.setSelfBonus(GrabUserFormatter.formatAmount(row.getSelfBonusRaw()));
|
||||
row.setShareBonus(GrabUserFormatter.formatAmount(row.getShareBonusRaw()));
|
||||
row.setTodayBuyAmount(GrabUserFormatter.formatAmount(row.getTodayBuyAmountRaw()));
|
||||
row.setTodaySellAmount(GrabUserFormatter.formatAmount(row.getTodaySellAmountRaw()));
|
||||
row.setStatusStr(GrabUserFormatter.mapStatus(row.getStatus()));
|
||||
row.setLevelName(GrabUserFormatter.mapLevelName(row.getLevel()));
|
||||
}
|
||||
|
||||
return new PageInfo<>(rows);
|
||||
}
|
||||
|
||||
private static Date toDate(LocalDateTime dt) {
|
||||
return Date.from(dt.atZone(ZONE_CN).toInstant());
|
||||
}
|
||||
}
|
||||
@@ -691,7 +691,10 @@ public class OrderPayServiceImpl implements OrderPayService {
|
||||
userService.updateNowMoney(user, storeOrder.getPayPrice(), "sub");
|
||||
// 扣除积分
|
||||
if (storeOrder.getUseIntegral() > 0) {
|
||||
userService.updateIntegral(user, storeOrder.getUseIntegral(), "sub");
|
||||
Boolean integralUpdated = userService.updateIntegral(user, storeOrder.getUseIntegral(), "sub");
|
||||
if (!integralUpdated) {
|
||||
throw new CrmebException("用户积分不足");
|
||||
}
|
||||
}
|
||||
// 添加支付成功redis队列
|
||||
redisUtil.lPush(TaskConstants.ORDER_TASK_PAY_SUCCESS_AFTER, storeOrder.getOrderId());
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
package com.zbkj.service.service.impl;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.zbkj.common.exception.CrmebException;
|
||||
import com.zbkj.common.model.consignment.WaUsers;
|
||||
import com.zbkj.common.request.TeamDailyReportRequest;
|
||||
import com.zbkj.common.response.TeamDailyMemberRow;
|
||||
import com.zbkj.common.response.TeamDailyMultiReportResponse;
|
||||
import com.zbkj.common.response.TeamDailyReportResponse;
|
||||
import com.zbkj.common.response.TeamDailySummary;
|
||||
import com.zbkj.service.dao.consignment.TeamReportDao;
|
||||
import com.zbkj.service.dao.consignment.WaUsersDao;
|
||||
import com.zbkj.service.service.TeamReportExternalService;
|
||||
import com.zbkj.service.util.TeamReportFormula;
|
||||
import org.apache.poi.ss.usermodel.*;
|
||||
import org.apache.poi.ss.util.CellRangeAddress;
|
||||
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 Service 实现(外部免认证)
|
||||
*
|
||||
* 关键设计:
|
||||
* 1) 费率从 wa_setting 读取(service_rate / e_score_rate),缺省 0.02 / 0.005;
|
||||
* 2) BigDecimal 全程 HALF_UP 保留 2 位;
|
||||
* 3) 小计的服务费 / E积分 / 实际收付 由"成员级别已舍入数值"再求和,避免逐行二次舍入;
|
||||
* 4) 时区:业务时区 Asia/Shanghai。
|
||||
* +----------------------------------------------------------------------
|
||||
* | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||||
* +----------------------------------------------------------------------
|
||||
*/
|
||||
@Service
|
||||
public class TeamReportExternalServiceImpl implements TeamReportExternalService {
|
||||
|
||||
private static final ZoneId ZONE_CN = ZoneId.of("Asia/Shanghai");
|
||||
private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
private static final BigDecimal DEFAULT_SERVICE_RATE = new BigDecimal("0.02");
|
||||
private static final BigDecimal DEFAULT_E_SCORE_RATE = new BigDecimal("0.005");
|
||||
private static final int SCALE = 2;
|
||||
|
||||
@Resource
|
||||
private TeamReportDao teamReportDao;
|
||||
|
||||
@Resource
|
||||
private WaUsersDao waUsersDao;
|
||||
|
||||
@Override
|
||||
public TeamDailyReportResponse getDailyReport(TeamDailyReportRequest request) {
|
||||
if (request == null || request.getLeaderId() == null) {
|
||||
throw new CrmebException("团队长 ID 不能为空");
|
||||
}
|
||||
return assembleSingleTeam(request);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TeamDailyMultiReportResponse getMultiDailyReport(TeamDailyReportRequest request) {
|
||||
if (request == null) request = new TeamDailyReportRequest();
|
||||
|
||||
Ctx ctx = prepare(request);
|
||||
|
||||
// 拉取原始聚合(leaderId 为空时一次拿所有团队成员)
|
||||
List<TeamDailyMemberRow> rows = teamReportDao.selectTeamMemberAggregates(
|
||||
request.getLeaderId(), ctx.dStart, ctx.dEnd, ctx.pStart, ctx.pEnd,
|
||||
request.getIncludeDisabled(),
|
||||
(request.getMemberIds() != null && !request.getMemberIds().isEmpty())
|
||||
? request.getMemberIds() : null);
|
||||
if (rows == null) rows = new ArrayList<>();
|
||||
|
||||
// 公式计算
|
||||
for (TeamDailyMemberRow r : rows) {
|
||||
TeamReportFormula.applyFormula(r, ctx.serviceRate, ctx.eScoreRate);
|
||||
r.setRemark("");
|
||||
}
|
||||
|
||||
// 按 leaderId 分组(保持稳定顺序)
|
||||
Map<Integer, List<TeamDailyMemberRow>> groups = new LinkedHashMap<>();
|
||||
Set<Integer> leaderIds = new HashSet<>();
|
||||
for (TeamDailyMemberRow r : rows) {
|
||||
Integer lid = r.getLeaderId();
|
||||
if (lid == null || lid <= 0) continue;
|
||||
groups.computeIfAbsent(lid, k -> new ArrayList<>()).add(r);
|
||||
leaderIds.add(lid);
|
||||
}
|
||||
|
||||
// 批量拉取团队长信息
|
||||
Map<Integer, WaUsers> leaderMap = new HashMap<>();
|
||||
if (!leaderIds.isEmpty()) {
|
||||
for (WaUsers u : waUsersDao.selectBatchIds(leaderIds)) {
|
||||
leaderMap.put(u.getId(), u);
|
||||
}
|
||||
}
|
||||
|
||||
// 组装每个团队子报表
|
||||
List<TeamDailyReportResponse> teams = new ArrayList<>(groups.size());
|
||||
TeamDailySummary grand = new TeamDailySummary();
|
||||
int totalMembers = 0;
|
||||
for (Map.Entry<Integer, List<TeamDailyMemberRow>> e : groups.entrySet()) {
|
||||
Integer lid = e.getKey();
|
||||
List<TeamDailyMemberRow> teamRows = e.getValue();
|
||||
WaUsers leader = leaderMap.get(lid);
|
||||
String leaderNickname = leader != null ? leader.getNickname() : null;
|
||||
String teamCode = leader != null && StrUtil.isNotBlank(leader.getInvite())
|
||||
? leader.getInvite()
|
||||
: String.valueOf(lid);
|
||||
|
||||
TeamDailySummary teamSummary = TeamReportFormula.aggregateSummary(teamRows);
|
||||
|
||||
teams.add(new TeamDailyReportResponse()
|
||||
.setLeaderId(lid)
|
||||
.setLeaderNickname(leaderNickname)
|
||||
.setTeamCode(teamCode)
|
||||
.setMemberCount(teamRows.size())
|
||||
.setDate(ctx.d.format(DATE_FMT))
|
||||
.setPreviousDate(ctx.prev.format(DATE_FMT))
|
||||
.setServiceRate(ctx.serviceRate)
|
||||
.setEScoreRate(ctx.eScoreRate)
|
||||
.setRows(teamRows)
|
||||
.setSummary(teamSummary));
|
||||
|
||||
// 累加 grand summary
|
||||
grand.setPrevBuy(grand.getPrevBuy().add(teamSummary.getPrevBuy()));
|
||||
grand.setTodaySell(grand.getTodaySell().add(teamSummary.getTodaySell()));
|
||||
grand.setTodayBuy(grand.getTodayBuy().add(teamSummary.getTodayBuy()));
|
||||
grand.setServiceFee(grand.getServiceFee().add(teamSummary.getServiceFee()));
|
||||
grand.setEScore(grand.getEScore().add(teamSummary.getEScore()));
|
||||
grand.setActual(grand.getActual().add(teamSummary.getActual()));
|
||||
totalMembers += teamRows.size();
|
||||
}
|
||||
|
||||
return new TeamDailyMultiReportResponse()
|
||||
.setDate(ctx.d.format(DATE_FMT))
|
||||
.setPreviousDate(ctx.prev.format(DATE_FMT))
|
||||
.setServiceRate(ctx.serviceRate)
|
||||
.setEScoreRate(ctx.eScoreRate)
|
||||
.setTeamCount(teams.size())
|
||||
.setTotalMemberCount(totalMembers)
|
||||
.setTeams(teams)
|
||||
.setGrandSummary(grand);
|
||||
}
|
||||
|
||||
@Override
|
||||
public TeamDailyReportResponse exportDailyReport(TeamDailyReportRequest request, OutputStream out) throws IOException {
|
||||
if (request == null || request.getLeaderId() == null) {
|
||||
throw new CrmebException("Excel 导出仅支持单团队,必须指定团队长 ID");
|
||||
}
|
||||
TeamDailyReportResponse data = assembleSingleTeam(request);
|
||||
writeExcel(data, out);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 主流程:单团队装配
|
||||
// ------------------------------------------------------------------
|
||||
private TeamDailyReportResponse assembleSingleTeam(TeamDailyReportRequest request) {
|
||||
// 校验团队长存在
|
||||
WaUsers leader = waUsersDao.selectById(request.getLeaderId());
|
||||
if (leader == null) {
|
||||
throw new CrmebException("团队长不存在");
|
||||
}
|
||||
|
||||
Ctx ctx = prepare(request);
|
||||
|
||||
// 拉取原始聚合
|
||||
List<TeamDailyMemberRow> rows = teamReportDao.selectTeamMemberAggregates(
|
||||
request.getLeaderId(), ctx.dStart, ctx.dEnd, ctx.pStart, ctx.pEnd,
|
||||
request.getIncludeDisabled(),
|
||||
(request.getMemberIds() != null && !request.getMemberIds().isEmpty())
|
||||
? request.getMemberIds() : null);
|
||||
if (rows == null) rows = new ArrayList<>();
|
||||
|
||||
for (TeamDailyMemberRow r : rows) {
|
||||
TeamReportFormula.applyFormula(r, ctx.serviceRate, ctx.eScoreRate);
|
||||
r.setRemark("");
|
||||
}
|
||||
TeamDailySummary summary = TeamReportFormula.aggregateSummary(rows);
|
||||
|
||||
String teamCode = StrUtil.isNotBlank(leader.getInvite())
|
||||
? leader.getInvite()
|
||||
: String.valueOf(leader.getId());
|
||||
|
||||
return new TeamDailyReportResponse()
|
||||
.setLeaderId(leader.getId())
|
||||
.setLeaderNickname(leader.getNickname())
|
||||
.setTeamCode(teamCode)
|
||||
.setMemberCount(rows.size())
|
||||
.setDate(ctx.d.format(DATE_FMT))
|
||||
.setPreviousDate(ctx.prev.format(DATE_FMT))
|
||||
.setServiceRate(ctx.serviceRate)
|
||||
.setEScoreRate(ctx.eScoreRate)
|
||||
.setRows(rows)
|
||||
.setSummary(summary);
|
||||
}
|
||||
|
||||
/** 单/多团队共用的上下文(日期、时间窗、费率) */
|
||||
private Ctx prepare(TeamDailyReportRequest request) {
|
||||
LocalDate today = LocalDate.now(ZONE_CN);
|
||||
LocalDate d;
|
||||
if (StrUtil.isNotBlank(request.getDate())) {
|
||||
d = LocalDate.parse(request.getDate(), DATE_FMT);
|
||||
} else {
|
||||
d = today.minusDays(1);
|
||||
}
|
||||
if (d.isAfter(today)) {
|
||||
throw new CrmebException("不能查询未来日期");
|
||||
}
|
||||
LocalDate prev = d.minusDays(1);
|
||||
Ctx ctx = new Ctx();
|
||||
ctx.d = d;
|
||||
ctx.prev = prev;
|
||||
ctx.dStart = toDate(d, true);
|
||||
ctx.dEnd = toDate(d, false);
|
||||
ctx.pStart = toDate(prev, true);
|
||||
ctx.pEnd = toDate(prev, false);
|
||||
ctx.serviceRate = readRate("service_rate", DEFAULT_SERVICE_RATE);
|
||||
ctx.eScoreRate = readRate("e_score_rate", DEFAULT_E_SCORE_RATE);
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/** Service 内部上下文 */
|
||||
private static class Ctx {
|
||||
LocalDate d;
|
||||
LocalDate prev;
|
||||
Date dStart;
|
||||
Date dEnd;
|
||||
Date pStart;
|
||||
Date pEnd;
|
||||
BigDecimal serviceRate;
|
||||
BigDecimal eScoreRate;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// 工具方法
|
||||
// ------------------------------------------------------------------
|
||||
private static Date toDate(LocalDate date, boolean start) {
|
||||
return Date.from((start ? date.atStartOfDay() : date.atTime(LocalTime.MAX))
|
||||
.atZone(ZONE_CN).toInstant());
|
||||
}
|
||||
|
||||
private BigDecimal readRate(String name, BigDecimal fallback) {
|
||||
String v = teamReportDao.selectSettingValue(name);
|
||||
if (StrUtil.isBlank(v)) return fallback;
|
||||
try {
|
||||
return new BigDecimal(v.trim());
|
||||
} catch (Exception e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Excel 导出(Apache POI)
|
||||
// ------------------------------------------------------------------
|
||||
private void writeExcel(TeamDailyReportResponse data, OutputStream out) throws IOException {
|
||||
try (Workbook wb = new XSSFWorkbook()) {
|
||||
Sheet sheet = wb.createSheet("团队日报");
|
||||
|
||||
// 表头横幅:团队长 昵称 · N 人 · 日期
|
||||
Row banner = sheet.createRow(0);
|
||||
Cell bannerCell = banner.createCell(0);
|
||||
String leaderShown = data.getLeaderNickname() != null && !data.getLeaderNickname().isEmpty()
|
||||
? data.getLeaderNickname()
|
||||
: (data.getTeamCode() == null ? "" : data.getTeamCode());
|
||||
bannerCell.setCellValue(String.format("团队长 %s · %d 人 · %s",
|
||||
leaderShown, data.getMemberCount(), data.getDate()));
|
||||
CellStyle bannerStyle = wb.createCellStyle();
|
||||
Font bannerFont = wb.createFont();
|
||||
bannerFont.setBold(true);
|
||||
bannerFont.setFontHeightInPoints((short) 14);
|
||||
bannerStyle.setFont(bannerFont);
|
||||
bannerStyle.setAlignment(HorizontalAlignment.CENTER);
|
||||
bannerCell.setCellStyle(bannerStyle);
|
||||
sheet.addMergedRegion(new CellRangeAddress(0, 0, 0, 8));
|
||||
|
||||
// 表头
|
||||
String[] headers = {
|
||||
"昵称",
|
||||
data.getPreviousDate() + " 买单",
|
||||
data.getDate() + " 卖单",
|
||||
data.getDate() + " 买单",
|
||||
"服务费*" + data.getServiceRate().toPlainString(),
|
||||
"E积分",
|
||||
"实际收付",
|
||||
"团队",
|
||||
"备注"
|
||||
};
|
||||
CellStyle headStyle = wb.createCellStyle();
|
||||
Font headFont = wb.createFont();
|
||||
headFont.setBold(true);
|
||||
headStyle.setFont(headFont);
|
||||
headStyle.setAlignment(HorizontalAlignment.CENTER);
|
||||
headStyle.setFillForegroundColor(IndexedColors.LIGHT_GREEN.getIndex());
|
||||
headStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
|
||||
applyBorders(headStyle);
|
||||
Row headRow = sheet.createRow(1);
|
||||
for (int i = 0; i < headers.length; i++) {
|
||||
Cell cell = headRow.createCell(i);
|
||||
cell.setCellValue(headers[i]);
|
||||
cell.setCellStyle(headStyle);
|
||||
}
|
||||
|
||||
// 小计行(紧跟表头)
|
||||
CellStyle summaryStyle = wb.createCellStyle();
|
||||
Font summaryFont = wb.createFont();
|
||||
summaryFont.setBold(true);
|
||||
summaryFont.setColor(IndexedColors.RED.getIndex());
|
||||
summaryStyle.setFont(summaryFont);
|
||||
applyBorders(summaryStyle);
|
||||
|
||||
CellStyle moneyStyle = wb.createCellStyle();
|
||||
moneyStyle.setDataFormat(wb.createDataFormat().getFormat("#,##0.00;-#,##0.00"));
|
||||
applyBorders(moneyStyle);
|
||||
|
||||
CellStyle moneyBoldRedStyle = wb.createCellStyle();
|
||||
moneyBoldRedStyle.cloneStyleFrom(moneyStyle);
|
||||
moneyBoldRedStyle.setFont(summaryFont);
|
||||
|
||||
Row summaryRow = sheet.createRow(2);
|
||||
TeamDailySummary s = data.getSummary();
|
||||
summaryRow.createCell(0).setCellValue("小计");
|
||||
summaryRow.getCell(0).setCellStyle(summaryStyle);
|
||||
writeMoney(summaryRow, 1, s.getPrevBuy(), moneyBoldRedStyle);
|
||||
writeMoney(summaryRow, 2, s.getTodaySell(), moneyBoldRedStyle);
|
||||
writeMoney(summaryRow, 3, s.getTodayBuy(), moneyBoldRedStyle);
|
||||
writeMoney(summaryRow, 4, s.getServiceFee(), moneyBoldRedStyle);
|
||||
writeMoney(summaryRow, 5, s.getEScore(), moneyBoldRedStyle);
|
||||
writeMoney(summaryRow, 6, s.getActual(), moneyBoldRedStyle);
|
||||
summaryRow.createCell(7).setCellStyle(summaryStyle);
|
||||
summaryRow.createCell(8).setCellStyle(summaryStyle);
|
||||
|
||||
// 数据行(团队列填团队长昵称)
|
||||
int rowIdx = 3;
|
||||
for (TeamDailyMemberRow r : data.getRows()) {
|
||||
Row row = sheet.createRow(rowIdx++);
|
||||
row.createCell(0).setCellValue(r.getNickname() == null ? "" : r.getNickname());
|
||||
writeMoney(row, 1, r.getPrevBuy(), moneyStyle);
|
||||
writeMoney(row, 2, r.getTodaySell(), moneyStyle);
|
||||
writeMoney(row, 3, r.getTodayBuy(), moneyStyle);
|
||||
writeMoney(row, 4, r.getServiceFee(), moneyStyle);
|
||||
writeMoney(row, 5, r.getEScore(), moneyStyle);
|
||||
writeMoney(row, 6, r.getActual(), moneyStyle);
|
||||
row.createCell(7).setCellValue(leaderShown);
|
||||
// 备注列保留空白(运营手填)
|
||||
row.createCell(8).setCellValue("");
|
||||
}
|
||||
|
||||
// 列宽
|
||||
int[] widths = { 14, 14, 14, 14, 16, 12, 14, 8, 20 };
|
||||
for (int i = 0; i < widths.length; i++) {
|
||||
sheet.setColumnWidth(i, widths[i] * 256);
|
||||
}
|
||||
|
||||
wb.write(out);
|
||||
out.flush();
|
||||
}
|
||||
}
|
||||
|
||||
private void writeMoney(Row row, int col, BigDecimal value, CellStyle style) {
|
||||
Cell cell = row.createCell(col);
|
||||
cell.setCellValue(value == null ? 0d : value.doubleValue());
|
||||
cell.setCellStyle(style);
|
||||
}
|
||||
|
||||
private void applyBorders(CellStyle style) {
|
||||
style.setBorderTop(BorderStyle.THIN);
|
||||
style.setBorderBottom(BorderStyle.THIN);
|
||||
style.setBorderLeft(BorderStyle.THIN);
|
||||
style.setBorderRight(BorderStyle.THIN);
|
||||
}
|
||||
}
|
||||
@@ -392,6 +392,27 @@ public class UserServiceImpl extends ServiceImpl<UserDao, User> implements UserS
|
||||
return update(lambdaUpdateWrapper);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户积分
|
||||
*
|
||||
* 使用数据库原子加减,避免根据调用方传入的旧 User 对象计算后覆盖最新积分。
|
||||
*
|
||||
* @param user 用户
|
||||
* @param integral 积分
|
||||
* @param type 增加add、扣减sub
|
||||
* @return Boolean
|
||||
*/
|
||||
@Override
|
||||
public Boolean updateIntegral(User user, Integer integral, String type) {
|
||||
if (ObjectUtil.isNull(user)) {
|
||||
throw new CrmebException("用户不存在");
|
||||
}
|
||||
if (ObjectUtil.isNull(integral) || integral <= 0) {
|
||||
throw new CrmebException("积分必须大于0");
|
||||
}
|
||||
return operationIntegral(user.getUid(), BigDecimal.valueOf(integral), user.getIntegral(), type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 会员分组
|
||||
*
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||
import com.github.pagehelper.PageHelper;
|
||||
import com.github.pagehelper.PageInfo;
|
||||
import com.zbkj.common.exception.CrmebException;
|
||||
import com.zbkj.common.model.consignment.WaMerchandise;
|
||||
import com.zbkj.common.model.consignment.WaOrder;
|
||||
import com.zbkj.common.model.consignment.WaUsers;
|
||||
import com.zbkj.common.page.CommonPage;
|
||||
@@ -13,6 +14,7 @@ import com.zbkj.common.request.PageParamRequest;
|
||||
import com.zbkj.common.request.WaOrderSearchRequest;
|
||||
import com.zbkj.common.request.WaOrderUpdateRequest;
|
||||
import com.zbkj.common.response.WaOrderResponse;
|
||||
import com.zbkj.service.dao.consignment.WaMerchandiseDao;
|
||||
import com.zbkj.service.dao.consignment.WaOrderDao;
|
||||
import com.zbkj.service.dao.consignment.WaUsersDao;
|
||||
import com.zbkj.service.service.WaOrderAdminService;
|
||||
@@ -20,7 +22,11 @@ import org.springframework.beans.BeanUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
@@ -44,6 +50,9 @@ public class WaOrderAdminServiceImpl extends ServiceImpl<WaOrderDao, WaOrder> im
|
||||
@Resource
|
||||
private WaUsersDao waUsersDao;
|
||||
|
||||
@Resource
|
||||
private WaMerchandiseDao waMerchandiseDao;
|
||||
|
||||
/**
|
||||
* 分页列表查询
|
||||
*/
|
||||
@@ -108,31 +117,58 @@ public class WaOrderAdminServiceImpl extends ServiceImpl<WaOrderDao, WaOrder> im
|
||||
wrapper.orderByDesc(WaOrder::getCreatedAt);
|
||||
|
||||
List<WaOrder> list = waOrderDao.selectList(wrapper);
|
||||
|
||||
// 批量收集所需关联 ID,避免循环 N+1 查询
|
||||
Set<Integer> userIds = new HashSet<>();
|
||||
Set<Integer> merchandiseIds = new HashSet<>();
|
||||
for (WaOrder o : list) {
|
||||
if (o.getSellerId() != null && o.getSellerId() > 0) userIds.add(o.getSellerId());
|
||||
if (o.getBuyerId() != null && o.getBuyerId() > 0) userIds.add(o.getBuyerId());
|
||||
if (o.getMerchandiseId() != null && o.getMerchandiseId() > 0) merchandiseIds.add(o.getMerchandiseId());
|
||||
}
|
||||
Map<Integer, WaUsers> userMap = new HashMap<>();
|
||||
if (!userIds.isEmpty()) {
|
||||
for (WaUsers u : waUsersDao.selectBatchIds(userIds)) {
|
||||
userMap.put(u.getId(), u);
|
||||
}
|
||||
}
|
||||
Map<Integer, WaMerchandise> merchandiseMap = new HashMap<>();
|
||||
if (!merchandiseIds.isEmpty()) {
|
||||
for (WaMerchandise m : waMerchandiseDao.selectBatchIds(merchandiseIds)) {
|
||||
merchandiseMap.put(m.getId(), m);
|
||||
}
|
||||
}
|
||||
|
||||
List<WaOrderResponse> responseList = list.stream().map(item -> {
|
||||
WaOrderResponse response = new WaOrderResponse();
|
||||
BeanUtils.copyProperties(item, response);
|
||||
|
||||
// 获取卖家名称
|
||||
|
||||
// 卖家名称
|
||||
if (item.getSellerId() != null && item.getSellerId() > 0) {
|
||||
WaUsers seller = waUsersDao.selectById(item.getSellerId());
|
||||
if (seller != null) {
|
||||
response.setSellerName(seller.getNickname());
|
||||
}
|
||||
WaUsers seller = userMap.get(item.getSellerId());
|
||||
if (seller != null) response.setSellerName(seller.getNickname());
|
||||
} else {
|
||||
response.setSellerName("平台");
|
||||
}
|
||||
|
||||
// 获取买家名称
|
||||
|
||||
// 买家名称
|
||||
if (item.getBuyerId() != null && item.getBuyerId() > 0) {
|
||||
WaUsers buyer = waUsersDao.selectById(item.getBuyerId());
|
||||
if (buyer != null) {
|
||||
response.setBuyerName(buyer.getNickname());
|
||||
WaUsers buyer = userMap.get(item.getBuyerId());
|
||||
if (buyer != null) response.setBuyerName(buyer.getNickname());
|
||||
}
|
||||
|
||||
// 商品名称 / 图片
|
||||
if (item.getMerchandiseId() != null && item.getMerchandiseId() > 0) {
|
||||
WaMerchandise mh = merchandiseMap.get(item.getMerchandiseId());
|
||||
if (mh != null) {
|
||||
response.setMerchandiseTitle(mh.getTitle());
|
||||
response.setMerchandiseImage(mh.getImage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return response;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
|
||||
return CommonPage.copyPageInfo((PageInfo<WaOrder>) new PageInfo<>(list), responseList);
|
||||
}
|
||||
|
||||
@@ -166,7 +202,16 @@ public class WaOrderAdminServiceImpl extends ServiceImpl<WaOrderDao, WaOrder> im
|
||||
response.setBuyerName(buyer.getNickname());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 商品名称 / 图片
|
||||
if (order.getMerchandiseId() != null && order.getMerchandiseId() > 0) {
|
||||
WaMerchandise mh = waMerchandiseDao.selectById(order.getMerchandiseId());
|
||||
if (mh != null) {
|
||||
response.setMerchandiseTitle(mh.getTitle());
|
||||
response.setMerchandiseImage(mh.getImage());
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.zbkj.service.util;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表 — 字段格式化工具。
|
||||
* 抽出为单独类便于单元测试。
|
||||
*/
|
||||
public final class GrabUserFormatter {
|
||||
|
||||
public static final int AMOUNT_SCALE = 3;
|
||||
|
||||
private GrabUserFormatter() {}
|
||||
|
||||
/**
|
||||
* 金额格式化:保留 3 位小数(与上传参考图一致)。
|
||||
*/
|
||||
public static String formatAmount(BigDecimal raw) {
|
||||
BigDecimal v = raw == null ? BigDecimal.ZERO : raw;
|
||||
return v.setScale(AMOUNT_SCALE, RoundingMode.HALF_UP).toPlainString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户等级映射;未知等级回退为「普通用户」。
|
||||
*/
|
||||
public static String mapLevelName(Integer level) {
|
||||
if (level == null) return "普通用户";
|
||||
switch (level) {
|
||||
case 0:
|
||||
case 1:
|
||||
return "普通用户";
|
||||
case 2:
|
||||
return "VIP";
|
||||
case 3:
|
||||
return "合伙人";
|
||||
default:
|
||||
return "等级" + level;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 状态文案:1=正常 / 其它=禁用。
|
||||
*/
|
||||
public static String mapStatus(Integer status) {
|
||||
return status != null && status == 1 ? "正常" : "禁用";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.zbkj.service.util;
|
||||
|
||||
import com.zbkj.common.response.TeamDailyMemberRow;
|
||||
import com.zbkj.common.response.TeamDailySummary;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 — 公式工具类。
|
||||
* 设计要点:
|
||||
* - 所有金额按 HALF_UP 舍入到 2 位;
|
||||
* - 小计的服务费 / E积分 / 实际收付 由"成员级别已舍入数值"再求和,
|
||||
* 避免逐行二次舍入。
|
||||
*
|
||||
* 抽出为单独类便于单元测试(无需 DB / Spring 上下文)。
|
||||
*/
|
||||
public final class TeamReportFormula {
|
||||
|
||||
public static final int SCALE = 2;
|
||||
|
||||
private TeamReportFormula() {}
|
||||
|
||||
/**
|
||||
* 单成员金额舍入与公式计算。
|
||||
* 修改入参对象的 prevBuy / todaySell / todayBuy / serviceFee / eScore / actual。
|
||||
*
|
||||
* @param row 成员行(prevBuy / todaySell / todayBuy 已由 SQL 填充)
|
||||
* @param serviceRate 服务费率
|
||||
* @param eScoreRate E 积分率
|
||||
*/
|
||||
public static void applyFormula(TeamDailyMemberRow row,
|
||||
BigDecimal serviceRate,
|
||||
BigDecimal eScoreRate) {
|
||||
if (row == null) return;
|
||||
row.setPrevBuy(scale(row.getPrevBuy()));
|
||||
row.setTodaySell(scale(row.getTodaySell()));
|
||||
row.setTodayBuy(scale(row.getTodayBuy()));
|
||||
|
||||
BigDecimal serviceFee = scale(row.getTodayBuy().multiply(serviceRate));
|
||||
BigDecimal eScore = scale(row.getTodayBuy().multiply(eScoreRate));
|
||||
BigDecimal actual = scale(row.getTodaySell()
|
||||
.subtract(row.getTodayBuy())
|
||||
.subtract(serviceFee)
|
||||
.subtract(eScore));
|
||||
|
||||
row.setServiceFee(serviceFee);
|
||||
row.setEScore(eScore);
|
||||
row.setActual(actual);
|
||||
}
|
||||
|
||||
/**
|
||||
* 累加每行已舍入数值得到小计;不再做二次舍入。
|
||||
*/
|
||||
public static TeamDailySummary aggregateSummary(List<TeamDailyMemberRow> rows) {
|
||||
TeamDailySummary s = new TeamDailySummary();
|
||||
if (rows == null) return s;
|
||||
for (TeamDailyMemberRow r : rows) {
|
||||
s.setPrevBuy(s.getPrevBuy().add(nz(r.getPrevBuy())));
|
||||
s.setTodaySell(s.getTodaySell().add(nz(r.getTodaySell())));
|
||||
s.setTodayBuy(s.getTodayBuy().add(nz(r.getTodayBuy())));
|
||||
s.setServiceFee(s.getServiceFee().add(nz(r.getServiceFee())));
|
||||
s.setEScore(s.getEScore().add(nz(r.getEScore())));
|
||||
s.setActual(s.getActual().add(nz(r.getActual())));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
public static BigDecimal scale(BigDecimal v) {
|
||||
return v == null ? BigDecimal.ZERO.setScale(SCALE)
|
||||
: v.setScale(SCALE, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private static BigDecimal nz(BigDecimal v) {
|
||||
return v == null ? BigDecimal.ZERO : v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.zbkj.service.dao.consignment.ExternalGrabUserDao">
|
||||
|
||||
<resultMap id="GrabUserResultMap" type="com.zbkj.common.response.ExternalGrabUserResponse">
|
||||
<id property="id" column="id"/>
|
||||
<result property="username" column="username"/>
|
||||
<result property="nickname" column="nickname"/>
|
||||
<result property="mobile" column="mobile"/>
|
||||
<result property="contract" column="contract"/>
|
||||
<result property="pid" column="pid"/>
|
||||
<result property="maxOrder" column="max_order"/>
|
||||
<result property="level" column="level"/>
|
||||
<result property="status" column="status"/>
|
||||
<result property="updatedAt" column="updated_at"/>
|
||||
<result property="moneyRaw" column="money"/>
|
||||
<result property="couponRaw" column="coupon"/>
|
||||
<result property="selfBonusRaw" column="self_bonus"/>
|
||||
<result property="shareBonusRaw" column="share_bonus"/>
|
||||
<result property="todayBuyAmountRaw" column="today_buy_amount"/>
|
||||
<result property="todaySellAmountRaw" column="today_sell_amount"/>
|
||||
<result property="todayBuyCnt" column="today_buy_cnt"/>
|
||||
<result property="prevSellCnt" column="prev_sell_cnt"/>
|
||||
</resultMap>
|
||||
|
||||
<!--
|
||||
SQL 设计要点:
|
||||
1) INNER JOIN buy + HAVING SUM(total_money)>0 把"今日购买总金额>0"过滤直接落到 SQL 层;
|
||||
2) LEFT JOIN sell / prev 不影响过滤结果集;
|
||||
3) is_cancel=0 全程过滤已取消订单。
|
||||
-->
|
||||
<select id="selectGrabUserList" resultMap="GrabUserResultMap">
|
||||
SELECT
|
||||
u.id,
|
||||
u.username,
|
||||
u.nickname,
|
||||
u.mobile,
|
||||
u.contract,
|
||||
u.pid,
|
||||
u.max_order,
|
||||
u.level,
|
||||
u.money,
|
||||
u.coupon,
|
||||
u.self_bonus,
|
||||
u.share_bonus,
|
||||
u.status,
|
||||
u.updated_at,
|
||||
COALESCE(buy.amt, 0) AS today_buy_amount,
|
||||
COALESCE(sell.amt, 0) AS today_sell_amount,
|
||||
COALESCE(buy.cnt, 0) AS today_buy_cnt,
|
||||
COALESCE(prev.cnt, 0) AS prev_sell_cnt
|
||||
FROM wa_users u
|
||||
INNER JOIN (
|
||||
SELECT buyer_id AS uid, SUM(total_money) AS amt, COUNT(*) AS cnt
|
||||
FROM wa_order
|
||||
WHERE is_cancel = 0
|
||||
AND pay_time >= #{todayStart}
|
||||
AND pay_time <= #{todayEnd}
|
||||
GROUP BY buyer_id
|
||||
HAVING SUM(total_money) > 0
|
||||
) buy ON buy.uid = u.id
|
||||
LEFT JOIN (
|
||||
SELECT seller_id AS uid, SUM(total_money) AS amt
|
||||
FROM wa_order
|
||||
WHERE is_cancel = 0
|
||||
AND pay_time >= #{todayStart}
|
||||
AND pay_time <= #{todayEnd}
|
||||
GROUP BY seller_id
|
||||
) sell ON sell.uid = u.id
|
||||
LEFT JOIN (
|
||||
SELECT seller_id AS uid, COUNT(*) AS cnt
|
||||
FROM wa_order
|
||||
WHERE is_cancel = 0
|
||||
AND pay_time >= #{yesterdayStart}
|
||||
AND pay_time <= #{yesterdayEnd}
|
||||
GROUP BY seller_id
|
||||
) prev ON prev.uid = u.id
|
||||
<where>
|
||||
<if test="uid != null">
|
||||
AND u.id = #{uid}
|
||||
</if>
|
||||
<if test="mobile != null and mobile != ''">
|
||||
AND (u.mobile LIKE CONCAT('%', #{mobile}, '%')
|
||||
OR u.username LIKE CONCAT('%', #{mobile}, '%'))
|
||||
</if>
|
||||
<if test="pid != null">
|
||||
AND u.pid = #{pid}
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY today_buy_amount DESC, u.id DESC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.zbkj.service.dao.consignment.TeamReportDao">
|
||||
|
||||
<resultMap id="MemberAggregateResultMap" type="com.zbkj.common.response.TeamDailyMemberRow">
|
||||
<id property="userId" column="user_id"/>
|
||||
<result property="nickname" column="nickname"/>
|
||||
<result property="teamCode" column="team_code"/>
|
||||
<result property="status" column="status"/>
|
||||
<result property="leaderId" column="leader_id"/>
|
||||
<result property="prevBuy" column="prev_buy"/>
|
||||
<result property="todaySell" column="today_sell"/>
|
||||
<result property="todayBuy" column="today_buy"/>
|
||||
</resultMap>
|
||||
|
||||
<!--
|
||||
SQL 设计:
|
||||
- 一次三 LEFT JOIN 聚合:prev / sell / buy
|
||||
- WHERE u.pid = leaderId 限定团队成员(直推下级)
|
||||
- includeDisabled=false 时仅取 status=1
|
||||
- memberIds 非空时再二次过滤
|
||||
-->
|
||||
<!--
|
||||
team-report 聚合查询:
|
||||
- leaderId 非空:仅查该团队长的下级(u.pid = leaderId)
|
||||
- leaderId 为空:查所有有上级的成员(u.pid > 0),后续在 Service 按 leader_id 分组
|
||||
-->
|
||||
<select id="selectTeamMemberAggregates" resultMap="MemberAggregateResultMap">
|
||||
SELECT
|
||||
u.id AS user_id,
|
||||
u.nickname AS nickname,
|
||||
u.invite AS team_code,
|
||||
u.status AS status,
|
||||
u.pid AS leader_id,
|
||||
COALESCE(prev.amt, 0) AS prev_buy,
|
||||
COALESCE(sell.amt, 0) AS today_sell,
|
||||
COALESCE(buy.amt, 0) AS today_buy
|
||||
FROM wa_users u
|
||||
LEFT JOIN (
|
||||
SELECT buyer_id AS uid, SUM(total_money) AS amt
|
||||
FROM wa_order
|
||||
WHERE is_cancel = 0
|
||||
AND pay_time >= #{prevStart}
|
||||
AND pay_time <= #{prevEnd}
|
||||
GROUP BY buyer_id
|
||||
) prev ON prev.uid = u.id
|
||||
LEFT JOIN (
|
||||
SELECT seller_id AS uid, SUM(total_money) AS amt
|
||||
FROM wa_order
|
||||
WHERE is_cancel = 0
|
||||
AND pay_time >= #{dStart}
|
||||
AND pay_time <= #{dEnd}
|
||||
GROUP BY seller_id
|
||||
) sell ON sell.uid = u.id
|
||||
LEFT JOIN (
|
||||
SELECT buyer_id AS uid, SUM(total_money) AS amt
|
||||
FROM wa_order
|
||||
WHERE is_cancel = 0
|
||||
AND pay_time >= #{dStart}
|
||||
AND pay_time <= #{dEnd}
|
||||
GROUP BY buyer_id
|
||||
) buy ON buy.uid = u.id
|
||||
<where>
|
||||
<choose>
|
||||
<when test="leaderId != null">
|
||||
u.pid = #{leaderId}
|
||||
</when>
|
||||
<otherwise>
|
||||
u.pid > 0
|
||||
</otherwise>
|
||||
</choose>
|
||||
<if test="includeDisabled == null or !includeDisabled">
|
||||
AND u.status = 1
|
||||
</if>
|
||||
<if test="memberIds != null and memberIds.size() > 0">
|
||||
AND u.id IN
|
||||
<foreach collection="memberIds" item="mid" open="(" separator="," close=")">
|
||||
#{mid}
|
||||
</foreach>
|
||||
</if>
|
||||
</where>
|
||||
ORDER BY u.pid ASC, u.id ASC
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.zbkj.service.util;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
/**
|
||||
* 今日抢单用户列表 - 格式化工具单元测试。
|
||||
*/
|
||||
public class GrabUserFormatterTest {
|
||||
|
||||
@Test
|
||||
public void formatAmount_null_should_be_zero() {
|
||||
assertEquals("0.000", GrabUserFormatter.formatAmount(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatAmount_should_keep_three_decimals() {
|
||||
assertEquals("0.000", GrabUserFormatter.formatAmount(new BigDecimal("0")));
|
||||
assertEquals("226.383", GrabUserFormatter.formatAmount(new BigDecimal("226.383")));
|
||||
assertEquals("100.000", GrabUserFormatter.formatAmount(new BigDecimal("100")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatAmount_should_round_half_up() {
|
||||
// 0.0005 → 0.001
|
||||
assertEquals("0.001", GrabUserFormatter.formatAmount(new BigDecimal("0.0005")));
|
||||
// 0.0014 → 0.001
|
||||
assertEquals("0.001", GrabUserFormatter.formatAmount(new BigDecimal("0.0014")));
|
||||
// 0.0015 → 0.002
|
||||
assertEquals("0.002", GrabUserFormatter.formatAmount(new BigDecimal("0.0015")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatAmount_should_handle_negative() {
|
||||
assertEquals("-1.234", GrabUserFormatter.formatAmount(new BigDecimal("-1.2340")));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mapLevelName_known_levels() {
|
||||
assertEquals("普通用户", GrabUserFormatter.mapLevelName(null));
|
||||
assertEquals("普通用户", GrabUserFormatter.mapLevelName(0));
|
||||
assertEquals("普通用户", GrabUserFormatter.mapLevelName(1));
|
||||
assertEquals("VIP", GrabUserFormatter.mapLevelName(2));
|
||||
assertEquals("合伙人", GrabUserFormatter.mapLevelName(3));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mapLevelName_unknown_levels_fallback() {
|
||||
assertEquals("等级5", GrabUserFormatter.mapLevelName(5));
|
||||
assertEquals("等级99", GrabUserFormatter.mapLevelName(99));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void mapStatus_should_match_spec() {
|
||||
assertEquals("正常", GrabUserFormatter.mapStatus(1));
|
||||
assertEquals("禁用", GrabUserFormatter.mapStatus(0));
|
||||
assertEquals("禁用", GrabUserFormatter.mapStatus(null));
|
||||
assertEquals("禁用", GrabUserFormatter.mapStatus(2));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.zbkj.service.util;
|
||||
|
||||
import com.zbkj.common.response.TeamDailyMemberRow;
|
||||
import com.zbkj.common.response.TeamDailySummary;
|
||||
import org.junit.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
||||
/**
|
||||
* 团队每日对账日报 公式工具单元测试。
|
||||
* 覆盖:
|
||||
* - 上传参考图样例 4 名成员(王珍华 / 柯美燕 / 王兵启 / 胡晓彩)
|
||||
* - 仅有卖单 / 仅有买单 / 全空 等边界
|
||||
* - 小计精度:与逐行二次舍入对比
|
||||
*/
|
||||
public class TeamReportFormulaTest {
|
||||
|
||||
private static final BigDecimal SERVICE_RATE = new BigDecimal("0.02");
|
||||
private static final BigDecimal E_SCORE_RATE = new BigDecimal("0.005");
|
||||
|
||||
/** 王珍华:22151 - 21418 - 428.36 - 107.09 = 197.55 */
|
||||
@Test
|
||||
public void member_with_buy_and_sell_should_match_doc() {
|
||||
TeamDailyMemberRow r = new TeamDailyMemberRow()
|
||||
.setNickname("王珍华")
|
||||
.setPrevBuy(new BigDecimal("21506"))
|
||||
.setTodaySell(new BigDecimal("22151"))
|
||||
.setTodayBuy(new BigDecimal("21418"));
|
||||
TeamReportFormula.applyFormula(r, SERVICE_RATE, E_SCORE_RATE);
|
||||
|
||||
assertEquals(new BigDecimal("428.36"), r.getServiceFee());
|
||||
assertEquals(new BigDecimal("107.09"), r.getEScore());
|
||||
assertEquals(new BigDecimal("197.55"), r.getActual());
|
||||
}
|
||||
|
||||
/** 胡晓彩:28259 - 27708 - 554.16 - 138.54 = -141.70 */
|
||||
@Test
|
||||
public void member_with_negative_actual() {
|
||||
TeamDailyMemberRow r = new TeamDailyMemberRow()
|
||||
.setNickname("胡晓彩")
|
||||
.setPrevBuy(new BigDecimal("27436"))
|
||||
.setTodaySell(new BigDecimal("28259"))
|
||||
.setTodayBuy(new BigDecimal("27708"));
|
||||
TeamReportFormula.applyFormula(r, SERVICE_RATE, E_SCORE_RATE);
|
||||
|
||||
assertEquals(new BigDecimal("554.16"), r.getServiceFee());
|
||||
assertEquals(new BigDecimal("138.54"), r.getEScore());
|
||||
assertEquals(new BigDecimal("-141.70"), r.getActual());
|
||||
}
|
||||
|
||||
/** 柯美燕 / 王兵启:仅有卖单。服务费/E积分=0;实际=卖单。 */
|
||||
@Test
|
||||
public void member_only_sell_should_zero_fees() {
|
||||
TeamDailyMemberRow r = new TeamDailyMemberRow()
|
||||
.setNickname("柯美燕")
|
||||
.setPrevBuy(new BigDecimal("22519"))
|
||||
.setTodaySell(new BigDecimal("23195"))
|
||||
.setTodayBuy(BigDecimal.ZERO);
|
||||
TeamReportFormula.applyFormula(r, SERVICE_RATE, E_SCORE_RATE);
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), r.getServiceFee());
|
||||
assertEquals(new BigDecimal("0.00"), r.getEScore());
|
||||
assertEquals(new BigDecimal("23195.00"), r.getActual());
|
||||
}
|
||||
|
||||
/** 仅有买单:实际收付为负值(-买 - 服务费 - E积分)。 */
|
||||
@Test
|
||||
public void member_only_buy_should_negative_actual() {
|
||||
TeamDailyMemberRow r = new TeamDailyMemberRow()
|
||||
.setTodaySell(BigDecimal.ZERO)
|
||||
.setTodayBuy(new BigDecimal("1000"));
|
||||
TeamReportFormula.applyFormula(r, SERVICE_RATE, E_SCORE_RATE);
|
||||
|
||||
assertEquals(new BigDecimal("20.00"), r.getServiceFee());
|
||||
assertEquals(new BigDecimal("5.00"), r.getEScore());
|
||||
assertEquals(new BigDecimal("-1025.00"), r.getActual());
|
||||
}
|
||||
|
||||
/** Null 字段:当作 0 处理,不抛异常。 */
|
||||
@Test
|
||||
public void member_with_null_amounts_should_be_zero() {
|
||||
TeamDailyMemberRow r = new TeamDailyMemberRow();
|
||||
TeamReportFormula.applyFormula(r, SERVICE_RATE, E_SCORE_RATE);
|
||||
|
||||
assertEquals(new BigDecimal("0.00"), r.getPrevBuy());
|
||||
assertEquals(new BigDecimal("0.00"), r.getTodaySell());
|
||||
assertEquals(new BigDecimal("0.00"), r.getTodayBuy());
|
||||
assertEquals(new BigDecimal("0.00"), r.getServiceFee());
|
||||
assertEquals(new BigDecimal("0.00"), r.getEScore());
|
||||
assertEquals(new BigDecimal("0.00"), r.getActual());
|
||||
}
|
||||
|
||||
/** 小计:4 名成员全量样例对账。 */
|
||||
@Test
|
||||
public void summary_should_match_doc_team_F_sample() {
|
||||
List<TeamDailyMemberRow> rows = Arrays.asList(
|
||||
row("王珍华", "21506", "22151", "21418"),
|
||||
row("柯美燕", "22519", "23195", "0"),
|
||||
row("王兵启", "34266", "35294", "0"),
|
||||
row("胡晓彩", "27436", "28259", "27708")
|
||||
);
|
||||
rows.forEach(r -> TeamReportFormula.applyFormula(r, SERVICE_RATE, E_SCORE_RATE));
|
||||
TeamDailySummary s = TeamReportFormula.aggregateSummary(rows);
|
||||
|
||||
// 文档样例核对:D-1 买单合计 = 105727
|
||||
assertEquals(new BigDecimal("105727.00"), s.getPrevBuy());
|
||||
// D 卖单合计 = 108899
|
||||
assertEquals(new BigDecimal("108899.00"), s.getTodaySell());
|
||||
// D 买单合计 = 49126
|
||||
assertEquals(new BigDecimal("49126.00"), s.getTodayBuy());
|
||||
// 服务费合计:428.36 + 0 + 0 + 554.16 = 982.52
|
||||
assertEquals(new BigDecimal("982.52"), s.getServiceFee());
|
||||
// E积分合计:107.09 + 0 + 0 + 138.54 = 245.63
|
||||
assertEquals(new BigDecimal("245.63"), s.getEScore());
|
||||
// 实际收付合计:197.55 + 23195 + 35294 + (-141.70) = 58544.85
|
||||
assertEquals(new BigDecimal("58544.85"), s.getActual());
|
||||
}
|
||||
|
||||
/** 空成员列表:小计全 0,不抛异常。 */
|
||||
@Test
|
||||
public void summary_empty_rows_should_zero() {
|
||||
TeamDailySummary s = TeamReportFormula.aggregateSummary(Collections.emptyList());
|
||||
assertNotNull(s);
|
||||
assertEquals(BigDecimal.ZERO, s.getPrevBuy());
|
||||
assertEquals(BigDecimal.ZERO, s.getTodayBuy());
|
||||
assertEquals(BigDecimal.ZERO, s.getActual());
|
||||
}
|
||||
|
||||
/** 不同费率:service_rate=0.03 / e_score_rate=0.01。 */
|
||||
@Test
|
||||
public void custom_rates_should_be_applied() {
|
||||
TeamDailyMemberRow r = new TeamDailyMemberRow()
|
||||
.setTodaySell(new BigDecimal("0"))
|
||||
.setTodayBuy(new BigDecimal("1000"));
|
||||
TeamReportFormula.applyFormula(r, new BigDecimal("0.03"), new BigDecimal("0.01"));
|
||||
|
||||
assertEquals(new BigDecimal("30.00"), r.getServiceFee());
|
||||
assertEquals(new BigDecimal("10.00"), r.getEScore());
|
||||
assertEquals(new BigDecimal("-1040.00"), r.getActual());
|
||||
}
|
||||
|
||||
private static TeamDailyMemberRow row(String name, String prev, String sell, String buy) {
|
||||
return new TeamDailyMemberRow()
|
||||
.setNickname(name)
|
||||
.setPrevBuy(new BigDecimal(prev))
|
||||
.setTodaySell(new BigDecimal(sell))
|
||||
.setTodayBuy(new BigDecimal(buy));
|
||||
}
|
||||
}
|
||||
61
dashboard-frontend/dist/assets/browser-EsQXAio1.js
vendored
Normal file
61
dashboard-frontend/dist/assets/browser-EsQXAio1.js
vendored
Normal file
File diff suppressed because one or more lines are too long
34
dashboard-frontend/dist/assets/index-CApl8iiU.js
vendored
Normal file
34
dashboard-frontend/dist/assets/index-CApl8iiU.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dashboard-frontend/dist/assets/index-CCwqfhCF.css
vendored
Normal file
1
dashboard-frontend/dist/assets/index-CCwqfhCF.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dashboard-frontend/dist/favicon.svg
vendored
Normal file
1
dashboard-frontend/dist/favicon.svg
vendored
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
dashboard-frontend/dist/icons.svg
vendored
Normal file
24
dashboard-frontend/dist/icons.svg
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
14
dashboard-frontend/dist/index.html
vendored
Normal file
14
dashboard-frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>dashboard-frontend</title>
|
||||
<script type="module" crossorigin src="/assets/index-CApl8iiU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CCwqfhCF.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
349
dashboard-frontend/dist/mockServiceWorker.js
vendored
Normal file
349
dashboard-frontend/dist/mockServiceWorker.js
vendored
Normal file
@@ -0,0 +1,349 @@
|
||||
/* eslint-disable */
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.14.5'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
79
deploy/docker/.env.example
Normal file
79
deploy/docker/.env.example
Normal file
@@ -0,0 +1,79 @@
|
||||
# ============================================================
|
||||
# Docker 部署环境变量模板
|
||||
# 使用方法: cp .env.example .env 然后按实际填写
|
||||
# 注意: .env 切勿提交到 git
|
||||
# ============================================================
|
||||
|
||||
# ---------- 基础 ----------
|
||||
TZ=Asia/Shanghai
|
||||
COMPOSE_PROJECT_NAME=integral-shop
|
||||
|
||||
# ---------- 阿里云 RDS for MySQL ----------
|
||||
# 外网地址(与 240 同地域 VPC 时建议改为内网地址,更快更安全)
|
||||
RDS_HOST=rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com
|
||||
RDS_PORT=3306
|
||||
|
||||
# 积分商城(Webman PHP 后端)
|
||||
RDS_INTEGRAL_DB=yangtangyoupin
|
||||
RDS_INTEGRAL_USER=yangtangyoupin
|
||||
RDS_INTEGRAL_PASS=change-me
|
||||
|
||||
# 寄卖商城(Spring Boot 后端) — 与积分商城共库共账号
|
||||
RDS_SINGLE_DB=yangtangyoupin
|
||||
RDS_SINGLE_USER=yangtangyoupin
|
||||
RDS_SINGLE_PASS=change-me
|
||||
|
||||
# ---------- 容器内 Redis ----------
|
||||
REDIS_PASSWORD=change-me-redis
|
||||
# 三个业务各自的 db 序号,建议互不冲突
|
||||
REDIS_INTEGRAL_DB=0
|
||||
REDIS_SINGLE_ADMIN_DB=25
|
||||
REDIS_SINGLE_FRONT_DB=26
|
||||
|
||||
# 是否把 Redis 端口暴露到宿主机(生产建议不暴露,留空字符串即可关闭)
|
||||
REDIS_HOST_PORT=6379
|
||||
|
||||
# ---------- 积分商城 H5 configs.js 注入 ----------
|
||||
# 用户浏览器访问到的 API/图片/H5 域名(必须为外网可达 URL)
|
||||
INTEGRAL_API_PUBLIC_URL=https://admin.example.com
|
||||
INTEGRAL_IMG_PUBLIC_URL=https://admin.example.com
|
||||
INTEGRAL_H5_PUBLIC_URL=https://h5.example.com/
|
||||
# 业务标识:sn_id(数字)、appStr(与寄卖商城 APP_SECRET 必须一致)
|
||||
INTEGRAL_SN_ID=17533260260405
|
||||
INTEGRAL_APP_STR=ZFyTNQTWEkCBczKzyUDJWE9Ecx260405
|
||||
INTEGRAL_TITLE=晨召春商贸
|
||||
INTEGRAL_CONTRACT_PAGE=10012
|
||||
|
||||
# ---------- 积分商城 Webman 后端短信 / OSS ----------
|
||||
# 不用就保持空值;启用时填写真实凭证(也可直接编辑 integral-resell/.env 文件)
|
||||
SMS_CHANNEL=alibaba
|
||||
SMS_SIGNNAME=
|
||||
SMS_TEMPLATE=
|
||||
SMS_KEYID=
|
||||
SMS_KEYSECRET=
|
||||
|
||||
# OSS_TYPE=public 表示用本地 public/upload;oss 表示走阿里云 OSS
|
||||
FILE_STORAGE=public
|
||||
OSS_ACCESS_ID=
|
||||
OSS_ACCESS_SECRET=
|
||||
OSS_BUCKET=
|
||||
OSS_ENDPOINT=
|
||||
OSS_URL=
|
||||
|
||||
# ---------- 寄卖商城前端构建参数 ----------
|
||||
# 留空即走与 Nginx 同域 /api/,最佳实践;如要直连后端写完整 URL
|
||||
SINGLE_ADMIN_BASE_API=
|
||||
SINGLE_H5_DOMAIN=
|
||||
|
||||
# ---------- 寄卖商城后端 JVM ----------
|
||||
SINGLE_ADMIN_JAVA_OPTS=-Xms256m -Xmx512m
|
||||
SINGLE_FRONT_JAVA_OPTS=-Xms256m -Xmx768m
|
||||
|
||||
# ---------- 寄卖商城订单同步(无 MER 时填默认) ----------
|
||||
SYNC_SOURCE_ID=
|
||||
SYNC_TARGET_MER_ID=0
|
||||
|
||||
# ---------- 宿主机暴露端口(可按需修改) ----------
|
||||
INTEGRAL_H5_PORT=18080
|
||||
SINGLE_ADMIN_WEB_PORT=18081
|
||||
SINGLE_H5_PORT=18082
|
||||
3
deploy/docker/.gitignore
vendored
Normal file
3
deploy/docker/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
# 部署敏感文件不入库
|
||||
.env
|
||||
integral-resell/.env
|
||||
113
deploy/docker/README.md
Normal file
113
deploy/docker/README.md
Normal file
@@ -0,0 +1,113 @@
|
||||
# Docker 部署 — 快速上手
|
||||
|
||||
> 详细方案见仓库根目录的 `DOCKER_DEPLOY.md`。本文件只列必要操作。
|
||||
|
||||
## 1. 准备环境变量
|
||||
|
||||
```bash
|
||||
cd deploy/docker
|
||||
cp .env.example .env
|
||||
$EDITOR .env # 填入 RDS / Redis / 域名 等
|
||||
|
||||
cp integral-resell/.env.template integral-resell/.env
|
||||
$EDITOR integral-resell/.env # 积分商城 Webman 后端配置
|
||||
```
|
||||
|
||||
## 2. 阿里云 RDS 初始化
|
||||
|
||||
在 RDS 控制台新建:
|
||||
- 积分商城库(默认名 `yangtangyoupin`)→ 导入 `db/yangtangyoupin.sql`
|
||||
- 寄卖商城库(默认同上)→ 导入 `db/shop22-v2.sql`(或与生产对齐的 `db/jjy153-mysql.sql`)
|
||||
|
||||
把 Docker 主机出口 IP 加入 RDS 白名单。
|
||||
|
||||
## 3. 构建并启动
|
||||
|
||||
```bash
|
||||
# 一次性构建所有镜像(首次 5-15 分钟,含 Maven & Node 拉依赖)
|
||||
docker compose build
|
||||
|
||||
# 后台启动全部服务
|
||||
docker compose up -d
|
||||
|
||||
# 查看状态
|
||||
docker compose ps
|
||||
docker compose logs -f single-admin-api
|
||||
docker compose logs -f single-front-api
|
||||
docker compose logs -f integral-houtai
|
||||
```
|
||||
|
||||
## 4. 验证
|
||||
|
||||
| URL | 期望 |
|
||||
|-----|------|
|
||||
| `http://<host>:18080/` | 积分商城 H5 首页 |
|
||||
| `http://<host>:18080/api/...` | 转发到 integral-houtai |
|
||||
| `http://<host>:18081/` | 寄卖管理后台登录页 |
|
||||
| `http://<host>:18082/` | 寄卖用户端 H5 |
|
||||
|
||||
## 5. 常用运维
|
||||
|
||||
```bash
|
||||
docker compose restart single-admin-api
|
||||
docker compose build --no-cache single-front-api && docker compose up -d single-front-api
|
||||
docker compose exec single-admin-api sh
|
||||
docker compose down # 不删卷
|
||||
docker compose down -v # 连卷一并删除(**慎用**)
|
||||
```
|
||||
|
||||
## 6. 备份卷
|
||||
|
||||
```bash
|
||||
# 在 docker host 上运行
|
||||
for v in integral-upload single-images redis-data; do
|
||||
docker run --rm -v $v:/d -v $(pwd):/b alpine \
|
||||
tar czf /b/${v}-$(date +%F).tgz -C /d .
|
||||
done
|
||||
```
|
||||
|
||||
## 7. "fast" 模式(跳过前端构建,使用源码已有 dist)
|
||||
|
||||
如果源码目录里 `backend-adminend/dist` 和 `single_uniapp22miao/unpackage/dist/build/` 已经是最新构建产物,可加速:
|
||||
|
||||
```bash
|
||||
docker compose build --build-arg=BUILDKIT_INLINE_CACHE=1 \
|
||||
--target fast single-admin-web single-h5
|
||||
```
|
||||
|
||||
## 8. 切换为外部 Redis
|
||||
|
||||
把 `.env` 中 `REDIS_HOST` 改为外部地址、注释 `docker-compose.yml` 中的 `redis:` 服务即可。Spring Boot 与 Webman 都通过环境变量读取 Redis 地址。
|
||||
|
||||
## 9. 远端一键部署(116.62.83.240)
|
||||
|
||||
```bash
|
||||
cd scripts
|
||||
cp server.env.example server.env # 已预填 116.62.83.240 / root / A@123456
|
||||
|
||||
# 首次(推荐):把密码登录换成 SSH key
|
||||
ssh-copy-id root@116.62.83.240
|
||||
# 然后把 server.env 里的 SSHPASS 行注释掉
|
||||
|
||||
# 同步代码并启动
|
||||
./sync-to-server.sh up
|
||||
|
||||
# 同步完成后,若是首次部署还需要先在远端填写 .env:
|
||||
./bootstrap-remote-env.sh
|
||||
./remote-up.sh ssh
|
||||
# 远端:cd /root/integral-shop/deploy/docker
|
||||
# vim .env
|
||||
# vim integral-resell/.env
|
||||
# 编辑完退出后:
|
||||
./remote-up.sh up
|
||||
|
||||
# 日常运维(本机执行,不用登录服务器)
|
||||
./remote-up.sh ps
|
||||
./remote-up.sh logs single-admin-api
|
||||
./remote-up.sh restart single-front-api
|
||||
./remote-up.sh build single-admin-web
|
||||
```
|
||||
|
||||
> 用密码模式需要先 `brew install hudochenkov/sshpass/sshpass`(macOS)。
|
||||
> 用 SSH key 模式则任何依赖都不需要。
|
||||
|
||||
187
deploy/docker/docker-compose.yml
Normal file
187
deploy/docker/docker-compose.yml
Normal file
@@ -0,0 +1,187 @@
|
||||
# =============================================================
|
||||
# 寄卖商城 + 积分商城 前后端 Docker 编排
|
||||
# - 不含 MER-2.2 多商户
|
||||
# - 不含 MySQL(使用阿里云 RDS)
|
||||
# - 包含独立 Redis(如需用外部 Redis,注释掉 redis 服务并修改 REDIS_HOST)
|
||||
# =============================================================
|
||||
|
||||
name: integral-shop
|
||||
|
||||
x-common: &common
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
|
||||
networks:
|
||||
inner:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
integral-runtime:
|
||||
integral-upload:
|
||||
single-images:
|
||||
single-logs:
|
||||
|
||||
services:
|
||||
# ---------------- Redis ----------------
|
||||
redis:
|
||||
<<: *common
|
||||
image: redis:6.2-alpine
|
||||
container_name: integral-shop-redis
|
||||
command: ["redis-server", "/etc/redis/redis.conf", "--requirepass", "${REDIS_PASSWORD}"]
|
||||
volumes:
|
||||
- ./redis/redis.conf:/etc/redis/redis.conf:ro
|
||||
- redis-data:/data
|
||||
networks: [inner]
|
||||
# 仅当 REDIS_HOST_PORT 非空时暴露
|
||||
ports:
|
||||
- "${REDIS_HOST_PORT:-}:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
# ---------------- 积分商城:Webman 后端 ----------------
|
||||
integral-houtai:
|
||||
<<: *common
|
||||
build:
|
||||
context: ../../integral-resell/houtai
|
||||
dockerfile: ../../deploy/docker/integral-resell/houtai.Dockerfile
|
||||
image: integral-shop/integral-houtai:latest
|
||||
container_name: integral-houtai
|
||||
networks: [inner]
|
||||
volumes:
|
||||
# .env 由宿主机模板渲染挂入容器;编辑此文件即可改 DB / OSS / 短信
|
||||
- ./integral-resell/.env:/app/.env:ro
|
||||
- integral-runtime:/app/runtime
|
||||
- integral-upload:/app/public/upload
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# ---------------- 积分商城:H5 静态站 ----------------
|
||||
integral-h5:
|
||||
<<: *common
|
||||
build:
|
||||
context: ../../integral-resell/h5
|
||||
dockerfile: ../../deploy/docker/integral-resell/h5.Dockerfile
|
||||
image: integral-shop/integral-h5:latest
|
||||
container_name: integral-h5
|
||||
networks: [inner]
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
# 这些会在 entrypoint 中被注入到 static/configs.js
|
||||
INTEGRAL_TITLE: ${INTEGRAL_TITLE}
|
||||
INTEGRAL_API_PUBLIC_URL: ${INTEGRAL_API_PUBLIC_URL}
|
||||
INTEGRAL_IMG_PUBLIC_URL: ${INTEGRAL_IMG_PUBLIC_URL}
|
||||
INTEGRAL_H5_PUBLIC_URL: ${INTEGRAL_H5_PUBLIC_URL}
|
||||
INTEGRAL_SN_ID: ${INTEGRAL_SN_ID}
|
||||
INTEGRAL_APP_STR: ${INTEGRAL_APP_STR}
|
||||
INTEGRAL_CONTRACT_PAGE: ${INTEGRAL_CONTRACT_PAGE}
|
||||
ports:
|
||||
- "${INTEGRAL_H5_PORT:-18080}:80"
|
||||
depends_on:
|
||||
- integral-houtai
|
||||
|
||||
# ---------------- 寄卖商城:管理后台 API ----------------
|
||||
single-admin-api:
|
||||
<<: *common
|
||||
build:
|
||||
context: ../../single-shop-22/backend
|
||||
dockerfile: ../../deploy/docker/single-shop/admin-api.Dockerfile
|
||||
image: integral-shop/single-admin-api:latest
|
||||
container_name: single-admin-api
|
||||
networks: [inner]
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
JAVA_HEAP_OPTS: ${SINGLE_ADMIN_JAVA_OPTS:--Xms128m -Xmx256m}
|
||||
SPRING_PROFILES_ACTIVE: docker
|
||||
SERVER_PORT: "30032"
|
||||
MYSQL_HOST: ${RDS_HOST}
|
||||
MYSQL_PORT: ${RDS_PORT:-3306}
|
||||
MYSQL_DATABASE: ${RDS_SINGLE_DB}
|
||||
MYSQL_USERNAME: ${RDS_SINGLE_USER}
|
||||
MYSQL_PASSWORD: ${RDS_SINGLE_PASS}
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: "6379"
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
REDIS_DATABASE: ${REDIS_SINGLE_ADMIN_DB:-25}
|
||||
SYNC_SOURCE_ID: ${SYNC_SOURCE_ID:-}
|
||||
SYNC_TARGET_MER_ID: ${SYNC_TARGET_MER_ID:-0}
|
||||
volumes:
|
||||
- ./single-shop/application-docker.yml:/config/application-docker.yml:ro
|
||||
- single-images:/usr/local/crmeb/crmebimage
|
||||
- single-logs:/app/log
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# ---------------- 寄卖商城:用户端 API ----------------
|
||||
single-front-api:
|
||||
<<: *common
|
||||
build:
|
||||
context: ../../single-shop-22/backend
|
||||
dockerfile: ../../deploy/docker/single-shop/front-api.Dockerfile
|
||||
image: integral-shop/single-front-api:latest
|
||||
container_name: single-front-api
|
||||
networks: [inner]
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
JAVA_OPTS: ${SINGLE_FRONT_JAVA_OPTS:--Xms256m -Xmx768m}
|
||||
SPRING_PROFILES_ACTIVE: docker
|
||||
SERVER_PORT: "30031"
|
||||
MYSQL_HOST: ${RDS_HOST}
|
||||
MYSQL_PORT: ${RDS_PORT:-3306}
|
||||
MYSQL_DATABASE: ${RDS_SINGLE_DB}
|
||||
MYSQL_USERNAME: ${RDS_SINGLE_USER}
|
||||
MYSQL_PASSWORD: ${RDS_SINGLE_PASS}
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: "6379"
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
REDIS_DATABASE: ${REDIS_SINGLE_FRONT_DB:-26}
|
||||
volumes:
|
||||
- ./single-shop/application-docker.yml:/config/application-docker.yml:ro
|
||||
- single-images:/usr/local/crmeb/crmebimage
|
||||
- single-logs:/app/log
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# ---------------- 寄卖商城:管理后台 Web ----------------
|
||||
single-admin-web:
|
||||
<<: *common
|
||||
build:
|
||||
context: ../../single-shop-22/backend-adminend
|
||||
dockerfile: ../../deploy/docker/single-shop/admin-web.Dockerfile
|
||||
args:
|
||||
VUE_APP_BASE_API: ${SINGLE_ADMIN_BASE_API:-}
|
||||
image: integral-shop/single-admin-web:latest
|
||||
container_name: single-admin-web
|
||||
networks: [inner]
|
||||
ports:
|
||||
- "${SINGLE_ADMIN_WEB_PORT:-18081}:80"
|
||||
depends_on:
|
||||
- single-admin-api
|
||||
|
||||
# ---------------- 寄卖商城:用户端 H5 ----------------
|
||||
single-h5:
|
||||
<<: *common
|
||||
build:
|
||||
context: ../../single-shop-22/single_uniapp22miao
|
||||
dockerfile: ../../deploy/docker/single-shop/h5.Dockerfile
|
||||
args:
|
||||
H5_API_DOMAIN: ${SINGLE_H5_DOMAIN:-}
|
||||
image: integral-shop/single-h5:latest
|
||||
container_name: single-h5
|
||||
networks: [inner]
|
||||
ports:
|
||||
- "${SINGLE_H5_PORT:-18082}:80"
|
||||
depends_on:
|
||||
- single-front-api
|
||||
117
deploy/docker/nginx/leilei-jf.czchunfang.com.conf
Normal file
117
deploy/docker/nginx/leilei-jf.czchunfang.com.conf
Normal file
@@ -0,0 +1,117 @@
|
||||
upstream jifenmall_h5 {
|
||||
server 127.0.0.1:18082;
|
||||
keepalive 10240;
|
||||
}
|
||||
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name leilei-jf.czchunfang.com;
|
||||
index index.html index.htm default.htm default.html;
|
||||
root /www/wwwroot/leilei-jf.czchunfang.com;
|
||||
include /www/server/panel/vhost/nginx/extension/leilei-jf.czchunfang.com/*.conf;
|
||||
#CERT-APPLY-CHECK--START
|
||||
include /www/server/panel/vhost/nginx/well-known/leilei-jf.czchunfang.com.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
|
||||
#SSL-START
|
||||
set $isRedcert 1;
|
||||
if ($server_port != 443) {
|
||||
set $isRedcert 2;
|
||||
}
|
||||
if ( $uri ~ /\.well-known/ ) {
|
||||
set $isRedcert 1;
|
||||
}
|
||||
if ($isRedcert != 1) {
|
||||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||||
}
|
||||
ssl_certificate /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leilei-jf.czchunfang.com_cert/nginx/leilei-jf.czchunfang.com.pem;
|
||||
ssl_certificate_key /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leilei-jf.czchunfang.com_cert/nginx/leilei-jf.czchunfang.com.key;
|
||||
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_tickets on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
#SSL-END
|
||||
|
||||
#REWRITE-START
|
||||
include /www/server/panel/vhost/rewrite/html_leilei-jf.czchunfang.com.conf;
|
||||
#REWRITE-END
|
||||
|
||||
# ---------- API 直连 Docker 容器(不经 H5 Nginx 中转) ----------
|
||||
|
||||
# 前台 API(用户端)→ single-front-api 容器 30033
|
||||
location /api/front {
|
||||
proxy_pass http://127.0.0.1:30033/api/front;
|
||||
proxy_http_version 1.1;
|
||||
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_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
# 管理后台 API → single-admin-api 容器 30032
|
||||
location /api/admin {
|
||||
proxy_pass http://127.0.0.1:30032/api/admin;
|
||||
proxy_http_version 1.1;
|
||||
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_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
# 外部接口(同 admin-api)
|
||||
location /api/external {
|
||||
proxy_pass http://127.0.0.1:30032/api/external;
|
||||
proxy_http_version 1.1;
|
||||
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_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
# ---------- H5 前端静态文件 → Docker single-h5 容器 ----------
|
||||
location ^~ / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
if (!-f $request_filename) {
|
||||
proxy_pass http://jifenmall_h5;
|
||||
}
|
||||
}
|
||||
|
||||
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$
|
||||
{ return 404; }
|
||||
|
||||
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/
|
||||
{ return 404; }
|
||||
|
||||
location ~ \.well-known { allow all; }
|
||||
|
||||
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
|
||||
expires 30d; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
location ~ .*\.(js|css)?$ {
|
||||
expires 12h; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/leilei-jf.czchunfang.com.log;
|
||||
error_log /www/wwwlogs/leilei-jf.czchunfang.com.error.log;
|
||||
}
|
||||
80
deploy/docker/nginx/leilei-jfadmin.czchunfang.com.conf
Normal file
80
deploy/docker/nginx/leilei-jfadmin.czchunfang.com.conf
Normal file
@@ -0,0 +1,80 @@
|
||||
upstream jifenmall_admin {
|
||||
server 127.0.0.1:18081;
|
||||
keepalive 10240;
|
||||
}
|
||||
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name leilei-jfadmin.czchunfang.com;
|
||||
index index.html index.htm default.htm default.html;
|
||||
root /www/wwwroot/leilei-jfadmin.czchunfang.com;
|
||||
include /www/server/panel/vhost/nginx/extension/leilei-jfadmin.czchunfang.com/*.conf;
|
||||
#CERT-APPLY-CHECK--START
|
||||
include /www/server/panel/vhost/nginx/well-known/leilei-jfadmin.czchunfang.com.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
|
||||
#SSL-START
|
||||
set $isRedcert 1;
|
||||
if ($server_port != 443) {
|
||||
set $isRedcert 2;
|
||||
}
|
||||
if ( $uri ~ /\.well-known/ ) {
|
||||
set $isRedcert 1;
|
||||
}
|
||||
if ($isRedcert != 1) {
|
||||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||||
}
|
||||
ssl_certificate /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leilei-jfadmin.czchunfang.com_cert/nginx/leilei-jfadmin.czchunfang.com.pem;
|
||||
ssl_certificate_key /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leilei-jfadmin.czchunfang.com_cert/nginx/leilei-jfadmin.czchunfang.com.key;
|
||||
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_tickets on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
#SSL-END
|
||||
|
||||
#REWRITE-START
|
||||
include /www/server/panel/vhost/rewrite/html_leilei-jfadmin.czchunfang.com.conf;
|
||||
#REWRITE-END
|
||||
|
||||
# ---------- 管理后台静态文件 → Docker single-admin-web 容器 ----------
|
||||
# 管理后台前端调用的 API 指向 leilei-jf.czchunfang.com(与 H5 共用 API 域名)
|
||||
location ^~ / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
if (!-f $request_filename) {
|
||||
proxy_pass http://jifenmall_admin;
|
||||
}
|
||||
}
|
||||
|
||||
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$
|
||||
{ return 404; }
|
||||
|
||||
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/
|
||||
{ return 404; }
|
||||
|
||||
location ~ \.well-known { allow all; }
|
||||
|
||||
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
|
||||
expires 30d; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
location ~ .*\.(js|css)?$ {
|
||||
expires 12h; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/leilei-jfadmin.czchunfang.com.log;
|
||||
error_log /www/wwwlogs/leilei-jfadmin.czchunfang.com.error.log;
|
||||
}
|
||||
82
deploy/docker/nginx/leilei.czchunfang.com.conf
Normal file
82
deploy/docker/nginx/leilei.czchunfang.com.conf
Normal file
@@ -0,0 +1,82 @@
|
||||
upstream resell_h5 {
|
||||
server 127.0.0.1:18080;
|
||||
keepalive 10240;
|
||||
}
|
||||
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name leilei.czchunfang.com;
|
||||
index index.html index.htm default.htm default.html;
|
||||
root /www/wwwroot/leilei.czchunfang.com;
|
||||
include /www/server/panel/vhost/nginx/extension/leilei.czchunfang.com/*.conf;
|
||||
#CERT-APPLY-CHECK--START
|
||||
include /www/server/panel/vhost/nginx/well-known/leilei.czchunfang.com.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
|
||||
#SSL-START
|
||||
#error_page 404/404.html;
|
||||
#HTTP_TO_HTTPS_START
|
||||
set $isRedcert 1;
|
||||
if ($server_port != 443) {
|
||||
set $isRedcert 2;
|
||||
}
|
||||
if ( $uri ~ /\.well-known/ ) {
|
||||
set $isRedcert 1;
|
||||
}
|
||||
if ($isRedcert != 1) {
|
||||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||||
}
|
||||
#HTTP_TO_HTTPS_END
|
||||
ssl_certificate /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leilei.czchunfang.com_cert/nginx/leilei.czchunfang.com.pem;
|
||||
ssl_certificate_key /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leilei.czchunfang.com_cert/nginx/leilei.czchunfang.com.key;
|
||||
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_tickets on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
#SSL-END
|
||||
|
||||
#REWRITE-START
|
||||
include /www/server/panel/vhost/rewrite/html_leilei.czchunfang.com.conf;
|
||||
#REWRITE-END
|
||||
|
||||
# 寄卖商城 H5 → Docker integral-h5 容器(内部已含 /api/ /upload/ 反代到 webman:8785)
|
||||
location ^~ / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
if (!-f $request_filename) {
|
||||
proxy_pass http://resell_h5;
|
||||
}
|
||||
}
|
||||
|
||||
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$
|
||||
{ return 404; }
|
||||
|
||||
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/
|
||||
{ return 404; }
|
||||
|
||||
location ~ \.well-known { allow all; }
|
||||
|
||||
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
|
||||
expires 30d; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
location ~ .*\.(js|css)?$ {
|
||||
expires 12h; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/leilei.czchunfang.com.log;
|
||||
error_log /www/wwwlogs/leilei.czchunfang.com.error.log;
|
||||
}
|
||||
82
deploy/docker/nginx/leileiadmin.czchunfang.com.conf
Normal file
82
deploy/docker/nginx/leileiadmin.czchunfang.com.conf
Normal file
@@ -0,0 +1,82 @@
|
||||
upstream resell_api {
|
||||
server 127.0.0.1:18085;
|
||||
keepalive 10240;
|
||||
}
|
||||
|
||||
server
|
||||
{
|
||||
listen 80;
|
||||
listen 443 ssl http2;
|
||||
server_name leileiadmin.czchunfang.com;
|
||||
index index.html index.htm default.htm default.html;
|
||||
root /www/wwwroot/leileiadmin.czchunfang.com;
|
||||
include /www/server/panel/vhost/nginx/extension/leileiadmin.czchunfang.com/*.conf;
|
||||
#CERT-APPLY-CHECK--START
|
||||
include /www/server/panel/vhost/nginx/well-known/leileiadmin.czchunfang.com.conf;
|
||||
#CERT-APPLY-CHECK--END
|
||||
|
||||
#SSL-START
|
||||
#error_page 404/404.html;
|
||||
#HTTP_TO_HTTPS_START
|
||||
set $isRedcert 1;
|
||||
if ($server_port != 443) {
|
||||
set $isRedcert 2;
|
||||
}
|
||||
if ( $uri ~ /\.well-known/ ) {
|
||||
set $isRedcert 1;
|
||||
}
|
||||
if ($isRedcert != 1) {
|
||||
rewrite ^(/.*)$ https://$host$1 permanent;
|
||||
}
|
||||
#HTTP_TO_HTTPS_END
|
||||
ssl_certificate /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leileiadmin.czchunfang.com_cert/nginx/leileiadmin.czchunfang.com.pem;
|
||||
ssl_certificate_key /www/wwwroot/integral-shop/deploy/docker/ssl-cert/leileiadmin.czchunfang.com_cert/nginx/leileiadmin.czchunfang.com.key;
|
||||
ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers EECDH+CHACHA20:EECDH+CHACHA20-draft:EECDH+AES128:RSA+AES128:EECDH+AES256:RSA+AES256:EECDH+3DES:RSA+3DES:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_tickets on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
add_header Strict-Transport-Security "max-age=31536000";
|
||||
error_page 497 https://$host$request_uri;
|
||||
#SSL-END
|
||||
|
||||
#REWRITE-START
|
||||
include /www/server/panel/vhost/rewrite/html_leileiadmin.czchunfang.com.conf;
|
||||
#REWRITE-END
|
||||
|
||||
# 寄卖商城后台 API → Docker integral-houtai 容器(webman.bin 写死端口 8785)
|
||||
location ^~ / {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-For $remote_addr;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
if (!-f $request_filename) {
|
||||
proxy_pass http://resell_api;
|
||||
}
|
||||
}
|
||||
|
||||
location ~* (\.user.ini|\.htaccess|\.htpasswd|\.env.*|\.project|\.bashrc|\.bash_profile|\.bash_logout|\.DS_Store|\.gitignore|\.gitattributes|LICENSE|README\.md|CLAUDE\.md|CHANGELOG\.md|CHANGELOG|CONTRIBUTING\.md|TODO\.md|FAQ\.md|composer\.json|composer\.lock|package(-lock)?\.json|yarn\.lock|pnpm-lock\.yaml|\.\w+~|\.swp|\.swo|\.bak(up)?|\.old|\.tmp|\.temp|\.log|\.sql(\.gz)?|docker-compose\.yml|docker\.env|Dockerfile|\.csproj|\.sln|Cargo\.toml|Cargo\.lock|go\.mod|go\.sum|phpunit\.xml|phpunit\.xml|pom\.xml|build\.gradl|pyproject\.toml|requirements\.txt|application(-\w+)?\.(ya?ml|properties))$
|
||||
{ return 404; }
|
||||
|
||||
location ~* /(\.git|\.svn|\.bzr|\.vscode|\.claude|\.idea|\.ssh|\.github|\.npm|\.yarn|\.pnpm|\.cache|\.husky|\.turbo|\.next|\.nuxt|node_modules|runtime)/
|
||||
{ return 404; }
|
||||
|
||||
location ~ \.well-known { allow all; }
|
||||
|
||||
if ( $uri ~ "^/\.well-known/.*\.(php|jsp|py|js|css|lua|ts|go|zip|tar\.gz|rar|7z|sql|bak)$" ) {
|
||||
return 403;
|
||||
}
|
||||
|
||||
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ {
|
||||
expires 30d; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
location ~ .*\.(js|css)?$ {
|
||||
expires 12h; error_log /dev/null; access_log /dev/null;
|
||||
}
|
||||
|
||||
access_log /www/wwwlogs/leileiadmin.czchunfang.com.log;
|
||||
error_log /www/wwwlogs/leileiadmin.czchunfang.com.error.log;
|
||||
}
|
||||
39
deploy/docker/redis/redis.conf
Normal file
39
deploy/docker/redis/redis.conf
Normal file
@@ -0,0 +1,39 @@
|
||||
# =============================================================
|
||||
# Redis 6.2 — 容器内最小化生产配置
|
||||
# 密码由 docker-compose 通过 --requirepass 参数注入
|
||||
# =============================================================
|
||||
|
||||
bind 0.0.0.0
|
||||
protected-mode yes
|
||||
port 6379
|
||||
tcp-backlog 511
|
||||
timeout 0
|
||||
tcp-keepalive 300
|
||||
|
||||
# 数据持久化: AOF + 一份 RDB 兜底
|
||||
appendonly yes
|
||||
appendfsync everysec
|
||||
save 900 1
|
||||
save 300 10
|
||||
save 60 10000
|
||||
|
||||
dir /data
|
||||
dbfilename dump.rdb
|
||||
appendfilename "appendonly.aof"
|
||||
auto-aof-rewrite-percentage 100
|
||||
auto-aof-rewrite-min-size 64mb
|
||||
|
||||
# 内存策略
|
||||
maxmemory 512mb
|
||||
maxmemory-policy allkeys-lru
|
||||
|
||||
# 日志
|
||||
loglevel notice
|
||||
logfile ""
|
||||
|
||||
# 慢日志
|
||||
slowlog-log-slower-than 10000
|
||||
slowlog-max-len 128
|
||||
|
||||
# 客户端
|
||||
maxclients 1000
|
||||
1
deploy/docker/scripts/.gitignore
vendored
Normal file
1
deploy/docker/scripts/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
server.env
|
||||
39
deploy/docker/scripts/bootstrap-remote-env.sh
Executable file
39
deploy/docker/scripts/bootstrap-remote-env.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# 首次部署:在远端创建 .env 模板(仅在缺失时创建)
|
||||
# 同步代码后执行: ./bootstrap-remote-env.sh
|
||||
# 之后 ssh 上去 vim 这两个文件填入真实密码即可
|
||||
# =============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if [ -f "$SCRIPT_DIR/server.env" ]; then
|
||||
set -a; . "$SCRIPT_DIR/server.env"; set +a
|
||||
fi
|
||||
|
||||
SERVER_HOST="${SERVER_HOST:?}"
|
||||
SERVER_USER="${SERVER_USER:-root}"
|
||||
SERVER_PORT="${SERVER_PORT:-22}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/root/integral-shop}"
|
||||
|
||||
if [ -n "${SSHPASS:-}" ]; then
|
||||
command -v sshpass >/dev/null || { echo "需要 sshpass"; exit 1; }
|
||||
export SSHPASS
|
||||
SSH=(sshpass -e ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
|
||||
else
|
||||
SSH=(ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no)
|
||||
fi
|
||||
|
||||
"${SSH[@]}" "${SERVER_USER}@${SERVER_HOST}" bash -se <<EOSSH
|
||||
set -e
|
||||
cd "${REMOTE_DIR}/deploy/docker"
|
||||
[ -f .env ] || { cp .env.example .env; echo "[+] 已创建 .env (基于 .env.example)"; }
|
||||
[ -f integral-resell/.env ] || { cp integral-resell/.env.template integral-resell/.env; echo "[+] 已创建 integral-resell/.env"; }
|
||||
|
||||
echo
|
||||
echo "请在远端编辑以下文件填入真实参数:"
|
||||
echo " ${REMOTE_DIR}/deploy/docker/.env"
|
||||
echo " ${REMOTE_DIR}/deploy/docker/integral-resell/.env"
|
||||
EOSSH
|
||||
204
deploy/docker/scripts/deploy-step1.sh
Executable file
204
deploy/docker/scripts/deploy-step1.sh
Executable file
@@ -0,0 +1,204 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# 寄卖商城(integral-resell)一键部署脚本
|
||||
# 目标服务器:116.62.83.240
|
||||
# 使用方式:bash deploy/docker/scripts/deploy-step1.sh
|
||||
# =============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
STEP1_DIR="${PROJECT_ROOT}/deploy/docker/step1-integral"
|
||||
|
||||
# ---------- 读取服务器配置 ----------
|
||||
if [ -f "$SCRIPT_DIR/server.env" ]; then
|
||||
set -a; . "$SCRIPT_DIR/server.env"; set +a
|
||||
else
|
||||
echo "[!] 未找到 server.env,请先创建"; exit 1
|
||||
fi
|
||||
|
||||
SERVER_HOST="${SERVER_HOST:?}"
|
||||
SERVER_USER="${SERVER_USER:-root}"
|
||||
SERVER_PORT="${SERVER_PORT:-22}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/root/integral-shop}"
|
||||
REMOTE_STEP1="${REMOTE_DIR}/deploy/docker/step1-integral"
|
||||
|
||||
REDIS_PASS="LeileiRedis@8899"
|
||||
DB_PASS="5Fn8eWrbYFtAhCZw"
|
||||
|
||||
# ---------- SSH / rsync 通道 ----------
|
||||
export SSHPASS="${SSHPASS:?缺少 SSHPASS}"
|
||||
SSH_OPTS="-p $SERVER_PORT -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
SSH=(sshpass -e ssh $SSH_OPTS)
|
||||
RSYNC_SSH="sshpass -e ssh $SSH_OPTS"
|
||||
RSYNC_BIN="/opt/homebrew/bin/rsync"
|
||||
[ -x "$RSYNC_BIN" ] || RSYNC_BIN="rsync"
|
||||
|
||||
remote() { "${SSH[@]}" "${SERVER_USER}@${SERVER_HOST}" "$@"; }
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " 寄卖商城 Docker 一键部署 → ${SERVER_HOST}"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
|
||||
# ─── 1. 生成本地临时 env 文件 ─────────────────────────────────
|
||||
echo ""
|
||||
echo "▶ [1/6] 生成临时 .env / houtai.env ..."
|
||||
|
||||
TMPENV=$(mktemp -d)
|
||||
trap 'rm -rf "$TMPENV"' EXIT
|
||||
|
||||
cat > "$TMPENV/.env" <<EOF
|
||||
TZ=Asia/Shanghai
|
||||
REDIS_PASSWORD=${REDIS_PASS}
|
||||
INTEGRAL_TITLE=池州雷蕾商贸
|
||||
INTEGRAL_API_PUBLIC_URL=https://leileiadmin.czchunfang.com
|
||||
INTEGRAL_IMG_PUBLIC_URL=https://leileiadmin.czchunfang.com
|
||||
INTEGRAL_H5_PUBLIC_URL=https://leilei.czchunfang.com/
|
||||
INTEGRAL_SN_ID=17533260260517
|
||||
INTEGRAL_APP_STR=ZFyTNQTWEkCBczKzyUDJWE9Ecx260517
|
||||
INTEGRAL_CONTRACT_PAGE=10012
|
||||
INTEGRAL_H5_PORT=18080
|
||||
RESELL_API_PORT=18085
|
||||
RESELL_H5_DIR=/www/wwwroot/leilei.czchunfang.com
|
||||
RESELL_HOUTAI_DIR=/www/wwwroot/leileiadmin.czchunfang.com
|
||||
EOF
|
||||
|
||||
cat > "$TMPENV/houtai.env" <<EOF
|
||||
DB_HOST = 'rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com'
|
||||
DB_PORT = 3306
|
||||
DB_DATABASE = 'yangtangyoupin'
|
||||
DB_USERNAME = 'yangtangyoupin'
|
||||
DB_PASSWORD = '${DB_PASS}'
|
||||
REDIS_HOST = 'redis'
|
||||
REDIS_PORT = 6379
|
||||
REDIS_PASSWORD = '${REDIS_PASS}'
|
||||
SMS_CHANNEL = 'alibaba'
|
||||
SMS_SIGNNAME = '池州雷蕾商贸'
|
||||
SMS_TEMPLATE = 'SMS_334320185'
|
||||
SMS_KEYID = 'LTAI5t7CfS15hZGdNLLEMUwG'
|
||||
SMS_KEYSECRET = 'ikfTvHbMMg5sStGgdvLNL8iuVYdner'
|
||||
SMS_SDKAPPID = ''
|
||||
FILE_STORAGE = 'public'
|
||||
OSS_ACCESS_ID = ''
|
||||
OSS_ACCESS_SECRET = ''
|
||||
OSS_BUCKET = ''
|
||||
OSS_ENDPOINT = ''
|
||||
OSS_URL = ''
|
||||
APP_SIGN = '1'
|
||||
APP_SECRET = 'ZFyTNQTWEkCBczKzyUDJWE9Ecx260517'
|
||||
EOF
|
||||
|
||||
echo " ✓ 临时文件已生成"
|
||||
|
||||
# ─── 2. rsync 项目文件 ────────────────────────────────────────
|
||||
echo ""
|
||||
echo "▶ [2/6] rsync 项目文件 → ${SERVER_USER}@${SERVER_HOST}:${REMOTE_DIR}/"
|
||||
remote "mkdir -p '${REMOTE_DIR}'"
|
||||
|
||||
"$RSYNC_BIN" -avz --partial \
|
||||
-e "$RSYNC_SSH" \
|
||||
--exclude '.git/' \
|
||||
--exclude '.DS_Store' \
|
||||
--exclude '*.log' \
|
||||
--exclude '**/.idea/' \
|
||||
--exclude '**/.cursor/' \
|
||||
--exclude '**/.vscode/' \
|
||||
--exclude '**/node_modules/' \
|
||||
--exclude '**/target/' \
|
||||
--exclude '**/unpackage/cache/' \
|
||||
--exclude '**/unpackage/dist/dev/' \
|
||||
--exclude 'integral-resell/houtai/runtime/' \
|
||||
--exclude 'single-shop-22/backend/logs/' \
|
||||
--exclude 'single-shop-22/backend/crmebimage/' \
|
||||
--exclude 'MER-2.2_2601/' \
|
||||
--exclude 'db/' \
|
||||
--exclude 'deploy/docker/scripts/server.env' \
|
||||
--exclude 'deploy/docker/step1-integral/.env' \
|
||||
--exclude 'deploy/docker/step1-integral/houtai.env' \
|
||||
"${PROJECT_ROOT}/" "${SERVER_USER}@${SERVER_HOST}:${REMOTE_DIR}/"
|
||||
|
||||
echo " ✓ 项目文件同步完成"
|
||||
|
||||
# ─── 3. 上传 env 文件 ─────────────────────────────────────────
|
||||
echo ""
|
||||
echo "▶ [3/6] 上传 .env 和 houtai.env ..."
|
||||
|
||||
"$RSYNC_BIN" -avz \
|
||||
-e "$RSYNC_SSH" \
|
||||
"$TMPENV/.env" \
|
||||
"${SERVER_USER}@${SERVER_HOST}:${REMOTE_STEP1}/.env"
|
||||
|
||||
"$RSYNC_BIN" -avz \
|
||||
-e "$RSYNC_SSH" \
|
||||
"$TMPENV/houtai.env" \
|
||||
"${SERVER_USER}@${SERVER_HOST}:${REMOTE_STEP1}/houtai.env"
|
||||
|
||||
echo " ✓ env 文件上传完成"
|
||||
|
||||
# ─── 4. 创建宿主机挂载目录 + 同步 H5 到 wwwroot ──────────────
|
||||
echo ""
|
||||
echo "▶ [4/6] 创建宿主机目录 & 同步 H5 静态文件 ..."
|
||||
|
||||
remote "
|
||||
set -e
|
||||
mkdir -p /www/wwwroot/leilei.czchunfang.com
|
||||
mkdir -p /www/wwwroot/leileiadmin.czchunfang.com/public/upload
|
||||
mkdir -p /www/wwwroot/leileiadmin.czchunfang.com/runtime/logs
|
||||
mkdir -p /www/wwwroot/leileiadmin.czchunfang.com/runtime/sessions
|
||||
echo ' ✓ 宿主机目录已就绪'
|
||||
|
||||
# 同步 H5 静态文件
|
||||
rsync -a --delete \
|
||||
'${REMOTE_DIR}/integral-resell/h5/' \
|
||||
'/www/wwwroot/leilei.czchunfang.com/'
|
||||
echo ' ✓ H5 静态文件已同步到 /www/wwwroot/leilei.czchunfang.com/'
|
||||
|
||||
# 同步 webman 应用文件(webman.bin / public / index.html)到宿主机,
|
||||
# 容器启动后直接从此目录运行,FTP 更新文件后 restart 即可生效
|
||||
rsync -a --exclude='runtime/' --exclude='.env' \
|
||||
'${REMOTE_DIR}/integral-resell/houtai/' \
|
||||
'/www/wwwroot/leileiadmin.czchunfang.com/'
|
||||
chmod +x /www/wwwroot/leileiadmin.czchunfang.com/webman.bin
|
||||
echo ' ✓ webman 应用文件已同步到 /www/wwwroot/leileiadmin.czchunfang.com/'
|
||||
ls /www/wwwroot/leileiadmin.czchunfang.com/ | head -8
|
||||
"
|
||||
|
||||
# ─── 5. docker compose build & up ────────────────────────────
|
||||
echo ""
|
||||
echo "▶ [5/6] docker compose build --no-cache ..."
|
||||
echo " (首次构建耗时 5-15 分钟,请耐心等待)"
|
||||
|
||||
remote "
|
||||
set -e
|
||||
cd '${REMOTE_STEP1}'
|
||||
docker compose --env-file .env build --no-cache
|
||||
echo ' ✓ 镜像构建完成'
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "▶ [6/6] docker compose up -d ..."
|
||||
|
||||
remote "
|
||||
set -e
|
||||
cd '${REMOTE_STEP1}'
|
||||
docker compose --env-file .env up -d
|
||||
echo ''
|
||||
docker compose --env-file .env ps
|
||||
"
|
||||
|
||||
# ─── 完成 ─────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
echo " ✅ 寄卖商城部署完成!"
|
||||
echo ""
|
||||
echo " H5 前台: http://${SERVER_HOST}:18080"
|
||||
echo " API 端口: http://${SERVER_HOST}:18085"
|
||||
echo ""
|
||||
echo " 查看日志:"
|
||||
echo " cd ${REMOTE_STEP1} && docker compose --env-file .env logs -f"
|
||||
echo ""
|
||||
echo " 查看容器状态:"
|
||||
echo " docker compose --env-file .env ps"
|
||||
echo "══════════════════════════════════════════════════════"
|
||||
92
deploy/docker/scripts/remote-up.sh
Executable file
92
deploy/docker/scripts/remote-up.sh
Executable file
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# 远端执行 docker compose 命令
|
||||
# 用法:
|
||||
# ./remote-up.sh up # build + up -d
|
||||
# ./remote-up.sh build # 仅 build
|
||||
# ./remote-up.sh restart svc # 重启某服务
|
||||
# ./remote-up.sh logs [svc] # 跟日志
|
||||
# ./remote-up.sh ps # 服务状态
|
||||
# ./remote-up.sh ssh # 登录到远端
|
||||
# =============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
if [ -f "$SCRIPT_DIR/server.env" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
set -a
|
||||
. "$SCRIPT_DIR/server.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
SERVER_HOST="${SERVER_HOST:?SERVER_HOST 未配置,请先 cp server.env.example server.env}"
|
||||
SERVER_USER="${SERVER_USER:-root}"
|
||||
SERVER_PORT="${SERVER_PORT:-22}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/root/integral-shop}"
|
||||
|
||||
if [ -n "${SSHPASS:-}" ]; then
|
||||
command -v sshpass >/dev/null 2>&1 || { echo "需要 sshpass"; exit 1; }
|
||||
export SSHPASS
|
||||
SSH=(sshpass -e ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -t)
|
||||
else
|
||||
SSH=(ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no -t)
|
||||
fi
|
||||
|
||||
run_remote() {
|
||||
"${SSH[@]}" "${SERVER_USER}@${SERVER_HOST}" "cd ${REMOTE_DIR}/deploy/docker && $*"
|
||||
}
|
||||
|
||||
cmd="${1:-up}"
|
||||
shift || true
|
||||
|
||||
case "$cmd" in
|
||||
up)
|
||||
# 预检:必须存在 .env
|
||||
run_remote "test -f .env && test -f integral-resell/.env" || {
|
||||
cat <<EOF
|
||||
[!] 远端缺少 .env 文件,请在远端执行:
|
||||
|
||||
ssh ${SERVER_USER}@${SERVER_HOST}
|
||||
cd ${REMOTE_DIR}/deploy/docker
|
||||
cp .env.example .env
|
||||
vim .env
|
||||
cp integral-resell/.env.template integral-resell/.env
|
||||
vim integral-resell/.env
|
||||
|
||||
EOF
|
||||
exit 1
|
||||
}
|
||||
run_remote "docker compose build && docker compose up -d && docker compose ps"
|
||||
;;
|
||||
build)
|
||||
run_remote "docker compose build $*"
|
||||
;;
|
||||
restart)
|
||||
run_remote "docker compose restart $*"
|
||||
;;
|
||||
logs)
|
||||
run_remote "docker compose logs --tail 200 -f $*"
|
||||
;;
|
||||
ps|status)
|
||||
run_remote "docker compose ps"
|
||||
;;
|
||||
down)
|
||||
run_remote "docker compose down"
|
||||
;;
|
||||
pull)
|
||||
run_remote "docker compose pull"
|
||||
;;
|
||||
ssh)
|
||||
"${SSH[@]}" "${SERVER_USER}@${SERVER_HOST}"
|
||||
;;
|
||||
exec)
|
||||
run_remote "docker compose exec $*"
|
||||
;;
|
||||
*)
|
||||
echo "未知子命令: $cmd"
|
||||
echo "支持: up | build | restart | logs | ps | down | pull | ssh | exec"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
21
deploy/docker/scripts/server.env.example
Normal file
21
deploy/docker/scripts/server.env.example
Normal file
@@ -0,0 +1,21 @@
|
||||
# =============================================================
|
||||
# 部署目标服务器配置 — 复制为 server.env 并按实际填写
|
||||
# 不要提交到 git
|
||||
# =============================================================
|
||||
|
||||
# 远端服务器
|
||||
# 宝塔 Docker 管理面板: https://116.62.83.240:14874/docker/conmanger
|
||||
SERVER_HOST=116.62.83.240
|
||||
SERVER_USER=root
|
||||
SERVER_PORT=22
|
||||
# 部署到服务器的目标目录
|
||||
REMOTE_DIR=/root/integral-shop
|
||||
|
||||
# 用密码登录时填写(需要 sshpass)
|
||||
# macOS: brew install hudochenkov/sshpass/sshpass
|
||||
# Linux: apt-get install -y sshpass
|
||||
# 留空则走 SSH key(推荐先 ssh-copy-id root@116.62.83.240)
|
||||
SSHPASS=A@123456
|
||||
|
||||
# 是否在 rsync 之后自动 build + up(yes / no)
|
||||
AUTO_UP=yes
|
||||
123
deploy/docker/scripts/sync-to-server.sh
Executable file
123
deploy/docker/scripts/sync-to-server.sh
Executable file
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================
|
||||
# 通过 rsync 把整个工程同步到远端服务器
|
||||
# 用法:
|
||||
# 1. 复制 server.env.example -> server.env,填入主机/密码
|
||||
# 2. ./sync-to-server.sh # 仅同步
|
||||
# ./sync-to-server.sh up # 同步后在远端 docker compose build && up -d
|
||||
# ./sync-to-server.sh logs # 远端 docker compose logs
|
||||
# =============================================================
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
|
||||
# 读取服务器配置
|
||||
if [ -f "$SCRIPT_DIR/server.env" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
set -a
|
||||
. "$SCRIPT_DIR/server.env"
|
||||
set +a
|
||||
elif [ -f "$SCRIPT_DIR/server.env.example" ]; then
|
||||
echo "[!] 未发现 server.env,请先 cp server.env.example server.env 并修改"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER_HOST="${SERVER_HOST:?SERVER_HOST 未配置}"
|
||||
SERVER_USER="${SERVER_USER:-root}"
|
||||
SERVER_PORT="${SERVER_PORT:-22}"
|
||||
REMOTE_DIR="${REMOTE_DIR:-/root/integral-shop}"
|
||||
|
||||
# ---------- 选择 SSH/rsync 通道 ----------
|
||||
if [ -n "${SSHPASS:-}" ]; then
|
||||
if ! command -v sshpass >/dev/null 2>&1; then
|
||||
echo "[!] 检测到 SSHPASS 但未安装 sshpass"
|
||||
echo " macOS: brew install hudochenkov/sshpass/sshpass"
|
||||
echo " Linux: apt-get install -y sshpass"
|
||||
exit 1
|
||||
fi
|
||||
export SSHPASS
|
||||
SSH=(sshpass -e ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
|
||||
RSYNC_SSH="sshpass -e ssh -p $SERVER_PORT -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
|
||||
else
|
||||
SSH=(ssh -p "$SERVER_PORT" -o StrictHostKeyChecking=no -o ServerAliveInterval=30)
|
||||
RSYNC_SSH="ssh -p $SERVER_PORT -o StrictHostKeyChecking=no -o ServerAliveInterval=30"
|
||||
fi
|
||||
|
||||
remote() {
|
||||
"${SSH[@]}" "${SERVER_USER}@${SERVER_HOST}" "$@"
|
||||
}
|
||||
|
||||
# ---------- 1. 远端目录准备 ----------
|
||||
echo "[1/3] 准备远端目录 ${SERVER_USER}@${SERVER_HOST}:${REMOTE_DIR}"
|
||||
remote "mkdir -p '${REMOTE_DIR}'"
|
||||
|
||||
# 优先使用 homebrew rsync(macOS 内置版本 2.6.9 太旧)
|
||||
RSYNC_BIN="/opt/homebrew/bin/rsync"
|
||||
[ -x "$RSYNC_BIN" ] || RSYNC_BIN="rsync"
|
||||
|
||||
# ---------- 2. rsync ----------
|
||||
echo "[2/3] rsync ${PROJECT_ROOT}/ -> ${SERVER_USER}@${SERVER_HOST}:${REMOTE_DIR}/"
|
||||
"$RSYNC_BIN" -avz --delete --partial \
|
||||
-e "$RSYNC_SSH" \
|
||||
--exclude '.git/' \
|
||||
--exclude '.DS_Store' \
|
||||
--exclude '*.log' \
|
||||
--exclude '**/.idea/' \
|
||||
--exclude '**/.cursor/' \
|
||||
--exclude '**/.vscode/' \
|
||||
--exclude '**/node_modules/' \
|
||||
--exclude '**/target/' \
|
||||
--exclude '**/unpackage/cache/' \
|
||||
--exclude '**/unpackage/dist/dev/' \
|
||||
--exclude 'integral-resell/houtai/runtime/logs/' \
|
||||
--exclude 'integral-resell/houtai/runtime/sessions/' \
|
||||
--exclude 'integral-resell/houtai/runtime/login/' \
|
||||
--exclude 'integral-resell/houtai/runtime/webman.pid' \
|
||||
--exclude 'single-shop-22/backend/logs/' \
|
||||
--exclude 'single-shop-22/backend/crmebimage/' \
|
||||
--exclude 'single-shop-22/backend-adminend/dist/' \
|
||||
--exclude 'MER-2.2_2601/' \
|
||||
--exclude 'db/mysql-recover-20260514/' \
|
||||
--exclude 'db/yangtangyoupin.sql' \
|
||||
--exclude 'deploy/docker/.env' \
|
||||
--exclude 'deploy/docker/integral-resell/.env' \
|
||||
--exclude 'deploy/docker/scripts/server.env' \
|
||||
"${PROJECT_ROOT}/" "${SERVER_USER}@${SERVER_HOST}:${REMOTE_DIR}/"
|
||||
|
||||
# ---------- 3. 远端 env 文件提示 ----------
|
||||
echo "[3/3] 检查远端环境文件"
|
||||
remote "test -f ${REMOTE_DIR}/deploy/docker/.env || echo '[!] 远端缺少 deploy/docker/.env,请按下面提示创建'"
|
||||
remote "test -f ${REMOTE_DIR}/deploy/docker/integral-resell/.env || echo '[!] 远端缺少 deploy/docker/integral-resell/.env'"
|
||||
|
||||
echo
|
||||
echo "==============================================="
|
||||
echo " rsync 完成"
|
||||
echo " 远端目录: ${REMOTE_DIR}"
|
||||
echo "==============================================="
|
||||
|
||||
# ---------- 后续动作 ----------
|
||||
case "${1:-${AUTO_UP:-}}" in
|
||||
up|yes|YES)
|
||||
echo "[远端] 执行 docker compose build && up -d"
|
||||
"$SCRIPT_DIR/remote-up.sh" up
|
||||
;;
|
||||
build)
|
||||
"$SCRIPT_DIR/remote-up.sh" build
|
||||
;;
|
||||
logs)
|
||||
shift || true
|
||||
"$SCRIPT_DIR/remote-up.sh" logs "$@"
|
||||
;;
|
||||
"")
|
||||
echo "下一步:"
|
||||
echo " $0 up # 同步并启动"
|
||||
echo " $0 build # 仅构建"
|
||||
echo " $0 logs # 查看日志"
|
||||
;;
|
||||
*)
|
||||
echo "未知参数: $1"
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
54
deploy/docker/single-shop/admin-api.Dockerfile
Normal file
54
deploy/docker/single-shop/admin-api.Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# =============================================================
|
||||
# 积分商城 管理端 API(miao-admin-2.2.jar)
|
||||
# JAR 由宿主机 bind-mount 进来(/app/app.jar),无需 Maven 编译
|
||||
# 宿主机路径:${SINGLE_ADMIN_JAR} → /app/app.jar
|
||||
# FTP 更新 JAR 后:docker compose --env-file .env restart single-admin-api
|
||||
# =============================================================
|
||||
|
||||
FROM eclipse-temurin:17-jre-jammy
|
||||
|
||||
ENV TZ=Asia/Shanghai \
|
||||
LANG=C.UTF-8 LC_ALL=C.UTF-8 \
|
||||
DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# 切换阿里云 Ubuntu 镜像源(服务器访问 archive.ubuntu.com 超时)
|
||||
RUN sed -i \
|
||||
-e 's|http://archive.ubuntu.com|https://mirrors.aliyun.com|g' \
|
||||
-e 's|http://security.ubuntu.com|https://mirrors.aliyun.com|g' \
|
||||
/etc/apt/sources.list
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tzdata ca-certificates curl \
|
||||
fontconfig fonts-dejavu fonts-wqy-zenhei \
|
||||
&& ln -sf /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||
&& echo $TZ > /etc/timezone \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /app /config /usr/local/crmeb/crmebimage /app/log
|
||||
|
||||
# 堆大小(可通过 compose environment 覆盖)
|
||||
ENV JAVA_HEAP_OPTS="-Xms128m -Xmx256m"
|
||||
|
||||
# Spring Boot 2.2.6 + Java 17 必须的模块开放参数
|
||||
ENV JAVA_MODULE_OPTS="\
|
||||
--add-opens java.base/java.lang=ALL-UNNAMED \
|
||||
--add-opens java.base/java.lang.reflect=ALL-UNNAMED \
|
||||
--add-opens java.base/java.util=ALL-UNNAMED \
|
||||
--add-opens java.base/java.io=ALL-UNNAMED \
|
||||
--add-opens java.base/java.math=ALL-UNNAMED \
|
||||
--add-opens java.base/sun.net.util=ALL-UNNAMED \
|
||||
--add-opens java.base/java.net=ALL-UNNAMED"
|
||||
|
||||
WORKDIR /app
|
||||
# /app/app.jar 由 docker-compose volumes bind-mount 进来
|
||||
EXPOSE 30032
|
||||
|
||||
# 等价于:nohup java -Xms128m -Xmx256m -jar miao-admin-2.2.jar > admin.log &
|
||||
ENTRYPOINT ["sh","-c","exec java \
|
||||
$JAVA_HEAP_OPTS \
|
||||
$JAVA_MODULE_OPTS \
|
||||
-Dfile.encoding=UTF-8 \
|
||||
-Duser.timezone=$TZ \
|
||||
-jar /app/app.jar \
|
||||
--spring.profiles.active=${SPRING_PROFILES_ACTIVE:-docker} \
|
||||
--spring.config.additional-location=file:/config/ \
|
||||
--server.port=${SERVER_PORT:-30032}"]
|
||||
61
deploy/docker/single-shop/admin-web.Dockerfile
Normal file
61
deploy/docker/single-shop/admin-web.Dockerfile
Normal file
@@ -0,0 +1,61 @@
|
||||
# =============================================================
|
||||
# 积分商城 管理后台前端(Vue 2 SPA)
|
||||
# 纯 Nginx 运行时镜像,不含 Node 构建阶段
|
||||
# 静态文件由宿主机 bind-mount 进来(${SINGLE_ADMIN_WEB_DIR}:/usr/share/nginx/html)
|
||||
# 宿主机目录示例:/www/wwwroot/leilei-jfadmin.czchunfang.com/
|
||||
# 更新方式:rsync 新 dist 到宿主机目录 → 无需重建镜像
|
||||
# =============================================================
|
||||
|
||||
FROM nginx:1.25-alpine
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
# 切换阿里云 Alpine 镜像源
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache tzdata \
|
||||
&& cp /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||
&& echo $TZ > /etc/timezone \
|
||||
&& rm -f /etc/apk/cache/*.apk
|
||||
|
||||
# Nginx 反代配置
|
||||
# /api/ 和 /adminapi/ 代理到 single-admin-api 容器
|
||||
RUN cat > /etc/nginx/conf.d/default.conf <<'NGX'
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 50m;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
|
||||
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|map)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://single-admin-api:30032/api/;
|
||||
proxy_http_version 1.1;
|
||||
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_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
location /adminapi/ {
|
||||
proxy_pass http://single-admin-api:30032/adminapi/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
NGX
|
||||
|
||||
EXPOSE 80
|
||||
70
deploy/docker/single-shop/application-docker.yml
Normal file
70
deploy/docker/single-shop/application-docker.yml
Normal file
@@ -0,0 +1,70 @@
|
||||
# =============================================================
|
||||
# 积分商城 Docker 部署专用 Spring profile
|
||||
# 通过 --spring.config.additional-location=file:/config/ + --spring.profiles.active=docker
|
||||
# 加载本文件,并由环境变量覆盖关键参数
|
||||
# =============================================================
|
||||
|
||||
server:
|
||||
port: ${SERVER_PORT:-30032}
|
||||
|
||||
crmeb:
|
||||
imagePath: /usr/local/crmeb/crmebimage/
|
||||
captchaOn: false
|
||||
asyncConfig: true
|
||||
demoSite: false
|
||||
|
||||
spring:
|
||||
datasource:
|
||||
name: ${MYSQL_DATABASE}
|
||||
type: com.alibaba.druid.pool.DruidDataSource
|
||||
driver-class-name: com.mysql.jdbc.Driver
|
||||
url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT:3306}/${MYSQL_DATABASE}?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&useSSL=false&allowPublicKeyRetrieval=true
|
||||
username: ${MYSQL_USERNAME}
|
||||
password: ${MYSQL_PASSWORD}
|
||||
druid:
|
||||
initial-size: 5
|
||||
min-idle: 5
|
||||
max-active: 50
|
||||
max-wait: 60000
|
||||
validation-query: SELECT 1
|
||||
test-while-idle: true
|
||||
test-on-borrow: false
|
||||
test-on-return: false
|
||||
redis:
|
||||
host: ${REDIS_HOST:redis}
|
||||
port: ${REDIS_PORT:6379}
|
||||
password: ${REDIS_PASSWORD:}
|
||||
database: ${REDIS_DATABASE:0}
|
||||
timeout: 10000
|
||||
jedis:
|
||||
pool:
|
||||
max-active: 200
|
||||
max-wait: -1
|
||||
max-idle: 10
|
||||
min-idle: 0
|
||||
time-between-eviction-runs: -1
|
||||
second:
|
||||
database: ${REDIS_SECOND_DATABASE:1}
|
||||
|
||||
# 订单同步(无 MER 时填默认)
|
||||
sync:
|
||||
source-id: ${SYNC_SOURCE_ID:}
|
||||
target-mer-id: ${SYNC_TARGET_MER_ID:0}
|
||||
|
||||
logging:
|
||||
level:
|
||||
io.swagger.*: error
|
||||
com.zbjk.crmeb: info
|
||||
org.springframework.boot.autoconfigure: ERROR
|
||||
config: classpath:logback-spring.xml
|
||||
file:
|
||||
path: /app/log
|
||||
|
||||
mybatis-plus:
|
||||
configuration:
|
||||
log-impl:
|
||||
|
||||
swagger:
|
||||
basic:
|
||||
enable: false
|
||||
check: false
|
||||
54
deploy/docker/single-shop/front-api.Dockerfile
Normal file
54
deploy/docker/single-shop/front-api.Dockerfile
Normal file
@@ -0,0 +1,54 @@
|
||||
# =============================================================
|
||||
# 积分商城 用户端 API(miao-front-2.2.jar)
|
||||
# JAR 由宿主机 bind-mount 进来(/app/app.jar),无需 Maven 编译
|
||||
# 宿主机路径:${SINGLE_FRONT_JAR} → /app/app.jar
|
||||
# FTP 更新 JAR 后:docker compose --env-file .env restart single-front-api
|
||||
# =============================================================
|
||||
|
||||
FROM eclipse-temurin:17-jre-jammy
|
||||
|
||||
ENV TZ=Asia/Shanghai \
|
||||
LANG=C.UTF-8 LC_ALL=C.UTF-8 \
|
||||
DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# 切换阿里云 Ubuntu 镜像源(服务器访问 archive.ubuntu.com 超时)
|
||||
RUN sed -i \
|
||||
-e 's|http://archive.ubuntu.com|https://mirrors.aliyun.com|g' \
|
||||
-e 's|http://security.ubuntu.com|https://mirrors.aliyun.com|g' \
|
||||
/etc/apt/sources.list
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
tzdata ca-certificates curl \
|
||||
fontconfig fonts-dejavu fonts-wqy-zenhei \
|
||||
&& ln -sf /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||
&& echo $TZ > /etc/timezone \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir -p /app /config /usr/local/crmeb/crmebimage /app/log
|
||||
|
||||
# 堆大小(可通过 compose environment 覆盖)
|
||||
ENV JAVA_HEAP_OPTS="-Xms128m -Xmx256m"
|
||||
|
||||
# Spring Boot 2.2.6 + Java 17 必须的模块开放参数
|
||||
ENV JAVA_MODULE_OPTS="\
|
||||
--add-opens java.base/java.lang=ALL-UNNAMED \
|
||||
--add-opens java.base/java.lang.reflect=ALL-UNNAMED \
|
||||
--add-opens java.base/java.util=ALL-UNNAMED \
|
||||
--add-opens java.base/java.io=ALL-UNNAMED \
|
||||
--add-opens java.base/java.math=ALL-UNNAMED \
|
||||
--add-opens java.base/sun.net.util=ALL-UNNAMED \
|
||||
--add-opens java.base/java.net=ALL-UNNAMED"
|
||||
|
||||
WORKDIR /app
|
||||
# /app/app.jar 由 docker-compose volumes bind-mount 进来
|
||||
EXPOSE 30033
|
||||
|
||||
# 等价于:nohup java -Xms128m -Xmx256m -jar miao-front-2.2.jar > front.log &
|
||||
ENTRYPOINT ["sh","-c","exec java \
|
||||
$JAVA_HEAP_OPTS \
|
||||
$JAVA_MODULE_OPTS \
|
||||
-Dfile.encoding=UTF-8 \
|
||||
-Duser.timezone=$TZ \
|
||||
-jar /app/app.jar \
|
||||
--spring.profiles.active=${SPRING_PROFILES_ACTIVE:-docker} \
|
||||
--spring.config.additional-location=file:/config/ \
|
||||
--server.port=${SERVER_PORT:-30033}"]
|
||||
55
deploy/docker/single-shop/h5.Dockerfile
Normal file
55
deploy/docker/single-shop/h5.Dockerfile
Normal file
@@ -0,0 +1,55 @@
|
||||
# =============================================================
|
||||
# 积分商城 用户端 H5(uni-app SPA)
|
||||
# 纯 Nginx 运行时镜像,不含 Node 构建阶段
|
||||
# 静态文件由宿主机 bind-mount 进来(${SINGLE_H5_DIR}:/usr/share/nginx/html)
|
||||
# 宿主机目录示例:/www/wwwroot/leilei-jf.czchunfang.com/
|
||||
# 更新方式:rsync 新 dist 到宿主机目录 → 无需重建镜像
|
||||
# =============================================================
|
||||
|
||||
FROM nginx:1.25-alpine
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
# 切换阿里云 Alpine 镜像源
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache tzdata \
|
||||
&& cp /usr/share/zoneinfo/$TZ /etc/localtime \
|
||||
&& echo $TZ > /etc/timezone \
|
||||
&& rm -f /etc/apk/cache/*.apk
|
||||
|
||||
# Nginx 反代配置
|
||||
# API 请求代理到 single-front-api 容器(Docker 内网,不经宝塔 Nginx)
|
||||
RUN cat > /etc/nginx/conf.d/default.conf <<'NGX'
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 50m;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
|
||||
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|map)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# 前台 API(单点登录/商品/订单等)
|
||||
location /api/ {
|
||||
proxy_pass http://single-front-api:30033/api/;
|
||||
proxy_http_version 1.1;
|
||||
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_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
NGX
|
||||
|
||||
EXPOSE 80
|
||||
37
deploy/docker/single-shop/nginx-admin-web.conf
Normal file
37
deploy/docker/single-shop/nginx-admin-web.conf
Normal file
@@ -0,0 +1,37 @@
|
||||
# 仅供参考: 内容已内联到 admin-web.Dockerfile
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 50m;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
|
||||
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|map)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://single-admin-api:30032/api/;
|
||||
proxy_http_version 1.1;
|
||||
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_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
location /adminapi/ {
|
||||
proxy_pass http://single-admin-api:30032/adminapi/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
30
deploy/docker/single-shop/nginx-h5.conf
Normal file
30
deploy/docker/single-shop/nginx-h5.conf
Normal file
@@ -0,0 +1,30 @@
|
||||
# 仅供参考: 内容已内联到 h5.Dockerfile
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 50m;
|
||||
add_header X-Frame-Options SAMEORIGIN always;
|
||||
|
||||
location ~* \.(?:js|css|png|jpg|jpeg|gif|svg|woff2?|ttf|map)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, max-age=2592000, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://single-front-api:30031/api/;
|
||||
proxy_http_version 1.1;
|
||||
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_read_timeout 120s;
|
||||
client_max_body_size 50m;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
7
deploy/docker/ssl-cert/.gitignore
vendored
Normal file
7
deploy/docker/ssl-cert/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# 忽略证书文件,不入库
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.cer
|
||||
*.p12
|
||||
*.pfx
|
||||
31
deploy/docker/step1-integral/.env.example
Normal file
31
deploy/docker/step1-integral/.env.example
Normal file
@@ -0,0 +1,31 @@
|
||||
# =============================================================
|
||||
# 步骤一:寄卖商城环境变量 — 池州雷蕾商贸 czleilei240
|
||||
# cp .env.example .env 并填入真实密码
|
||||
# .env 不入库
|
||||
# =============================================================
|
||||
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# ---------- Redis(容器内) ----------
|
||||
REDIS_PASSWORD=change-me-redis
|
||||
|
||||
# ---------- H5 对外域名(浏览器可达) ----------
|
||||
INTEGRAL_TITLE=池州雷蕾商贸
|
||||
INTEGRAL_API_PUBLIC_URL=https://leileiadmin.czchunfang.com
|
||||
INTEGRAL_IMG_PUBLIC_URL=https://leileiadmin.czchunfang.com
|
||||
INTEGRAL_H5_PUBLIC_URL=https://leilei.czchunfang.com/
|
||||
INTEGRAL_SN_ID=17533260260517
|
||||
INTEGRAL_APP_STR=ZFyTNQTWEkCBczKzyUDJWE9Ecx260517
|
||||
INTEGRAL_CONTRACT_PAGE=10012
|
||||
|
||||
# ---------- 宿主机暴露端口 ----------
|
||||
INTEGRAL_H5_PORT=18080
|
||||
# webman API 直连端口(宝塔 Nginx leileiadmin.czchunfang.com → 此端口)
|
||||
RESELL_API_PORT=18085
|
||||
|
||||
# ---------- 宿主机目录映射(bind mount,与原部署路径一致)----------
|
||||
# 寄卖商城 H5 静态文件目录(手动改 JS/configs.js 直接生效,无需重建镜像)
|
||||
RESELL_H5_DIR=/www/wwwroot/leilei.czchunfang.com
|
||||
# webman 后台完整应用目录(FTP 上传新 webman.bin/public/ 后 restart 容器即可更新)
|
||||
# 上传图片、public/upload 等均包含在此目录内,无需单独挂载
|
||||
RESELL_HOUTAI_DIR=/www/wwwroot/leileiadmin.czchunfang.com
|
||||
2
deploy/docker/step1-integral/.gitignore
vendored
Normal file
2
deploy/docker/step1-integral/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
houtai.env
|
||||
126
deploy/docker/step1-integral/README.md
Normal file
126
deploy/docker/step1-integral/README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 步骤一:寄卖商城 Docker 部署(池州雷蕾商贸 czleilei240)
|
||||
|
||||
项目:`integral-resell`(寄卖商城)
|
||||
服务:`redis` · `integral-houtai`(Webman PHP 8.0)· `integral-h5`(Nginx 静态站)
|
||||
|
||||
步骤二(积分商城)与本步骤完全独立,可以单独部署、单独重启。
|
||||
|
||||
---
|
||||
|
||||
## 快速部署
|
||||
|
||||
```bash
|
||||
cd deploy/docker/step1-integral
|
||||
|
||||
# 1. 准备环境变量
|
||||
cp .env.example .env
|
||||
cp houtai.env.example houtai.env
|
||||
vim .env # 填入 REDIS_PASSWORD
|
||||
vim houtai.env # 填入 DB_PASSWORD(RDS 密码)、REDIS_PASSWORD(同 .env)
|
||||
|
||||
# 2. 首次部署:在服务器上确保宿主机目录存在
|
||||
# (若原部署目录已存在则跳过)
|
||||
mkdir -p /www/wwwroot/leilei.czchunfang.com
|
||||
mkdir -p /www/wwwroot/leileiadmin.czchunfang.com/public/upload
|
||||
|
||||
# 3. 将 H5 静态文件同步到宿主机目录(首次 / 每次前端更新后)
|
||||
rsync -av integral-resell/h5/ /www/wwwroot/leilei.czchunfang.com/
|
||||
|
||||
# 4. 构建并启动
|
||||
docker compose --env-file .env up -d --build
|
||||
|
||||
# 5. 查看状态
|
||||
docker compose --env-file .env ps
|
||||
docker compose --env-file .env logs -f integral-houtai
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 目录映射(宿主机 ↔ 容器)
|
||||
|
||||
| 宿主机路径 | 容器路径 | 用途 |
|
||||
|---|---|---|
|
||||
| `/www/wwwroot/leilei.czchunfang.com` | `/usr/share/nginx/html` | H5 静态文件,手动改 JS 即时生效 |
|
||||
| `/www/wwwroot/leileiadmin.czchunfang.com/public/upload` | `/app/public/upload` | webman 后台上传文件 |
|
||||
| `./houtai.env` | `/app/.env` | 运行时配置,只读挂入,不打进镜像 |
|
||||
| `integral-runtime`(named vol)| `/app/runtime` | webman PID、session 等运行时数据 |
|
||||
|
||||
> **H5 文件更新流程**:直接修改 `/www/wwwroot/leilei.czchunfang.com/` 下的文件(如 JS bundle、configs.js),
|
||||
> Nginx 下次请求时自动读取新文件,**无需重启容器**。
|
||||
> 仅当 Nginx 配置或镜像本身需要变更时,才需要 `docker compose build integral-h5`。
|
||||
|
||||
| 域名 | 用途 | Docker 容器端口 | 宿主机端口 | 宝塔 upstream |
|
||||
|---|---|---|---|---|
|
||||
| `leilei.czchunfang.com` | 寄卖商城 H5 | integral-h5:80 | **18080** | `resell_h5` |
|
||||
| `leileiadmin.czchunfang.com` | 寄卖商城 API / 后台 | integral-houtai:**8785** | **18085** | `resell_api` |
|
||||
|
||||
> webman.bin 写死监听 **8785** 端口。
|
||||
> - H5 容器内部 Nginx 已将 `/api/` 和 `/upload/` 代理到 `integral-houtai:8785`(Docker 内网,无需暴露)
|
||||
> - 宝塔 Nginx 的 `leileiadmin.czchunfang.com` 直连宿主机 **18085**(映射到 webman 8785)
|
||||
|
||||
---
|
||||
|
||||
## 宝塔 Nginx 配置
|
||||
|
||||
将以下两个文件内容分别粘贴到宝塔面板对应站点的「配置文件」中:
|
||||
|
||||
| 配置文件 | 说明 |
|
||||
|---|---|
|
||||
| `deploy/docker/nginx/leilei.czchunfang.com.conf` | H5 站点,upstream → 127.0.0.1:18080 |
|
||||
| `deploy/docker/nginx/leileiadmin.czchunfang.com.conf` | API 站点,upstream → 127.0.0.1:18085 |
|
||||
|
||||
证书路径(文件已在项目中):
|
||||
|
||||
```
|
||||
deploy/docker/ssl-cert/
|
||||
leilei.czchunfang.com_cert/nginx/leilei.czchunfang.com.{pem,key}
|
||||
leileiadmin.czchunfang.com_cert/nginx/leileiadmin.czchunfang.com.{pem,key}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
| 地址 | 预期 |
|
||||
|------|------|
|
||||
| `https://leilei.czchunfang.com/` | 寄卖商城 H5 首页(生产) |
|
||||
| `https://leileiadmin.czchunfang.com/api/...` | 寄卖商城 API(生产) |
|
||||
| `http://116.62.83.240:18080/` | H5 直连测试(绕过域名/SSL) |
|
||||
|
||||
---
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
# 重启 webman
|
||||
docker compose --env-file .env restart integral-houtai
|
||||
|
||||
# 看 webman 日志
|
||||
docker compose --env-file .env logs -f integral-houtai
|
||||
|
||||
# 进入 webman 容器
|
||||
docker compose --env-file .env exec integral-houtai bash
|
||||
|
||||
# 仅重建 H5(改了 .env 中的域名参数后)
|
||||
docker compose --env-file .env build integral-h5
|
||||
docker compose --env-file .env up -d integral-h5
|
||||
|
||||
# 停止(保留卷)
|
||||
docker compose --env-file .env down
|
||||
|
||||
# 停止并删除所有卷(慎用:清空上传图片和 runtime)
|
||||
docker compose --env-file .env down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 关键一致性检查
|
||||
|
||||
| 位置 | 值 |
|
||||
|---|---|
|
||||
| `.env` INTEGRAL_API_PUBLIC_URL | `https://leileiadmin.czchunfang.com` |
|
||||
| `.env` INTEGRAL_H5_PUBLIC_URL | `https://leilei.czchunfang.com/` |
|
||||
| `.env` INTEGRAL_APP_STR | `ZFyTNQTWEkCBczKzyUDJWE9Ecx260517` |
|
||||
| `houtai.env` APP_SECRET | **同上** |
|
||||
| `.env` INTEGRAL_SN_ID | `17533260260517` |
|
||||
| `h5/static/configs.js` sn_id | **同上** |
|
||||
95
deploy/docker/step1-integral/docker-compose.yml
Normal file
95
deploy/docker/step1-integral/docker-compose.yml
Normal file
@@ -0,0 +1,95 @@
|
||||
# =============================================================
|
||||
# 步骤一:寄卖商城(integral-resell)独立部署
|
||||
# 客户:池州雷蕾商贸 czleilei240
|
||||
# 包含服务:redis · integral-houtai(webman) · integral-h5(Nginx)
|
||||
# =============================================================
|
||||
|
||||
name: resell-czleilei240
|
||||
|
||||
x-common: &common
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
|
||||
networks:
|
||||
integral-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
integral-redis-data:
|
||||
integral-runtime:
|
||||
|
||||
services:
|
||||
# ---------- Redis ----------
|
||||
redis:
|
||||
<<: *common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: redis.Dockerfile
|
||||
image: resell-czleilei240/redis:local
|
||||
container_name: integral-redis
|
||||
command: ["--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- integral-redis-data:/data
|
||||
networks: [integral-net]
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
# ---------- Webman 后端 ----------
|
||||
integral-houtai:
|
||||
<<: *common
|
||||
build:
|
||||
context: ../../../integral-resell/houtai
|
||||
dockerfile: ../../deploy/docker/integral-resell/houtai.Dockerfile
|
||||
image: resell-czleilei240/houtai:latest
|
||||
container_name: integral-houtai
|
||||
networks: [integral-net]
|
||||
ports:
|
||||
# 宝塔 Nginx 直连 webman API(webman.bin 写死监听 8785)
|
||||
- "${RESELL_API_PORT:-18085}:8785"
|
||||
volumes:
|
||||
# 整个应用目录挂到宿主机 /www/wwwroot/leileiadmin.czchunfang.com/
|
||||
# FTP 上传新 webman.bin / public/ 后 docker compose restart integral-houtai 即可生效
|
||||
- ${RESELL_HOUTAI_DIR}:/app
|
||||
# .env 单独挂入(覆盖宿主机目录里的 .env),避免明文密码出现在 wwwroot
|
||||
- ./houtai.env:/app/.env:ro
|
||||
# runtime 使用命名卷(日志/session/pid 不受 FTP 覆盖影响)
|
||||
- integral-runtime:/app/runtime
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
|
||||
# ---------- H5 静态站 ----------
|
||||
integral-h5:
|
||||
<<: *common
|
||||
build:
|
||||
context: ../../../integral-resell/h5
|
||||
dockerfile: ../../deploy/docker/integral-resell/h5.Dockerfile
|
||||
image: resell-czleilei240/h5:latest
|
||||
container_name: integral-h5
|
||||
networks: [integral-net]
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
INTEGRAL_TITLE: ${INTEGRAL_TITLE}
|
||||
INTEGRAL_API_PUBLIC_URL: ${INTEGRAL_API_PUBLIC_URL}
|
||||
INTEGRAL_IMG_PUBLIC_URL: ${INTEGRAL_IMG_PUBLIC_URL}
|
||||
INTEGRAL_H5_PUBLIC_URL: ${INTEGRAL_H5_PUBLIC_URL}
|
||||
INTEGRAL_SN_ID: ${INTEGRAL_SN_ID}
|
||||
INTEGRAL_APP_STR: ${INTEGRAL_APP_STR}
|
||||
INTEGRAL_CONTRACT_PAGE: ${INTEGRAL_CONTRACT_PAGE}
|
||||
volumes:
|
||||
# H5 静态文件目录挂到宿主机,手动更新 JS/configs.js 直接生效,无需重建镜像
|
||||
# 子目录 crmebimage/ 同时由步骤二 Java 后端写入(PDF/图片),Nginx 直接对外提供访问
|
||||
- ${RESELL_H5_DIR}:/usr/share/nginx/html
|
||||
ports:
|
||||
- "${INTEGRAL_H5_PORT:-18080}:80"
|
||||
depends_on:
|
||||
- integral-houtai
|
||||
37
deploy/docker/step1-integral/houtai.env.example
Normal file
37
deploy/docker/step1-integral/houtai.env.example
Normal file
@@ -0,0 +1,37 @@
|
||||
# =============================================================
|
||||
# Webman 后端运行时配置 — 池州雷蕾商贸 czleilei240(寄卖商城)
|
||||
# cp houtai.env.example houtai.env 并填入真实密码
|
||||
# houtai.env 不入库,由 docker-compose volumes: 挂入 /app/.env
|
||||
# =============================================================
|
||||
|
||||
# MySQL(阿里云 RDS)
|
||||
DB_HOST = 'rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com'
|
||||
DB_PORT = 3306
|
||||
DB_DATABASE = 'yangtangyoupin'
|
||||
DB_USERNAME = 'yangtangyoupin'
|
||||
DB_PASSWORD = 'change-me'
|
||||
|
||||
# Redis(指向同 compose 内的 redis 容器)
|
||||
REDIS_HOST = 'redis'
|
||||
REDIS_PORT = 6379
|
||||
REDIS_PASSWORD = 'change-me-redis'
|
||||
|
||||
# 短信(池州雷蕾商贸专属通道)
|
||||
SMS_CHANNEL = 'alibaba'
|
||||
SMS_SIGNNAME = '池州雷蕾商贸'
|
||||
SMS_TEMPLATE = 'SMS_334320185'
|
||||
SMS_KEYID = 'LTAI5t7CfS15hZGdNLLEMUwG'
|
||||
SMS_KEYSECRET = 'ikfTvHbMMg5sStGgdvLNL8iuVYdner'
|
||||
SMS_SDKAPPID = ''
|
||||
|
||||
# OSS(不启用则走本地 public/upload)
|
||||
FILE_STORAGE = 'public'
|
||||
OSS_ACCESS_ID = ''
|
||||
OSS_ACCESS_SECRET = ''
|
||||
OSS_BUCKET = ''
|
||||
OSS_ENDPOINT = ''
|
||||
OSS_URL = ''
|
||||
|
||||
# 业务标识(须与 H5 configs.js 的 sn_id / appStr 以及积分商城 admin 后台一致)
|
||||
APP_SIGN = '1'
|
||||
APP_SECRET = 'ZFyTNQTWEkCBczKzyUDJWE9Ecx260517'
|
||||
13
deploy/docker/step1-integral/redis.Dockerfile
Normal file
13
deploy/docker/step1-integral/redis.Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
# 使用 Alpine 通过 apk 安装 Redis,绕过镜像源问题
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache redis tzdata \
|
||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& echo "Asia/Shanghai" > /etc/timezone
|
||||
|
||||
VOLUME /data
|
||||
WORKDIR /data
|
||||
EXPOSE 6379
|
||||
|
||||
ENTRYPOINT ["redis-server"]
|
||||
44
deploy/docker/step2-single-shop/.env.example
Normal file
44
deploy/docker/step2-single-shop/.env.example
Normal file
@@ -0,0 +1,44 @@
|
||||
# =============================================================
|
||||
# 步骤二:积分商城环境变量 — 池州雷蕾商贸 czleilei240
|
||||
# 使用方法:cp .env.example .env 然后填入真实密码
|
||||
# .env 不入库(已加入 .gitignore)
|
||||
# =============================================================
|
||||
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# ---------- Redis(容器内,与步骤一独立) ----------
|
||||
REDIS_PASSWORD=change-me-redis
|
||||
|
||||
# ---------- 阿里云 RDS ----------
|
||||
RDS_HOST=rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com
|
||||
RDS_DB=yangtangyoupin
|
||||
RDS_USER=yangtangyoupin
|
||||
RDS_PASSWORD=change-me
|
||||
|
||||
# ---------- 订单同步(多商户 source / target) ----------
|
||||
SYNC_SOURCE_ID=shop_15
|
||||
SYNC_TARGET_MER_ID=15
|
||||
|
||||
# ---------- Java JAR 宿主机路径(FTP 更新后 restart 容器即可) ----------
|
||||
# 对应宿主机原启动命令目录:/www/wwwroot/javaapi/
|
||||
SINGLE_FRONT_JAR=/www/wwwroot/javaapi/miao-front-2.2.jar
|
||||
SINGLE_ADMIN_JAR=/www/wwwroot/javaapi/miao-admin-2.2.jar
|
||||
|
||||
# ---------- Java 日志目录(bind-mount 到宿主机,直接 tail -f 查看) ----------
|
||||
SINGLE_FRONT_LOG_DIR=/www/wwwroot/javaapi/logs/front
|
||||
SINGLE_ADMIN_LOG_DIR=/www/wwwroot/javaapi/logs/admin
|
||||
|
||||
# ---------- 图片/PDF 目录(与步骤一 H5 Nginx 共享宿主机路径) ----------
|
||||
# Java 后端写入 /usr/local/crmeb/crmebimage/ → 宿主机 /www/wwwroot/leilei.czchunfang.com/crmebimage/
|
||||
# 步骤一的 H5 Nginx(leilei.czchunfang.com)提供对外访问
|
||||
CRMEB_IMAGE_DIR=/www/wwwroot/leilei.czchunfang.com/crmebimage
|
||||
|
||||
# ---------- 前端静态目录(bind-mount,rsync 更新后立即生效) ----------
|
||||
# 积分商城 H5(uni-app SPA),对应域名 leilei-jf.czchunfang.com
|
||||
SINGLE_H5_DIR=/www/wwwroot/leilei-jf.czchunfang.com
|
||||
# 积分商城管理后台(Vue SPA),对应域名 leilei-jfadmin.czchunfang.com
|
||||
SINGLE_ADMIN_WEB_DIR=/www/wwwroot/leilei-jfadmin.czchunfang.com
|
||||
|
||||
# ---------- 宿主机暴露端口(供宝塔 Nginx 反代) ----------
|
||||
SINGLE_ADMIN_PORT=18081
|
||||
SINGLE_H5_PORT=18082
|
||||
1
deploy/docker/step2-single-shop/.gitignore
vendored
Normal file
1
deploy/docker/step2-single-shop/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.env
|
||||
176
deploy/docker/step2-single-shop/README.md
Normal file
176
deploy/docker/step2-single-shop/README.md
Normal file
@@ -0,0 +1,176 @@
|
||||
# 步骤二:积分商城 Docker 部署(池州雷蕾商贸 czleilei240)
|
||||
|
||||
项目:`single-shop-22`(积分商城)
|
||||
服务:`redis` · `single-front-api`(Spring Boot)· `single-admin-api`(Spring Boot)
|
||||
`single-admin-web`(Vue 管理后台)· `single-h5`(uni-app H5)
|
||||
|
||||
步骤一(寄卖商城)与本步骤完全独立,可以单独部署、单独重启。
|
||||
|
||||
---
|
||||
|
||||
## 部署前提:宿主机文件准备
|
||||
|
||||
> JAR 和静态文件通过 **bind-mount** 挂入容器,部署前需先把文件放到宿主机对应目录。
|
||||
|
||||
### 1. Java JAR(Spring Boot API)
|
||||
|
||||
```bash
|
||||
# 宿主机目录
|
||||
mkdir -p /www/wwwroot/javaapi/logs/front
|
||||
mkdir -p /www/wwwroot/javaapi/logs/admin
|
||||
|
||||
# 将本地编译好的 JAR 传到服务器(macOS 本地执行)
|
||||
scp single-shop-22/backend/crmeb-front/target/miao-front-2.2.jar root@116.62.83.240:/www/wwwroot/javaapi/
|
||||
scp single-shop-22/backend/crmeb-admin/target/miao-admin-2.2.jar root@116.62.83.240:/www/wwwroot/javaapi/
|
||||
```
|
||||
|
||||
> 更新 JAR:FTP 替换宿主机文件 → `docker compose --env-file .env restart single-front-api`
|
||||
|
||||
### 2. 前端静态文件
|
||||
|
||||
```bash
|
||||
# H5(uni-app)
|
||||
mkdir -p /www/wwwroot/leilei-jf.czchunfang.com
|
||||
rsync -a --delete single-shop-22/single_uniapp22miao/unpackage/dist/build/h5/ \
|
||||
root@116.62.83.240:/www/wwwroot/leilei-jf.czchunfang.com/
|
||||
chmod -R 755 /www/wwwroot/leilei-jf.czchunfang.com/
|
||||
|
||||
# 管理后台(Vue)
|
||||
mkdir -p /www/wwwroot/leilei-jfadmin.czchunfang.com
|
||||
rsync -a --delete single-shop-22/backend-adminend/dist/ \
|
||||
root@116.62.83.240:/www/wwwroot/leilei-jfadmin.czchunfang.com/
|
||||
chmod -R 755 /www/wwwroot/leilei-jfadmin.czchunfang.com/
|
||||
```
|
||||
|
||||
> 更新前端:rsync 同步到宿主机 → 浏览器强刷即可,无需重建镜像或重启容器
|
||||
|
||||
### 3. 图片/PDF 目录
|
||||
|
||||
```bash
|
||||
# 与步骤一 H5 Nginx 共享,步骤一已创建则无需重建
|
||||
mkdir -p /www/wwwroot/leilei.czchunfang.com/crmebimage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 快速部署
|
||||
|
||||
```bash
|
||||
cd deploy/docker/step2-single-shop
|
||||
|
||||
# 1. 准备环境变量
|
||||
cp .env.example .env
|
||||
vim .env # 填入 RDS_PASSWORD、REDIS_PASSWORD
|
||||
|
||||
# 2. 构建镜像(仅 JRE + Nginx,无 Maven/Node,约 2-5 分钟)
|
||||
docker compose --env-file .env build
|
||||
|
||||
# 3. 启动所有服务
|
||||
docker compose --env-file .env up -d
|
||||
|
||||
# 4. 查看状态
|
||||
docker compose --env-file .env ps
|
||||
docker compose --env-file .env logs -f single-front-api
|
||||
docker compose --env-file .env logs -f single-admin-api
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 域名与端口
|
||||
|
||||
| 域名 | 用途 | 宿主机端口 |
|
||||
|---|---|---|
|
||||
| `leilei-jf.czchunfang.com` | 积分商城 H5(uni-app) | **18082** |
|
||||
| `leilei-jfadmin.czchunfang.com` | 积分商城管理后台(Vue) | **18081** |
|
||||
|
||||
> Spring Boot API 端口(30032 / 30033)仅容器内监听,不对外暴露。
|
||||
> 宝塔 Nginx 通过域名反代到 `127.0.0.1:18081 / 18082`,再由容器内 Nginx 转发到 API。
|
||||
|
||||
---
|
||||
|
||||
## 宝塔 Nginx 配置
|
||||
|
||||
将以下两个文件内容分别粘贴到宝塔面板对应站点的「配置文件」中:
|
||||
|
||||
| 配置文件 | 说明 |
|
||||
|---|---|
|
||||
| `deploy/docker/nginx/leilei-jf.czchunfang.com.conf` | H5 站点,upstream → 127.0.0.1:18082 |
|
||||
| `deploy/docker/nginx/leilei-jfadmin.czchunfang.com.conf` | 管理后台,upstream → 127.0.0.1:18081 |
|
||||
|
||||
证书路径(文件已在项目中):
|
||||
|
||||
```
|
||||
deploy/docker/ssl-cert/
|
||||
leilei-jf.czchunfang.com_cert/nginx/leilei-jf.czchunfang.com.{pem,key}
|
||||
leilei-jfadmin.czchunfang.com_cert/nginx/leilei-jfadmin.czchunfang.com.{pem,key}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 验证
|
||||
|
||||
| 地址 | 预期 |
|
||||
|------|------|
|
||||
| `https://leilei-jf.czchunfang.com/` | 积分商城 H5(生产) |
|
||||
| `https://leilei-jfadmin.czchunfang.com/` | 积分商城管理后台(生产) |
|
||||
| `http://116.62.83.240:18082/` | H5 直连测试(绕过域名/SSL) |
|
||||
| `http://116.62.83.240:18081/` | 管理后台直连测试 |
|
||||
|
||||
---
|
||||
|
||||
## 常用运维命令
|
||||
|
||||
```bash
|
||||
# 重启 Java API(更新 JAR 后)
|
||||
docker compose --env-file .env restart single-front-api
|
||||
docker compose --env-file .env restart single-admin-api
|
||||
|
||||
# 实时日志(宿主机路径 /www/wwwroot/javaapi/logs/ 也可直接查看)
|
||||
docker compose --env-file .env logs -f single-admin-api
|
||||
docker compose --env-file .env logs -f single-front-api
|
||||
|
||||
# 进入容器
|
||||
docker compose --env-file .env exec single-admin-api bash
|
||||
|
||||
# 停止(保留卷)
|
||||
docker compose --env-file .env down
|
||||
|
||||
# 停止并删除 Redis 数据卷(慎用)
|
||||
docker compose --env-file .env down -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## bind-mount 目录总览
|
||||
|
||||
| 宿主机路径 | 挂入容器路径 | 说明 |
|
||||
|---|---|---|
|
||||
| `/www/wwwroot/javaapi/miao-front-2.2.jar` | `/app/app.jar` (front-api) | 用户端 API JAR(只读) |
|
||||
| `/www/wwwroot/javaapi/miao-admin-2.2.jar` | `/app/app.jar` (admin-api) | 管理端 API JAR(只读) |
|
||||
| `/www/wwwroot/javaapi/logs/front/` | `/app/log` (front-api) | 用户端 API 日志 |
|
||||
| `/www/wwwroot/javaapi/logs/admin/` | `/app/log` (admin-api) | 管理端 API 日志 |
|
||||
| `/www/wwwroot/leilei.czchunfang.com/crmebimage/` | `/usr/local/crmeb/crmebimage/` (两个 API) | 图片/PDF 写入目录 |
|
||||
| `/www/wwwroot/leilei-jf.czchunfang.com/` | `/usr/share/nginx/html` (h5) | H5 静态文件 |
|
||||
| `/www/wwwroot/leilei-jfadmin.czchunfang.com/` | `/usr/share/nginx/html` (admin-web) | 管理后台静态文件 |
|
||||
| `../single-shop/application-docker.yml` | `/config/application-docker.yml` (两个 API) | Spring Boot 配置(只读) |
|
||||
|
||||
---
|
||||
|
||||
## czleilei240 关键配置对照
|
||||
|
||||
| 配置项 | 值 |
|
||||
|---|---|
|
||||
| RDS Host | `rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com` |
|
||||
| DB / User | `yangtangyoupin` |
|
||||
| crmeb.imagePath(容器内) | `/usr/local/crmeb/crmebimage/` |
|
||||
| SYNC_SOURCE_ID | `shop_15` |
|
||||
| SYNC_TARGET_MER_ID | `15` |
|
||||
| Spring profile | `docker`(application-docker.yml 通过 env 注入) |
|
||||
|
||||
---
|
||||
|
||||
## 备注
|
||||
|
||||
- JVM 参数已包含 Java 17 + Spring Boot 2.2.6 所需的 `--add-opens` 标志(见 Dockerfile)。
|
||||
- 图片/PDF 目录 `/www/wwwroot/leilei.czchunfang.com/crmebimage/` 同时挂入 `front-api` 和 `admin-api` 两个容器,确保文件共享。
|
||||
- Redis 实例(`single-redis`)与步骤一(`integral-redis`)完全独立,数据互不干扰。
|
||||
190
deploy/docker/step2-single-shop/docker-compose.yml
Normal file
190
deploy/docker/step2-single-shop/docker-compose.yml
Normal file
@@ -0,0 +1,190 @@
|
||||
# =============================================================
|
||||
# 步骤二:积分商城(single-shop-22)独立部署
|
||||
# 客户:池州雷蕾商贸 czleilei240
|
||||
# 包含服务:redis · single-admin-api · single-front-api
|
||||
# single-admin-web(Vue) · single-h5(uni-app)
|
||||
#
|
||||
# 优化要点(参考 step1 寄卖商城经验):
|
||||
# 1. Redis:本地 Alpine+apk 构建,不从 DockerHub 拉取镜像
|
||||
# 2. Java API:不含 Maven 编译,JAR bind-mount 自宿主机(快速部署/更新)
|
||||
# 3. 前端:Nginx only 镜像,静态文件 bind-mount(更新无需重建镜像)
|
||||
# 4. 日志:bind-mount 到宿主机,无需进容器查看
|
||||
# =============================================================
|
||||
|
||||
name: jifenmall-czleilei240
|
||||
|
||||
x-common: &common
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "20m"
|
||||
max-file: "5"
|
||||
|
||||
x-spring-common: &spring-common
|
||||
<<: *common
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
MYSQL_HOST: ${RDS_HOST}
|
||||
MYSQL_DATABASE: ${RDS_DB}
|
||||
MYSQL_USERNAME: ${RDS_USER}
|
||||
MYSQL_PASSWORD: ${RDS_PASSWORD}
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
SYNC_SOURCE_ID: ${SYNC_SOURCE_ID:-shop_15}
|
||||
SYNC_TARGET_MER_ID: ${SYNC_TARGET_MER_ID:-15}
|
||||
|
||||
networks:
|
||||
single-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
single-redis-data:
|
||||
|
||||
services:
|
||||
# ---------- Redis(Alpine 本地构建,无需拉取 Docker Hub 镜像) ----------
|
||||
redis:
|
||||
<<: *common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: redis.Dockerfile
|
||||
image: jifenmall-czleilei240/redis:local
|
||||
container_name: single-redis
|
||||
command: ["--requirepass", "${REDIS_PASSWORD}", "--appendonly", "yes"]
|
||||
volumes:
|
||||
- single-redis-data:/data
|
||||
networks: [single-net]
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
|
||||
interval: 10s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
|
||||
# ---------- Front API(用户端 Spring Boot) ----------
|
||||
# JAR 文件 bind-mount 自 ${SINGLE_FRONT_JAR}(宿主机 /www/wwwroot/javaapi/miao-front-2.2.jar)
|
||||
# 更新 JAR:FTP 替换宿主机文件 → docker compose restart single-front-api
|
||||
single-front-api:
|
||||
<<: *spring-common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ../single-shop/front-api.Dockerfile
|
||||
image: jifenmall-czleilei240/front-api:local
|
||||
container_name: single-front-api
|
||||
networks: [single-net]
|
||||
ports:
|
||||
- "127.0.0.1:30033:30033"
|
||||
volumes:
|
||||
# JAR bind-mount:FTP 更新 JAR 后 restart 容器即可
|
||||
- ${SINGLE_FRONT_JAR}:/app/app.jar:ro
|
||||
# 图片/PDF 目录:与 step1 H5 Nginx 共享宿主机路径
|
||||
- ${CRMEB_IMAGE_DIR}:/usr/local/crmeb/crmebimage
|
||||
# 日志:bind-mount 到宿主机,便于直接查看
|
||||
- ${SINGLE_FRONT_LOG_DIR}:/app/log
|
||||
# Spring 配置:只读挂入
|
||||
- ../single-shop/application-docker.yml:/config/application-docker.yml:ro
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
MYSQL_HOST: ${RDS_HOST}
|
||||
MYSQL_DATABASE: ${RDS_DB}
|
||||
MYSQL_USERNAME: ${RDS_USER}
|
||||
MYSQL_PASSWORD: ${RDS_PASSWORD}
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
SYNC_SOURCE_ID: ${SYNC_SOURCE_ID:-shop_15}
|
||||
SYNC_TARGET_MER_ID: ${SYNC_TARGET_MER_ID:-15}
|
||||
SERVER_PORT: 30033
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:30033/actuator/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 90s
|
||||
|
||||
# ---------- Admin API(管理端 Spring Boot) ----------
|
||||
# JAR 文件 bind-mount 自 ${SINGLE_ADMIN_JAR}(宿主机 /www/wwwroot/javaapi/miao-admin-2.2.jar)
|
||||
# 更新 JAR:FTP 替换宿主机文件 → docker compose restart single-admin-api
|
||||
single-admin-api:
|
||||
<<: *spring-common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ../single-shop/admin-api.Dockerfile
|
||||
image: jifenmall-czleilei240/admin-api:local
|
||||
container_name: single-admin-api
|
||||
networks: [single-net]
|
||||
ports:
|
||||
- "127.0.0.1:30032:30032"
|
||||
volumes:
|
||||
# JAR bind-mount
|
||||
- ${SINGLE_ADMIN_JAR}:/app/app.jar:ro
|
||||
# 图片/PDF 目录
|
||||
- ${CRMEB_IMAGE_DIR}:/usr/local/crmeb/crmebimage
|
||||
# 日志 bind-mount
|
||||
- ${SINGLE_ADMIN_LOG_DIR}:/app/log
|
||||
# Spring 配置
|
||||
- ../single-shop/application-docker.yml:/config/application-docker.yml:ro
|
||||
environment:
|
||||
TZ: ${TZ:-Asia/Shanghai}
|
||||
MYSQL_HOST: ${RDS_HOST}
|
||||
MYSQL_DATABASE: ${RDS_DB}
|
||||
MYSQL_USERNAME: ${RDS_USER}
|
||||
MYSQL_PASSWORD: ${RDS_PASSWORD}
|
||||
REDIS_HOST: redis
|
||||
REDIS_PORT: 6379
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
SYNC_SOURCE_ID: ${SYNC_SOURCE_ID:-shop_15}
|
||||
SYNC_TARGET_MER_ID: ${SYNC_TARGET_MER_ID:-15}
|
||||
SERVER_PORT: 30032
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -sf http://localhost:30032/actuator/health || exit 1"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
start_period: 90s
|
||||
|
||||
# ---------- Admin Web(Vue 管理后台,Nginx only) ----------
|
||||
# 静态文件 bind-mount 自 ${SINGLE_ADMIN_WEB_DIR}(宿主机 /www/wwwroot/leilei-jfadmin.czchunfang.com/)
|
||||
# 更新前端:rsync 新 dist/ 到宿主机目录 → 浏览器硬刷新即可(无需重启容器)
|
||||
single-admin-web:
|
||||
<<: *common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ../single-shop/admin-web.Dockerfile
|
||||
image: jifenmall-czleilei240/admin-web:local
|
||||
container_name: single-admin-web
|
||||
networks: [single-net]
|
||||
ports:
|
||||
- "${SINGLE_ADMIN_PORT:-18081}:80"
|
||||
volumes:
|
||||
# 静态文件 bind-mount:rsync 更新宿主机目录后立即生效
|
||||
- ${SINGLE_ADMIN_WEB_DIR}:/usr/share/nginx/html
|
||||
depends_on:
|
||||
- single-admin-api
|
||||
|
||||
# ---------- H5 前端(uni-app SPA,Nginx only) ----------
|
||||
# 静态文件 bind-mount 自 ${SINGLE_H5_DIR}(宿主机 /www/wwwroot/leilei-jf.czchunfang.com/)
|
||||
# 更新前端:rsync 新 unpackage/dist/build/h5/ 到宿主机目录 → 无需重启容器
|
||||
single-h5:
|
||||
<<: *common
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ../single-shop/h5.Dockerfile
|
||||
image: jifenmall-czleilei240/h5:local
|
||||
container_name: single-h5
|
||||
networks: [single-net]
|
||||
ports:
|
||||
- "${SINGLE_H5_PORT:-18082}:80"
|
||||
volumes:
|
||||
# 静态文件 bind-mount
|
||||
- ${SINGLE_H5_DIR}:/usr/share/nginx/html
|
||||
depends_on:
|
||||
- single-front-api
|
||||
18
deploy/docker/step2-single-shop/redis.Dockerfile
Normal file
18
deploy/docker/step2-single-shop/redis.Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
||||
# =============================================================
|
||||
# Redis(Alpine + apk 安装,绕过 Docker Hub 镜像拉取问题)
|
||||
# 与 step1 方案一致:不依赖 docker.io,只需 registry-1.docker.io 拉 alpine:3.19
|
||||
# =============================================================
|
||||
|
||||
FROM alpine:3.19
|
||||
|
||||
RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \
|
||||
&& apk add --no-cache redis tzdata \
|
||||
&& cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime \
|
||||
&& echo "Asia/Shanghai" > /etc/timezone \
|
||||
&& rm -f /etc/apk/cache/*.apk
|
||||
|
||||
VOLUME /data
|
||||
WORKDIR /data
|
||||
EXPOSE 6379
|
||||
|
||||
ENTRYPOINT ["redis-server"]
|
||||
BIN
deploy/ssl/leilei-jf.czchunfang.com_cert.zip
Normal file
BIN
deploy/ssl/leilei-jf.czchunfang.com_cert.zip
Normal file
Binary file not shown.
BIN
deploy/ssl/leilei-jfadmin.czchunfang.com_cert.zip
Normal file
BIN
deploy/ssl/leilei-jfadmin.czchunfang.com_cert.zip
Normal file
Binary file not shown.
BIN
deploy/ssl/leilei.czchunfang.com_cert.zip
Normal file
BIN
deploy/ssl/leilei.czchunfang.com_cert.zip
Normal file
Binary file not shown.
BIN
deploy/ssl/leileiadmin.czchunfang.com_cert.zip
Normal file
BIN
deploy/ssl/leileiadmin.czchunfang.com_cert.zip
Normal file
Binary file not shown.
51
docs/com-czleilei240-data-imgration.md
Normal file
51
docs/com-czleilei240-data-imgration.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 公司名称:池州雷蕾商贸
|
||||
|
||||
## mysql数据库配置信息
|
||||
|
||||
host ip: 116.62.83.240
|
||||
datasource:
|
||||
rds: rm-bp1a178eq62lxba9xbo.mysql.rds.aliyuncs.com
|
||||
name: yangtangyoupin
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
|
||||
## 数据清理任务
|
||||
|
||||
- **数据范围**:用户 id 集(`wa_users`)
|
||||
`93119,93134,93156,93173,93187,93188,93198,93190,93214,93220,93212,93221,93226,93235,93245,93240,93132,93183,93172,93197,93195,93193,93209,93215,93224,93239,93230,93133,93163,93191,93199`
|
||||
|
||||
- 保留wa_users表中id在用户id数据范围的 ,删除其余用户数据
|
||||
- 保留eb_user表中uid在用户id数据范围的 ,删除其余用户数据
|
||||
|
||||
- wa_order
|
||||
清空wa_order表中数据
|
||||
|
||||
- wa_merchandise
|
||||
从源数据dump文件中提取“created_at >= 2026-05-14”并且seller_id或buyer_id在用户id数据范围的寄售商品,删除其余数据
|
||||
(当前库表字段为 `user_id` 表示卖家,实现时按 `user_id` 与日期条件过滤。)
|
||||
|
||||
- wa_selfbonus_log
|
||||
只保留 `user_id` 在用户id数据范围内的记录,删除其余数据
|
||||
|
||||
- wa_sharebonus_log
|
||||
只保留 `user_id` 在用户id数据范围内的记录,删除其余数据
|
||||
|
||||
- wa_coupon_log
|
||||
只保留 `user_id` 在用户id数据范围内的记录,删除其余数据
|
||||
|
||||
- wa_withdraw
|
||||
清空wa_withdraw表中数据
|
||||
|
||||
- eb_store_order
|
||||
清空eb_store_order表中数据
|
||||
|
||||
- eb_user_integral_record
|
||||
只保留用户在名单内的记录;表字段为 `uid`(与 `wa_users.id` / `eb_user.uid` 对应),实现按 `uid` 过滤。
|
||||
|
||||
## 执行脚本
|
||||
|
||||
|
||||
## 相关文件
|
||||
|
||||
- 新公司初始会员信息: 'db/czleilei240/会员信息20260516.xlsx'
|
||||
|
||||
50
docs/com-czleilei240.md
Normal file
50
docs/com-czleilei240.md
Normal file
@@ -0,0 +1,50 @@
|
||||
## 公司名称: 池州雷蕾商贸, host ip: 116.62.83.240
|
||||
|
||||
---
|
||||
|
||||
### backend/crmeb-front模块变更
|
||||
|
||||
- 1. profile: czleilei240
|
||||
- 2. profile file: application-czleilei240.yml, mysql和redis主机ip修改
|
||||
- 3. **PDF合同模板文件路径**:pdf/sign_contract_czleilei240.pdf
|
||||
- 4. 用户PDF合同url地址前缀/落库域名:https://leilei.czchunfang.com/
|
||||
- 5. imagePath: /www/wwwroot/leilei.czchunfang.com/
|
||||
|
||||
### uniapp前端配置变更
|
||||
|
||||
- 1. 积分商城domain:https://leilei-jf.czchunfang.com
|
||||
- 2. 抢购页面跳转地址:https://leilei.czchunfang.com
|
||||
- 3. **PDF合同预览文件路径**: /static/sign_contract_czleilei240.pdf
|
||||
|
||||
|
||||
---
|
||||
|
||||
### backend/crmeb-admin模块变更
|
||||
|
||||
- 1. profile: czleilei240
|
||||
- 2. profile file: application-czleilei240.yml, mysql和redis主机ip修改,sync: source-id: shop_15, target-mer-id: 15
|
||||
|
||||
### 积分商城后台backend-adminend配置变更
|
||||
|
||||
- 1. backend-adminend/.env.development文件中VUE_APP_BASE_API改为https://leilei-jf.czchunfang.com
|
||||
- 2. backend-adminend/.env.production文件中VUE_APP_BASE_API改为https://leilei-jf.czchunfang.com
|
||||
|
||||
---
|
||||
|
||||
|
||||
### **修改任务**
|
||||
|
||||
- **已完成**新建分支czleilei240,合并sxsy80分支的最新代码到该分支,并根据上述信息修改相关需要变更项,使符合该新公司项目环境
|
||||
|
||||
|
||||
## 相关文件
|
||||
|
||||
、、、启动积分商城api服务
|
||||
cd /www/wwwroot/javaapi
|
||||
nohup java -Xms128m -Xmx256m -jar miao-front-2.2.jar > front.log & tail -f front.log
|
||||
、、、
|
||||
|
||||
、、、启动积分商城后台api服务
|
||||
cd /www/wwwroot/javaapi
|
||||
nohup java -Xms128m -Xmx256m -jar miao-admin-2.2.jar > admin.log & tail -f admin.log
|
||||
、、、
|
||||
51
docs/com-czrt6-data-imgration-0511.md
Normal file
51
docs/com-czrt6-data-imgration-0511.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# 公司名称:池州瑞棠商贸
|
||||
|
||||
## mysql数据库配置信息
|
||||
|
||||
host ip: 101.37.101.6
|
||||
datasource:
|
||||
name: yangtangyoupin
|
||||
username: yangtangyoupin
|
||||
password: 5Fn8eWrbYFtAhCZw
|
||||
|
||||
## 数据清理任务
|
||||
|
||||
- **数据范围**:用户 id 集(`eb_user.uid` 与 `wa_users.id` 一致):92738,92827,92861,92909,93090,93140,93150,93157,93175,93194,93201,93210,93211,93212,93219,93220
|
||||
|
||||
- wa_users表中id在用户id数据范围的
|
||||
- eb_user表中uid在用户id数据范围的
|
||||
|
||||
|
||||
- wa_order
|
||||
|
||||
|
||||
- wa_merchandise
|
||||
|
||||
|
||||
- wa_selfbonus_log
|
||||
|
||||
|
||||
- wa_sharebonus_log
|
||||
|
||||
|
||||
- wa_coupon_log
|
||||
|
||||
|
||||
- wa_withdraw
|
||||
|
||||
|
||||
- eb_store_order
|
||||
|
||||
|
||||
- eb_user_integral_record
|
||||
|
||||
|
||||
|
||||
## 执行脚本
|
||||
|
||||
|
||||
|
||||
## 相关文件
|
||||
|
||||
- 源数据dump文件
|
||||
|
||||
553
docs/dashboard/boss-dashboard-development-guide.md
Normal file
553
docs/dashboard/boss-dashboard-development-guide.md
Normal file
@@ -0,0 +1,553 @@
|
||||
# 老板驾驶舱 BI 开发说明
|
||||
|
||||
基于 `.cursor/plans/老板驾驶舱bi_1ae0092c.plan.md` 编写。
|
||||
|
||||
## 目标
|
||||
|
||||
建设一个面向公司老板的 H5 移动端商城运营驾驶舱,默认展示**昨日完整经营复盘**,并在每日 **10:15**、**14:55** 两个抢购/寄卖节点后展示**今日节点快报**。
|
||||
|
||||
本项目应按“先前端展示确认,再后端数据实现”的顺序推进:
|
||||
|
||||
1. 前端先做可点击、可切换、带 Mock 数据的 H5 高保真页面。
|
||||
2. 给老板用手机确认信息层级、指标命名、视觉重点和下钻路径。
|
||||
3. 确认后再做后端接口、数据聚合、快照任务和真实联调。
|
||||
|
||||
## 开发原则
|
||||
|
||||
- 不先陷入复杂 BI 汇总表设计,第一阶段以前端展示和老板确认口径为核心。
|
||||
- 默认大盘展示“昨日”,避免当天数据未结束导致经营判断失真。
|
||||
- 今日数据只作为 10:15、14:55 两个节点快报,不作为完整日报。
|
||||
- 所有核心指标都要能追溯到现有明细页面或数据库表。
|
||||
- 前后端接口契约先稳定,后端可分阶段从 Mock、实时 SQL、快照表演进。
|
||||
- 前端第一版按 H5 移动端优先设计,不按 PC 后台页面铺开。
|
||||
|
||||
## 阶段划分
|
||||
|
||||
### 阶段一:前端 Mock 驾驶舱
|
||||
|
||||
目标:先把老板能看的页面做出来,用假数据确认展示结构。
|
||||
|
||||
交付内容:
|
||||
|
||||
- 新增“经营驾驶舱”菜单。
|
||||
- 新增老板驾驶舱页面。
|
||||
- 页面使用本地 Mock 数据,不依赖后端接口。
|
||||
- 支持切换:昨日经营复盘、今日 10:15 快报、今日 14:55 快报、今日实时临时查看。
|
||||
- KPI 卡、趋势图、排行、风险预警列表、下钻按钮均先展示出来。
|
||||
- 使用手机端单列信息流、底部 Tab、顶部吸顶筛选和弹层选择器。
|
||||
|
||||
验收方式:
|
||||
|
||||
- 让老板用手机看实际 H5 页面或手机尺寸截图。
|
||||
- 确认是否一眼能看懂经营情况。
|
||||
- 确认哪些指标要放大、哪些指标可以下移或删除。
|
||||
- 确认“昨日复盘 + 今日两次快报”的业务口径是否符合管理习惯。
|
||||
- 确认手机首屏能否快速判断昨日经营是否正常。
|
||||
|
||||
### 阶段二:后端实时聚合接口
|
||||
|
||||
目标:在老板确认前端结构后,用真实数据替换 Mock。
|
||||
|
||||
交付内容:
|
||||
|
||||
- 新增老板驾驶舱接口。
|
||||
- 先用实时 SQL 聚合昨日数据。
|
||||
- 今日 10:15、14:55 快报先可按接口实时查询生成,不急于落快照表。
|
||||
- 返回数据结构与前端 Mock 保持一致,减少前端返工。
|
||||
|
||||
验收方式:
|
||||
|
||||
- 昨日订单、用户、奖金、寄售数据与现有页面/数据库明细核对一致。
|
||||
- 今日节点快报能在 10:15、14:55 后反映节点后的数据。
|
||||
- 接口响应在常规数据量下控制在 3 秒内。
|
||||
|
||||
### 阶段三:快照表与定时任务
|
||||
|
||||
目标:让日报和今日快报可追溯、可复盘、可审计。
|
||||
|
||||
交付内容:
|
||||
|
||||
- 新增日报汇总表。
|
||||
- 新增今日快报快照表。
|
||||
- 新增定时任务:凌晨生成昨日完整日报,10:15 生成今日第一版快报,14:55 生成今日第二版快报。
|
||||
- 快报生成失败时保留失败状态和错误信息。
|
||||
|
||||
验收方式:
|
||||
|
||||
- 可查看历史任意日期日报。
|
||||
- 可查看某天 10:15 / 14:55 两个快报版本。
|
||||
- 快报不会被手动刷新覆盖。
|
||||
|
||||
## 前端开发说明
|
||||
|
||||
### 文件规划
|
||||
|
||||
如果采用独立 Dashboard 前端项目,建议新增:
|
||||
|
||||
- `src/app/router/routes.tsx`
|
||||
- `src/app/layouts/MobileLayout.tsx`
|
||||
- `src/features/boss-dashboard/pages/BossDashboardPage.tsx`
|
||||
- `src/features/boss-dashboard/components/KpiOverview.tsx`
|
||||
- `src/features/boss-dashboard/components/TodaySnapshotSection.tsx`
|
||||
- `src/features/boss-dashboard/components/TrendSection.tsx`
|
||||
- `src/features/boss-dashboard/components/RankSection.tsx`
|
||||
- `src/features/boss-dashboard/components/RiskAlertSection.tsx`
|
||||
- `src/features/boss-dashboard/mock.ts`
|
||||
|
||||
可复用:
|
||||
|
||||
- `src/components/kpi/KpiCard.tsx`
|
||||
- `src/components/charts/BaseChart.tsx`
|
||||
- `src/components/feedback/RiskLevelTag.tsx`
|
||||
- `src/services/api/dashboard.ts`
|
||||
- `src/services/mock/handlers.ts`
|
||||
|
||||
### 菜单与路由
|
||||
|
||||
H5 第一版不使用 PC 侧边栏菜单,采用底部 TabBar。
|
||||
|
||||
底部 Tab:
|
||||
|
||||
- 首页
|
||||
- 日报
|
||||
- 快报
|
||||
- 风险
|
||||
- 我的
|
||||
|
||||
首页内保留快捷入口:
|
||||
|
||||
- 用户排行
|
||||
- 团队排行
|
||||
- 商品排行
|
||||
- 资金池
|
||||
- 下钻明细
|
||||
|
||||
建议路由:
|
||||
|
||||
```text
|
||||
/h5/dashboard/boss
|
||||
/h5/dashboard/daily-report
|
||||
/h5/dashboard/today-snapshot
|
||||
/h5/dashboard/risk-center
|
||||
/h5/dashboard/drill/user
|
||||
/h5/dashboard/drill/order
|
||||
/h5/dashboard/drill/product
|
||||
/h5/dashboard/drill/team
|
||||
```
|
||||
|
||||
### 页面结构
|
||||
|
||||
页面自上而下:
|
||||
|
||||
1. 顶部吸顶标题和日期口径
|
||||
2. 昨日经营 KPI 卡片
|
||||
3. 今日节点快报卡片
|
||||
4. 近 7 天趋势图
|
||||
5. 用户 / 团队 / 商品排行
|
||||
6. 风险预警列表
|
||||
7. 底部 TabBar 和下钻入口
|
||||
|
||||
H5 布局要求:
|
||||
|
||||
- 页面单列展示,不做 PC 两列大盘。
|
||||
- 首屏显示核心 4 个 KPI 和今日快报状态。
|
||||
- 其他 KPI、资金池、排行和风险通过继续下滑查看。
|
||||
- KPI 使用双列小卡,重点金额卡可跨两列。
|
||||
- 图表单列展示,高度控制在 220px 到 280px。
|
||||
- 排行榜首页只展示 Top 5,点击查看全部。
|
||||
|
||||
### 顶部筛选区
|
||||
|
||||
字段:
|
||||
|
||||
- 经营日期:默认昨日。
|
||||
- 时间范围:昨日、近 7 天、近 30 天、自定义。
|
||||
- 今日快报:10:15 快报、14:55 快报、实时临时查看。
|
||||
- 公司/环境:第一版可固定当前公司,后续支持多公司。
|
||||
|
||||
展示规则:
|
||||
|
||||
- 页面默认打开时展示昨日经营复盘。
|
||||
- 如果选择今日 10:15 快报,但当前时间未到 10:15,显示“待生成”。
|
||||
- 如果选择今日 14:55 快报,但当前时间未到 14:55,显示“待生成”。
|
||||
- 实时临时查看必须有明显标识:`临时数据,不作为日报结论`。
|
||||
- 日期、范围和快报版本筛选使用弹层或分段控件,不使用 PC 横向表单。
|
||||
|
||||
### KPI 卡片
|
||||
|
||||
第一行建议 8 张卡:
|
||||
|
||||
- 昨日成交金额
|
||||
- 昨日订单数
|
||||
- 昨日采购用户数
|
||||
- 昨日新增注册用户
|
||||
- 昨日新增寄售商品
|
||||
- 昨日个人奖金发放
|
||||
- 昨日推广奖金发放
|
||||
- 昨日待支付 / 待结算金额
|
||||
|
||||
第二行资金池卡片:
|
||||
|
||||
- 余额总额
|
||||
- 优惠券总额
|
||||
- 个人奖金总额
|
||||
- 推广奖金总额
|
||||
- 积分总额
|
||||
- 待审核提现金额
|
||||
|
||||
卡片要求:
|
||||
|
||||
- 显示主数值、同比/环比变化、状态颜色。
|
||||
- 支持点击下钻。
|
||||
- 对金额使用千分位与两/三位小数,和业务页面保持一致。
|
||||
- 手机端点击热区不小于 44px。
|
||||
- 长金额不得溢出卡片,可缩小字号或换行。
|
||||
|
||||
### 今日快报模块
|
||||
|
||||
今日快报单独放在 KPI 下方,避免和昨日完整日报混淆。
|
||||
|
||||
内容:
|
||||
|
||||
- 当前快报版本:10:15 / 14:55 / 实时临时。
|
||||
- 生成时间。
|
||||
- 累计采购用户数。
|
||||
- 累计订单数。
|
||||
- 累计成交金额。
|
||||
- 累计支付金额。
|
||||
- 新增寄售商品数。
|
||||
- 个人奖金变化。
|
||||
- 推广奖金变化。
|
||||
- 团队 Top 10。
|
||||
|
||||
状态:
|
||||
|
||||
- `待生成`
|
||||
- `已生成`
|
||||
- `生成失败`
|
||||
- `临时数据`
|
||||
|
||||
### 趋势图
|
||||
|
||||
至少 4 张:
|
||||
|
||||
- 近 7 天成交金额走势
|
||||
- 近 7 天订单数走势
|
||||
- 近 7 天新增用户走势
|
||||
- 近 7 天奖金发放走势
|
||||
|
||||
第一版可以合并成两个图:
|
||||
|
||||
- 交易趋势:成交金额 + 订单数。
|
||||
- 用户与奖金趋势:新增用户 + 奖金发放。
|
||||
|
||||
### 排行榜
|
||||
|
||||
建议三个排行:
|
||||
|
||||
- 高价值用户 Top 20:按个人奖金 + 推广奖金 + 积分折算排序。
|
||||
- 团队贡献 Top 10:按成交金额、成员数、奖金贡献排序。
|
||||
- 高货值未成交商品 Top 20:按寄售价格排序。
|
||||
|
||||
### 风险预警
|
||||
|
||||
风险列表字段:
|
||||
|
||||
- 等级:红 / 黄 / 灰。
|
||||
- 类型:资金、订单、商品、用户、数据质量。
|
||||
- 标题。
|
||||
- 描述。
|
||||
- 关联对象:用户、订单、商品。
|
||||
- 操作:查看明细。
|
||||
|
||||
第一版预警规则:
|
||||
|
||||
- 积分与个人奖金比例异常。
|
||||
- 单用户奖金余额过高。
|
||||
- 大额待审核提现。
|
||||
- 大额未支付订单。
|
||||
- 高价商品长时间未成交。
|
||||
- `wa_users` 与 `eb_user` 用户缺失或手机号不一致。
|
||||
|
||||
### 下钻设计
|
||||
|
||||
点击 KPI 或风险项时跳转现有页面:
|
||||
|
||||
- 用户相关:`/user/list` 或 `/integral-external/user`
|
||||
- 用户积分明细:`/integral-external/user/integral-detail?uid=xxx`
|
||||
- 寄售商品:`/consignment/merchandise`
|
||||
- 寄卖订单:`/consignment/order` 或现有订单管理页面
|
||||
- 提现:`/consignment/withdraw`
|
||||
- 财务日志:`/consignment/financial-log`
|
||||
|
||||
如现有路由不存在对应路径,第一版先保留按钮并提示“待接入明细页”。
|
||||
|
||||
### Mock 数据要求
|
||||
|
||||
前端第一阶段必须使用 Mock 数据覆盖所有展示状态:
|
||||
|
||||
- 昨日经营正常。
|
||||
- 今日 10:15 已生成。
|
||||
- 今日 14:55 待生成。
|
||||
- 今日快报生成失败。
|
||||
- 风险列表为空。
|
||||
- 风险列表有红黄灰多级预警。
|
||||
|
||||
Mock 数据结构应和后端接口设计一致,后续只替换 API 数据源。
|
||||
|
||||
## 后端开发说明
|
||||
|
||||
### 接口规划
|
||||
|
||||
建议第一版接口:
|
||||
|
||||
- `GET /api/admin/dashboard/boss/yesterday`
|
||||
- `GET /api/admin/dashboard/boss/today-report?slot=1015`
|
||||
- `GET /api/admin/dashboard/boss/today-report?slot=1455`
|
||||
- `GET /api/admin/dashboard/boss/trends?range=7`
|
||||
- `GET /api/admin/dashboard/boss/alerts`
|
||||
|
||||
也可以合并成一个接口:
|
||||
|
||||
- `GET /api/admin/dashboard/boss/overview?mode=yesterday|today&slot=1015|1455`
|
||||
|
||||
建议先保持多个接口,便于前端按模块加载和失败降级。
|
||||
|
||||
### 后端文件规划
|
||||
|
||||
建议新增:
|
||||
|
||||
- `backend/crmeb-admin/src/main/java/com/zbkj/admin/controller/BossDashboardController.java`
|
||||
- `backend/crmeb-service/src/main/java/com/zbkj/service/service/BossDashboardService.java`
|
||||
- `backend/crmeb-service/src/main/java/com/zbkj/service/service/impl/BossDashboardServiceImpl.java`
|
||||
- `backend/crmeb-common/src/main/java/com/zbkj/common/response/dashboard/BossDashboardResponse.java`
|
||||
- `backend/crmeb-common/src/main/java/com/zbkj/common/response/dashboard/BossTodayReportResponse.java`
|
||||
- `backend/crmeb-common/src/main/java/com/zbkj/common/response/dashboard/BossRiskAlertResponse.java`
|
||||
|
||||
### 数据来源
|
||||
|
||||
用户:
|
||||
|
||||
- `wa_users`
|
||||
- `eb_user`
|
||||
|
||||
交易:
|
||||
|
||||
- `wa_order`
|
||||
|
||||
寄售商品:
|
||||
|
||||
- `wa_merchandise`
|
||||
|
||||
奖金:
|
||||
|
||||
- `wa_selfbonus_log`
|
||||
- `wa_sharebonus_log`
|
||||
|
||||
提现:
|
||||
|
||||
- `wa_withdraw`
|
||||
|
||||
积分:
|
||||
|
||||
- `eb_user_integral_record`
|
||||
- `eb_user.integral`
|
||||
|
||||
### 昨日经营口径
|
||||
|
||||
昨日范围:
|
||||
|
||||
```sql
|
||||
created_at >= CURDATE() - INTERVAL 1 DAY
|
||||
AND created_at < CURDATE()
|
||||
```
|
||||
|
||||
如果表字段是 `join_time`、`create_time`、`pay_time`,按对应字段替换。
|
||||
|
||||
关键指标:
|
||||
|
||||
- 昨日新增用户:`wa_users.created_at` 或 `wa_users.join_time`
|
||||
- 昨日采购用户:`wa_order.buyer_id` 去重
|
||||
- 昨日订单数:`wa_order.created_at`
|
||||
- 昨日成交金额:`wa_order.total_money`
|
||||
- 昨日新增寄售商品:`wa_merchandise.created_at`
|
||||
- 昨日个人奖金发放:`wa_selfbonus_log.type = 1`
|
||||
- 昨日推广奖金发放:`wa_sharebonus_log.type = 1`
|
||||
- 昨日待支付金额:`wa_order.status = 0`
|
||||
- 昨日已支付未完成金额:`wa_order.status = 1`
|
||||
|
||||
### 今日节点快报口径
|
||||
|
||||
10:15 快报范围:
|
||||
|
||||
```sql
|
||||
created_at >= CURDATE()
|
||||
AND created_at <= CONCAT(CURDATE(), ' 10:15:00')
|
||||
```
|
||||
|
||||
14:55 快报范围:
|
||||
|
||||
```sql
|
||||
created_at >= CURDATE()
|
||||
AND created_at <= CONCAT(CURDATE(), ' 14:55:00')
|
||||
```
|
||||
|
||||
快报需要记录生成时刻,避免后续数据变化导致历史快报不可复现。
|
||||
|
||||
第一版如果不建表,可实时查询并明确标记为“临时快报”。正式版需要落表。
|
||||
|
||||
### 快照表建议
|
||||
|
||||
后续阶段新增:
|
||||
|
||||
```sql
|
||||
CREATE TABLE bi_today_snapshot (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
report_date DATE NOT NULL,
|
||||
slot VARCHAR(16) NOT NULL COMMENT '1015/1455',
|
||||
status TINYINT NOT NULL DEFAULT 1 COMMENT '1=成功,2=失败',
|
||||
generated_at DATETIME NOT NULL,
|
||||
payload JSON,
|
||||
error_message VARCHAR(1000),
|
||||
created_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
UNIQUE KEY uk_report_slot (report_date, slot)
|
||||
);
|
||||
```
|
||||
|
||||
如果当前 MySQL 版本或项目 JSON 字段支持不稳定,可把 `payload` 改为 `LONGTEXT` 存 JSON 字符串。
|
||||
|
||||
### 风险预警规则
|
||||
|
||||
第一版实时计算即可。
|
||||
|
||||
建议规则:
|
||||
|
||||
- 积分与个人奖金比例异常:`ABS(eb_user.integral - wa_users.self_bonus / 2) > 1`
|
||||
- 单用户个人奖金过高:`wa_users.self_bonus >= 阈值`
|
||||
- 单用户推广奖金过高:`wa_users.share_bonus >= 阈值`
|
||||
- 大额待审核提现:`wa_withdraw.status = 0 AND money >= 阈值`
|
||||
- 大额未支付订单:`wa_order.status = 0 AND total_money >= 阈值`
|
||||
- 高价滞销商品:`wa_merchandise.status = 1 AND price >= 阈值 AND created_at <= NOW() - INTERVAL N DAY`
|
||||
- 数据不一致:`wa_users.id` 在 `eb_user.uid` 中不存在,或 `wa_users.mobile != eb_user.phone`
|
||||
|
||||
阈值第一版可写死在服务层,后续再做配置化。
|
||||
|
||||
### 定时任务
|
||||
|
||||
建议任务:
|
||||
|
||||
- 每天 00:10:生成昨日经营日报。
|
||||
- 每天 10:15:生成今日第一版快报。
|
||||
- 每天 14:55:生成今日第二版快报。
|
||||
- 每小时:刷新风险预警。
|
||||
|
||||
如果系统已有 Quartz/ScheduleJob,则接入现有任务体系。
|
||||
|
||||
## 前后端协作顺序
|
||||
|
||||
### Step 1:前端出静态页面
|
||||
|
||||
前端完成:
|
||||
|
||||
- H5 底部 TabBar
|
||||
- 手机端页面布局
|
||||
- 顶部吸顶 Header
|
||||
- 筛选弹层
|
||||
- Mock 数据
|
||||
- 图表
|
||||
- 风险列表
|
||||
- 下钻按钮
|
||||
|
||||
不等待后端。
|
||||
|
||||
### Step 2:老板确认
|
||||
|
||||
确认内容:
|
||||
|
||||
- 是否默认看昨日。
|
||||
- 今日快报是否放在合适位置。
|
||||
- 10:15、14:55 两个节点名称是否符合业务习惯。
|
||||
- KPI 是否足够。
|
||||
- 哪些风险最重要。
|
||||
- 哪些排行需要保留。
|
||||
- 手机首屏是否足够清楚。
|
||||
- 滑动查看排行榜、风险和下钻是否顺手。
|
||||
|
||||
确认后冻结第一版字段。
|
||||
|
||||
### Step 3:后端按字段实现接口
|
||||
|
||||
后端按前端 Mock 字段实现真实接口。
|
||||
|
||||
要求:
|
||||
|
||||
- 字段名不随意改。
|
||||
- 缺数据返回 0 或空数组。
|
||||
- 金额统一 BigDecimal。
|
||||
- 日期统一 `yyyy-MM-dd HH:mm:ss`。
|
||||
|
||||
### Step 4:联调
|
||||
|
||||
联调顺序:
|
||||
|
||||
1. 昨日经营复盘。
|
||||
2. 今日 10:15 快报。
|
||||
3. 今日 14:55 快报。
|
||||
4. 趋势图。
|
||||
5. 排行榜。
|
||||
6. 风险预警。
|
||||
|
||||
### Step 5:快照化与定时任务
|
||||
|
||||
如果老板确认使用,再做快照表和定时任务。
|
||||
|
||||
## 测试说明
|
||||
|
||||
### 前端测试
|
||||
|
||||
- 页面能独立打开。
|
||||
- Mock 数据展示完整。
|
||||
- 今日快报四种状态展示正常。
|
||||
- 图表在空数据时不报错。
|
||||
- 风险列表为空时显示空状态。
|
||||
- 点击下钻按钮不报错。
|
||||
- 375px 手机宽度下无横向滚动。
|
||||
- 底部 TabBar、筛选弹层、返回逻辑正常。
|
||||
|
||||
### 后端测试
|
||||
|
||||
- 昨日时间范围正确。
|
||||
- 10:15 快报范围正确。
|
||||
- 14:55 快报范围正确。
|
||||
- 金额汇总和数据库明细一致。
|
||||
- 用户、订单、商品、奖金、提现为空时接口正常返回。
|
||||
- 风险规则命中样例正确。
|
||||
|
||||
### 联调验收
|
||||
|
||||
- 老板能通过一个页面看懂昨日经营是否正常。
|
||||
- 10:15 后能看到第一版今日快报。
|
||||
- 14:55 后能看到第二版今日快报。
|
||||
- 核心指标能下钻到现有业务页面。
|
||||
- 与现有后台明细抽样核对一致。
|
||||
- 老板能在手机 H5 页面顺畅查看核心指标、排行和风险。
|
||||
|
||||
## 里程碑
|
||||
|
||||
- 前端 Mock:1-2 天,交付可访问的 H5 经营驾驶舱页面,用于老板手机确认页面和指标。
|
||||
- 后端实时接口:2-4 天,交付 BI 查询接口,用真实数据替换 Mock。
|
||||
- 联调验收:1-2 天,完成指标和明细对账,形成可用 MVP。
|
||||
- 快照任务:3-5 天,交付汇总表、快照表和定时任务。
|
||||
|
||||
## 不在第一版范围
|
||||
|
||||
- PC 后台完整看板。
|
||||
- 多公司统一看板。
|
||||
- 复杂权限隔离。
|
||||
- 自定义指标配置。
|
||||
- 老板微信/企微推送。
|
||||
- 完整 BI 报表编辑器。
|
||||
- 所有历史日报回填。
|
||||
|
||||
这些可在老板确认 MVP 价值后再做。
|
||||
47
docs/dashboard/crmeb-front-mysql-remote-connections.md
Normal file
47
docs/dashboard/crmeb-front-mysql-remote-connections.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# 积分商城front MySQL 远程连接汇总
|
||||
|
||||
整理范围:`backend/crmeb-front/src/main/resources/application*.yml`
|
||||
|
||||
整理时间:2026-05-11
|
||||
|
||||
## 口径说明
|
||||
|
||||
- 仅汇总 MySQL URL 中 host 为远程地址的配置。
|
||||
- 所属公司优先按 `docs/company-info-*.md`、`docs/*data-imgration*.md` 中的公司名称和 host ip 匹配;没有明确公司名时,按部署文档、域名、profile 或数据库名推断并注明。
|
||||
- 密码按当前配置文件完整记录。
|
||||
|
||||
## 按 MySQL 主机聚合
|
||||
|
||||
| MySQL host:port | 关联 profile | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `8.140.218.149:3306` | `byjyw149` | 宝应金雅文商贸 |
|
||||
| `8.136.120.231:3306` | `czc231` | 宝应晨召春商贸 |
|
||||
| `121.43.134.82:3306` | `czcf82` | 池州春芳商贸 |
|
||||
| `101.37.101.6:3306` | `czrt6` | 池州瑞棠商贸 |
|
||||
| `114.55.232.191:3306` | `hapr191` | 淮安鹏然商贸 |
|
||||
| `106.14.132.80:3306` | `sxsy80` | 太原树英商贸 |
|
||||
| `39.106.63.33:3306` | `miao33` | 夏盛军商贸 |
|
||||
| `123.56.214.80:3306` | `miao80` | 宝应博森元 |
|
||||
| `101.37.253.50:3306` | `miao50` | 上海文锦惠商贸 |
|
||||
| `101.132.245.153:3306` | `shjjy153` | 上海聚伽源商贸 |
|
||||
| `182.92.78.159:3306` | `shccd159` | 上海慈初德商贸 |
|
||||
|
||||
## 远程 MySQL 配置清单
|
||||
|
||||
| Profile | 配置文件 | 所属公司 / 项目 | MySQL host:port | 数据库名 | 用户名 | 密码 | 依据 / 备注 |
|
||||
| --- | --- | --- | --- | --- | --- | --- | --- |
|
||||
| `byjyw149` | `backend/crmeb-front/src/main/resources/application-byjyw149.yml` | 宝应金雅文商贸 | `8.140.218.149:3306` | `yangtangyoupin` | `yangtangyoupin` | `5Fn8eWrbYFtAhCZw` | `docs/company-info-byjyw149.md`、`docs/byjyw149-data-imgration.md` |
|
||||
| `czc231` | `backend/crmeb-front/src/main/resources/application-czc231.yml` | 宝应晨召春商贸 | `8.136.120.231:3306` | `yangtangyoupin` | `yangtangyoupin` | `5Fn8eWrbYFtAhCZw` | `docs/company-czc231-data-imgration.md`、`docs/company-czc231-integral-imgration.md` |
|
||||
| `czcf82` | `backend/crmeb-front/src/main/resources/application-czcf82.yml` | 池州春芳商贸 | `121.43.134.82:3306` | `yangtangyoupin` | `yangtangyoupin` | `5Fn8eWrbYFtAhCZw` | `docs/company-info-czcf82.md`、`docs/company-czcf82-data-imgration.md` |
|
||||
| `czrt6` | `backend/crmeb-front/src/main/resources/application-czrt6.yml` | 池州瑞棠商贸 | `101.37.101.6:3306` | `yangtangyoupin` | `yangtangyoupin` | `5Fn8eWrbYFtAhCZw` | `docs/company-info-czrt6.md`、`docs/company-czrt6-data-imgration.md` |
|
||||
| `hapr191` | `backend/crmeb-front/src/main/resources/application-hapr191.yml` | 淮安鹏然商贸 | `114.55.232.191:3306` | `yangtangyoupin` | `yangtangyoupin` | `5Fn8eWrbYFtAhCZw` | `docs/company-info.md`、`docs/company-data-imgration.md` |
|
||||
| `sxsy80` | `backend/crmeb-front/src/main/resources/application-sxsy80.yml` | 太原树英商贸 | `106.14.132.80:3306` | `yangtangyoupin` | `yangtangyoupin` | `5Fn8eWrbYFtAhCZw` | `docs/com-sxsy80.md`、`docs/com-sxsy80-data-imgration.md` |
|
||||
| `miao33` | `backend/crmeb-front/src/main/resources/application-miao33.yml` | 夏盛军商贸 | `39.106.63.33:3306` | `yangtangyoupin` | `yangtangyoupin` | `5Fn8eWrbYFtAhCZw` | `docs/com-xsj33-data-imgration.md`;部署文档中也标注 `jfadmin.xiashengjun.com` |
|
||||
| `miao80` | `backend/crmeb-front/src/main/resources/application-miao80.yml` | 宝应博森元 | `123.56.214.80:3306` | `yangtangyoupin` | `yangtangyoupin` | `5Fn8eWrbYFtAhCZw` | `backend-adminend/DEPLOY.md` 中 by80 示例域名为 `jfadmin.bosenyuan.com`;`.cursor/plans/bybsy80范围数据删除_1cf340f6.plan.md` 也指向该 host |
|
||||
| `miao50` | `backend/crmeb-front/src/main/resources/application-miao50.yml` | 上海文锦惠商贸 | `101.37.253.50:3306` | `yangtangyoupin` | `yangtangyoupin` | `5Fn8eWrbYFtAhCZw` | `backend/DEPLOY.md`、OpenClaw 配置文档中标注为生产环境 |
|
||||
| `shjjy153` | `backend/crmeb-front/src/main/resources/application-shjjy153.yml` | 上海聚伽源商贸 | `101.132.245.153:3306` | `yangtangyoupin` | `yangtangyoupin` | `5Fn8eWrbYFtAhCZw` | `docs/compare-shjjy153-shccd159.md` 标注域名 `jjy-jf.fwxgpt.com`、`jjy-jfadmin.fwxgpt.com` |
|
||||
| `shccd159` | `backend/crmeb-front/src/main/resources/application-shccd159.yml` | 上海慈初德商贸 | `182.92.78.159:3306` | `yangtangyoupin` | `yangtangyoupin` | `5Fn8eWrbYFtAhCZw` | `docs/compare-shjjy153-shccd159.md` 标注域名 `ccd-jf.fwxgpt.com`、`ccd-jfadmin.fwxgpt.com` |
|
||||
|
||||
|
||||
|
||||
|
||||
1212
docs/dashboard/dashboard-frontend-dev-spec.md
Normal file
1212
docs/dashboard/dashboard-frontend-dev-spec.md
Normal file
File diff suppressed because it is too large
Load Diff
751
docs/dashboard/dashboard-frontend-technical-architecture.md
Normal file
751
docs/dashboard/dashboard-frontend-technical-architecture.md
Normal file
@@ -0,0 +1,751 @@
|
||||
# Dashboard 前端项目技术架构方案
|
||||
|
||||
## 1. 方案定位
|
||||
|
||||
本文档面向一个全新的 Dashboard 前端项目,不考虑当前商城后台已有技术架构、目录结构和历史包袱。
|
||||
|
||||
目标是建设一个适合老板手机查看的 H5 经营驾驶舱,覆盖经营分析、BI 报表、风险预警和数据下钻。项目应具备快速交付、长期可维护、强类型、易扩展、易联调和可独立部署的能力。
|
||||
|
||||
## 2. 架构目标
|
||||
|
||||
- 支持经营驾驶舱、日报、快报、趋势分析、排行榜、风险预警等 BI 场景。
|
||||
- 优先支持 H5 移动端展示,适配手机浏览器、企业微信内置浏览器和移动端 WebView。
|
||||
- 在 PC 浏览器访问时以手机宽度居中展示,不优先建设 PC 后台版。
|
||||
- 前端可先独立使用 Mock 数据完成页面确认,不依赖后端开发进度。
|
||||
- 接口契约稳定后,平滑从 Mock 切换到真实 API。
|
||||
- 支持权限、菜单、主题、国际化、错误边界、埋点和性能监控。
|
||||
- 支持独立构建、独立部署、独立版本发布。
|
||||
|
||||
## 3. 推荐技术栈
|
||||
|
||||
### 3.1 核心框架
|
||||
|
||||
- 前端框架:`React 19`
|
||||
- 开发语言:`TypeScript`
|
||||
- 构建工具:`Vite`
|
||||
- 包管理器:`pnpm`
|
||||
- 路由:`React Router`
|
||||
- 服务端状态:`TanStack Query`
|
||||
- 客户端状态:`Zustand`
|
||||
- HTTP 请求:`Axios`
|
||||
- 图表:`Apache ECharts`
|
||||
- UI 组件库:`Vant React` 或等价移动端组件库
|
||||
- 样式方案:`CSS Modules` + `CSS Variables`
|
||||
- 代码规范:`ESLint` + `Prettier`,或统一采用 `Biome`
|
||||
- 单元测试:`Vitest`
|
||||
- 组件测试:`React Testing Library`
|
||||
- E2E 测试:`Playwright`
|
||||
- Mock:`MSW`
|
||||
|
||||
### 3.2 技术选型理由
|
||||
|
||||
`React + TypeScript + Vite` 适合从 0 建设独立 BI Dashboard,生态成熟、类型安全、启动和构建速度快。
|
||||
|
||||
`Vant React` 更适合 H5 移动端经营驾驶舱场景,提供移动端表单、弹层、Tab、日期选择、列表、Toast、Dialog 等基础能力,能降低手机端交互开发成本。如果团队最终选用其他 React 移动端组件库,也应满足同等移动端触控和适配能力。
|
||||
|
||||
`ECharts` 适合经营分析中的复杂图表需求,包括折线图、柱状图、饼图、仪表盘、热力图和双轴图。移动端使用时需要封装触控友好的 tooltip、缩放和空态,不直接套用 PC 大图表配置。
|
||||
|
||||
`TanStack Query` 负责接口数据、缓存、刷新、重试和加载状态,避免把接口数据塞进全局状态。
|
||||
|
||||
`Zustand` 只管理前端本地状态,例如主题、底部 Tab、筛选条件、当前快报版本、用户偏好。
|
||||
|
||||
## 4. 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Browser[浏览器] --> App[Dashboard App]
|
||||
App --> Router[路由层]
|
||||
App --> Layout[H5 布局层]
|
||||
App --> FeatureModules[业务模块]
|
||||
App --> Shared[共享能力]
|
||||
|
||||
FeatureModules --> BossDashboard[老板驾驶舱]
|
||||
FeatureModules --> DailyReport[经营日报]
|
||||
FeatureModules --> TodaySnapshot[今日节点快报]
|
||||
FeatureModules --> RiskCenter[风险预警中心]
|
||||
FeatureModules --> DataDrill[数据下钻]
|
||||
|
||||
Shared --> ApiClient[API Client]
|
||||
Shared --> QueryLayer[TanStack Query]
|
||||
Shared --> Store[Zustand Store]
|
||||
Shared --> ChartKit[Chart Kit]
|
||||
Shared --> Permission[权限与移动端导航]
|
||||
Shared --> Theme[主题系统]
|
||||
|
||||
ApiClient --> Backend[BI API 服务]
|
||||
QueryLayer --> ApiClient
|
||||
```
|
||||
|
||||
## 5. 项目目录设计
|
||||
|
||||
建议目录:
|
||||
|
||||
```text
|
||||
dashboard-frontend/
|
||||
public/
|
||||
src/
|
||||
app/
|
||||
App.tsx
|
||||
providers/
|
||||
router/
|
||||
layouts/
|
||||
assets/
|
||||
components/
|
||||
common/
|
||||
charts/
|
||||
kpi/
|
||||
list/
|
||||
feedback/
|
||||
features/
|
||||
boss-dashboard/
|
||||
daily-report/
|
||||
today-snapshot/
|
||||
risk-center/
|
||||
data-drill/
|
||||
services/
|
||||
api/
|
||||
http/
|
||||
mock/
|
||||
stores/
|
||||
hooks/
|
||||
utils/
|
||||
types/
|
||||
styles/
|
||||
tests/
|
||||
.env.development
|
||||
.env.production
|
||||
package.json
|
||||
vite.config.ts
|
||||
tsconfig.json
|
||||
```
|
||||
|
||||
### 5.1 `app`
|
||||
|
||||
承载项目启动、全局 Provider、路由、布局和全局配置。
|
||||
|
||||
包含:
|
||||
|
||||
- 路由初始化
|
||||
- QueryClient 初始化
|
||||
- 移动端 UI ConfigProvider
|
||||
- 权限上下文
|
||||
- 主题上下文
|
||||
- 全局错误边界
|
||||
|
||||
### 5.2 `features`
|
||||
|
||||
按业务功能拆分模块,避免所有页面堆在 `views` 下。
|
||||
|
||||
建议模块:
|
||||
|
||||
- `boss-dashboard`:老板驾驶舱首页
|
||||
- `daily-report`:昨日/历史经营日报
|
||||
- `today-snapshot`:今日 10:15、14:55 快报
|
||||
- `risk-center`:风险预警列表与处理
|
||||
- `data-drill`:用户、订单、商品、团队等下钻页
|
||||
|
||||
每个模块内部建议结构:
|
||||
|
||||
```text
|
||||
features/boss-dashboard/
|
||||
pages/
|
||||
components/
|
||||
hooks/
|
||||
api.ts
|
||||
types.ts
|
||||
constants.ts
|
||||
mock.ts
|
||||
```
|
||||
|
||||
### 5.3 `components`
|
||||
|
||||
放跨模块复用组件。
|
||||
|
||||
建议拆分:
|
||||
|
||||
- `common`:页面标题、空状态、加载状态、错误状态
|
||||
- `charts`:折线图、柱状图、饼图、双轴图、仪表盘
|
||||
- `kpi`:指标卡、指标组、同比环比组件
|
||||
- `table`:统一表格封装
|
||||
- `feedback`:异常提示、状态标签、风险等级标签
|
||||
|
||||
### 5.4 `services`
|
||||
|
||||
承载接口、HTTP 客户端、Mock 和数据适配。
|
||||
|
||||
核心职责:
|
||||
|
||||
- 统一请求拦截
|
||||
- 统一响应解包
|
||||
- 统一错误提示
|
||||
- 统一登录态处理
|
||||
- Mock 与真实 API 切换
|
||||
- 后端字段到前端视图模型的适配
|
||||
|
||||
### 5.5 `types`
|
||||
|
||||
放全局类型和跨模块共享类型。
|
||||
|
||||
原则:
|
||||
|
||||
- 接口响应类型和页面视图类型分开。
|
||||
- 后端 DTO 不直接污染页面组件。
|
||||
- 金额、日期、枚举、状态统一定义。
|
||||
|
||||
## 6. 模块架构设计
|
||||
|
||||
### 6.1 老板驾驶舱
|
||||
|
||||
职责:
|
||||
|
||||
- 展示昨日经营总览。
|
||||
- 展示今日 10:15 / 14:55 节点快报。
|
||||
- 展示趋势图、排行榜、风险预警。
|
||||
- 提供下钻入口。
|
||||
|
||||
页面结构:
|
||||
|
||||
```text
|
||||
BossDashboardPage
|
||||
MobileStickyHeader
|
||||
MobileDateFilterSheet
|
||||
KpiOverview
|
||||
TodaySnapshotPanel
|
||||
TrendSection
|
||||
RankSection
|
||||
RiskAlertSection
|
||||
BottomTabBar
|
||||
```
|
||||
|
||||
H5 展示要求:
|
||||
|
||||
- 首屏优先展示 4 个核心 KPI 和今日快报状态。
|
||||
- 其他 KPI 使用折叠区或分组卡片承载。
|
||||
- 页面采用单列信息流,不使用 PC 两列 Dashboard。
|
||||
- 排行榜默认展示 Top 5,点击进入完整列表。
|
||||
- 筛选条件使用弹层、分段控件或底部 ActionSheet。
|
||||
|
||||
### 6.2 经营日报
|
||||
|
||||
职责:
|
||||
|
||||
- 查看昨日、近 7 天、近 30 天和自定义日期经营数据。
|
||||
- 支持日报导出。
|
||||
- 支持历史日期切换。
|
||||
|
||||
### 6.3 今日节点快报
|
||||
|
||||
职责:
|
||||
|
||||
- 展示 10:15 快报。
|
||||
- 展示 14:55 快报。
|
||||
- 标识快报状态:待生成、已生成、生成失败、临时数据。
|
||||
- 支持与昨日同节点或昨日全天对比。
|
||||
|
||||
### 6.4 风险预警中心
|
||||
|
||||
职责:
|
||||
|
||||
- 展示资金、订单、商品、用户、数据质量风险。
|
||||
- 支持风险等级筛选。
|
||||
- 支持跳转明细。
|
||||
- 支持风险状态流转:未处理、处理中、已处理、已忽略。
|
||||
|
||||
### 6.5 数据下钻
|
||||
|
||||
职责:
|
||||
|
||||
- 从 KPI、图表、排行和风险项跳转到明细视图。
|
||||
- 支持携带筛选条件。
|
||||
- 支持返回驾驶舱时保留上下文。
|
||||
|
||||
## 7. 数据流设计
|
||||
|
||||
### 7.1 数据流原则
|
||||
|
||||
- 接口数据归 `TanStack Query` 管。
|
||||
- 页面交互状态归组件本地状态或 `Zustand` 管。
|
||||
- 复杂筛选条件可以进入 URL Query,方便分享和刷新恢复。
|
||||
- 图表组件只接收标准化后的数据,不直接请求接口。
|
||||
|
||||
### 7.2 数据流示意
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Page as 页面
|
||||
participant Hook as Query Hook
|
||||
participant API as API Service
|
||||
participant Adapter as Data Adapter
|
||||
participant Chart as Chart/KPI 组件
|
||||
participant Backend as BI API
|
||||
|
||||
Page->>Hook: 传入日期、快报版本、筛选条件
|
||||
Hook->>API: 请求接口
|
||||
API->>Backend: HTTP Request
|
||||
Backend-->>API: DTO Response
|
||||
API->>Adapter: 字段转换与默认值处理
|
||||
Adapter-->>Hook: ViewModel
|
||||
Hook-->>Page: data/loading/error
|
||||
Page->>Chart: 传入标准图表数据
|
||||
```
|
||||
|
||||
## 8. 接口契约设计
|
||||
|
||||
### 8.1 前端视图模型
|
||||
|
||||
前端不直接依赖后端原始字段,统一转换为页面视图模型。
|
||||
|
||||
核心模型:
|
||||
|
||||
- `DashboardOverview`
|
||||
- `KpiMetric`
|
||||
- `TodaySnapshot`
|
||||
- `TrendSeries`
|
||||
- `RankItem`
|
||||
- `RiskAlert`
|
||||
- `DrillLink`
|
||||
|
||||
### 8.2 接口分层
|
||||
|
||||
建议接口:
|
||||
|
||||
- `GET /dashboard/overview`
|
||||
- `GET /dashboard/today-snapshot`
|
||||
- `GET /dashboard/trends`
|
||||
- `GET /dashboard/ranks`
|
||||
- `GET /dashboard/risks`
|
||||
- `GET /dashboard/drill-options`
|
||||
|
||||
前端可以先使用相同结构的 Mock 数据,后端上线后只替换请求来源。
|
||||
|
||||
### 8.3 错误处理
|
||||
|
||||
接口错误分三层:
|
||||
|
||||
- 页面级错误:整个模块无法加载。
|
||||
- 区块级错误:某个图表或榜单失败。
|
||||
- 字段级兜底:单个指标缺失时显示 `--`。
|
||||
|
||||
老板驾驶舱首页不应因为某一个模块失败而整页不可用。
|
||||
|
||||
## 9. 状态管理方案
|
||||
|
||||
### 9.1 Server State
|
||||
|
||||
由 `TanStack Query` 管理:
|
||||
|
||||
- 接口缓存
|
||||
- 自动刷新
|
||||
- 重试
|
||||
- loading / error 状态
|
||||
- 后台静默刷新
|
||||
- 请求去重
|
||||
|
||||
适用数据:
|
||||
|
||||
- KPI 数据
|
||||
- 趋势数据
|
||||
- 排行数据
|
||||
- 风险列表
|
||||
- 快报快照
|
||||
|
||||
### 9.2 Client State
|
||||
|
||||
由 `Zustand` 管理:
|
||||
|
||||
- 当前主题
|
||||
- 底部 Tab 当前选中状态
|
||||
- 筛选弹层打开状态
|
||||
- 当前公司
|
||||
- 当前快报版本
|
||||
- 用户偏好
|
||||
- 全局筛选草稿
|
||||
|
||||
### 9.3 URL State
|
||||
|
||||
建议进入 URL 的状态:
|
||||
|
||||
- 日期范围
|
||||
- 快报版本
|
||||
- 风险等级
|
||||
- 团队 ID
|
||||
- 下钻来源
|
||||
|
||||
这样可以支持刷新恢复和链接分享。
|
||||
|
||||
## 10. 图表体系
|
||||
|
||||
### 10.1 图表库
|
||||
|
||||
推荐使用 `Apache ECharts`。
|
||||
|
||||
原因:
|
||||
|
||||
- 适合 BI 场景。
|
||||
- 支持复杂组合图。
|
||||
- 支持大数据量渲染优化。
|
||||
- 国内开发者熟悉度高。
|
||||
- 主题定制能力强。
|
||||
|
||||
### 10.2 图表封装原则
|
||||
|
||||
不要在业务页面直接写大量 ECharts option。
|
||||
|
||||
建议封装:
|
||||
|
||||
- `LineTrendChart`
|
||||
- `BarCompareChart`
|
||||
- `PieRatioChart`
|
||||
- `DualAxisChart`
|
||||
- `GaugeMetricChart`
|
||||
- `RankBarChart`
|
||||
|
||||
业务页面只传入标准数据:
|
||||
|
||||
```text
|
||||
{
|
||||
title,
|
||||
xAxis,
|
||||
series,
|
||||
unit,
|
||||
loading,
|
||||
empty
|
||||
}
|
||||
```
|
||||
|
||||
### 10.3 图表状态
|
||||
|
||||
每个图表必须支持:
|
||||
|
||||
- loading
|
||||
- empty
|
||||
- error
|
||||
- normal
|
||||
- fullscreen
|
||||
|
||||
## 11. 权限与安全
|
||||
|
||||
### 11.1 权限模型
|
||||
|
||||
建议分三层:
|
||||
|
||||
- 路由权限:是否能访问驾驶舱。
|
||||
- 模块权限:是否能看资金、奖金、团队等模块。
|
||||
- 字段权限:是否脱敏手机号、姓名、金额。
|
||||
|
||||
### 11.2 前端安全
|
||||
|
||||
- Token 不写死。
|
||||
- 请求统一携带认证信息。
|
||||
- 敏感字段按权限展示。
|
||||
- 不在前端保存数据库连接信息。
|
||||
- 不在 Mock 数据中使用真实手机号、真实姓名、真实金额。
|
||||
|
||||
### 11.3 敏感信息展示
|
||||
|
||||
老板角色可以看完整金额。
|
||||
|
||||
普通管理角色建议:
|
||||
|
||||
- 手机号脱敏。
|
||||
- 用户姓名部分脱敏。
|
||||
- 大额资金需要权限。
|
||||
- 导出需要二次确认。
|
||||
|
||||
## 12. Mock 与联调
|
||||
|
||||
### 12.1 Mock 优先
|
||||
|
||||
第一阶段必须支持无后端运行。
|
||||
|
||||
推荐使用 `MSW`:
|
||||
|
||||
- 拦截浏览器请求。
|
||||
- Mock 真实接口。
|
||||
- 与后端接口格式保持一致。
|
||||
- 联调时可以逐个接口关闭 Mock。
|
||||
|
||||
### 12.2 Mock 数据场景
|
||||
|
||||
至少覆盖:
|
||||
|
||||
- 昨日经营正常。
|
||||
- 昨日经营下滑。
|
||||
- 今日 10:15 已生成。
|
||||
- 今日 14:55 待生成。
|
||||
- 今日快报生成失败。
|
||||
- 风险为空。
|
||||
- 风险包含红黄灰三级。
|
||||
- 某个图表接口失败。
|
||||
- 某个 KPI 缺失。
|
||||
|
||||
### 12.3 联调策略
|
||||
|
||||
前端接口层设置数据源模式:
|
||||
|
||||
- `mock`
|
||||
- `api`
|
||||
- `hybrid`
|
||||
|
||||
`hybrid` 用于部分接口接真实 API,部分接口继续使用 Mock。
|
||||
|
||||
## 13. 样式与主题
|
||||
|
||||
### 13.1 设计方向
|
||||
|
||||
老板驾驶舱应偏“手机经营简报”风格:信息聚焦、数字醒目、可快速滑动浏览,并兼顾老板在企业微信或手机浏览器内查看的可读性。
|
||||
|
||||
建议:
|
||||
|
||||
- 默认浅色主题。
|
||||
- 后续可支持深色移动端主题。
|
||||
- 金额、风险、增长下降用统一颜色。
|
||||
- 卡片信息密度高但不拥挤。
|
||||
- 关键数字明显大于说明文字。
|
||||
- 手机首屏必须能看见昨日经营核心结论。
|
||||
- 底部导航和顶部筛选不遮挡内容。
|
||||
|
||||
### 13.1.1 H5 布局要求
|
||||
|
||||
- 核心适配宽度:360px、375px、390px、414px。
|
||||
- PC 浏览器访问时,内容区最大宽度建议 430px 并居中。
|
||||
- 使用单列信息流。
|
||||
- KPI 使用 2 列卡片,重点金额卡片可跨 2 列。
|
||||
- 图表单列展示,高度建议 220px 到 280px。
|
||||
- 底部 TabBar 需要适配 `safe-area-inset-bottom`。
|
||||
- 点击热区不小于 44px。
|
||||
- 不允许页面出现横向滚动条。
|
||||
|
||||
### 13.2 主题变量
|
||||
|
||||
统一管理:
|
||||
|
||||
- 主色
|
||||
- 第一版确定使用参考图中的活力橙/珊瑚橙作为主色调,主色建议接近 `#ff5b36`,辅色使用橙黄 `#ffb000`,用于核心按钮、选中态、关键图表主线和高亮数字。
|
||||
- 成功色
|
||||
- 警告色
|
||||
- 危险色
|
||||
- 背景色
|
||||
- 卡片背景
|
||||
- 字体颜色
|
||||
- 边框颜色
|
||||
- 图表色板
|
||||
|
||||
## 14. 性能方案
|
||||
|
||||
### 14.1 加载性能
|
||||
|
||||
- 路由懒加载。
|
||||
- 图表组件懒加载。
|
||||
- 大模块按需加载。
|
||||
- 首屏接口并行请求。
|
||||
- 非核心模块延迟加载。
|
||||
- 移动端首屏优先加载 KPI 和快报,排行榜、风险列表可延迟加载。
|
||||
|
||||
### 14.2 渲染性能
|
||||
|
||||
- 大表格虚拟滚动。
|
||||
- 图表数据降采样。
|
||||
- 趋势图默认展示合理时间窗口。
|
||||
- 避免父组件频繁重渲染图表。
|
||||
- ECharts 实例销毁和 resize 管理统一封装。
|
||||
|
||||
### 14.3 数据刷新
|
||||
|
||||
- 昨日数据不频繁刷新。
|
||||
- 今日快报按节点刷新。
|
||||
- 风险预警可设置 1 到 5 分钟刷新。
|
||||
- 页面不可见时暂停自动刷新。
|
||||
|
||||
## 15. 测试策略
|
||||
|
||||
### 15.1 单元测试
|
||||
|
||||
覆盖:
|
||||
|
||||
- 数据适配器
|
||||
- 金额格式化
|
||||
- 日期范围计算
|
||||
- 风险等级映射
|
||||
- KPI 状态计算
|
||||
|
||||
### 15.2 组件测试
|
||||
|
||||
覆盖:
|
||||
|
||||
- KPI 卡片不同状态展示。
|
||||
- 今日快报待生成/已生成/失败状态。
|
||||
- 风险列表空态和多级风险。
|
||||
- 图表空数据和错误状态。
|
||||
|
||||
### 15.3 E2E 测试
|
||||
|
||||
覆盖关键路径:
|
||||
|
||||
- 打开老板驾驶舱。
|
||||
- 切换昨日、近 7 天、近 30 天。
|
||||
- 切换 10:15 / 14:55 快报。
|
||||
- 点击 KPI 下钻。
|
||||
- 点击风险项查看明细。
|
||||
- 导出日报。
|
||||
|
||||
## 16. 部署架构
|
||||
|
||||
### 16.1 独立部署
|
||||
|
||||
推荐 Dashboard 前端独立部署为静态站点。
|
||||
|
||||
部署目标:
|
||||
|
||||
- Nginx
|
||||
- OSS/CDN
|
||||
- Docker + Nginx
|
||||
- 企业内网静态服务
|
||||
|
||||
### 16.2 环境划分
|
||||
|
||||
建议环境:
|
||||
|
||||
- `local`
|
||||
- `development`
|
||||
- `staging`
|
||||
- `production`
|
||||
|
||||
环境变量:
|
||||
|
||||
- `VITE_APP_ENV`
|
||||
- `VITE_API_BASE_URL`
|
||||
- `VITE_MOCK_ENABLED`
|
||||
- `VITE_SENTRY_DSN`
|
||||
- `VITE_BUILD_VERSION`
|
||||
|
||||
### 16.3 版本发布
|
||||
|
||||
每次构建写入:
|
||||
|
||||
- 构建版本
|
||||
- Git Commit
|
||||
- 构建时间
|
||||
- 环境名称
|
||||
|
||||
页面底部或调试面板可查看当前版本,便于排查问题。
|
||||
|
||||
## 17. 监控与可观测性
|
||||
|
||||
建议接入:
|
||||
|
||||
- 前端错误监控
|
||||
- 页面性能监控
|
||||
- API 请求耗时统计
|
||||
- 白屏监控
|
||||
- 用户行为埋点
|
||||
|
||||
关键埋点:
|
||||
|
||||
- 老板驾驶舱访问次数
|
||||
- 日期范围切换
|
||||
- 今日快报查看
|
||||
- KPI 点击下钻
|
||||
- 风险项点击
|
||||
- 导出日报
|
||||
|
||||
## 18. CI/CD
|
||||
|
||||
推荐流水线:
|
||||
|
||||
1. 安装依赖
|
||||
2. 类型检查
|
||||
3. Lint
|
||||
4. 单元测试
|
||||
5. 构建
|
||||
6. 产物上传
|
||||
7. 部署到目标环境
|
||||
8. 冒烟测试
|
||||
|
||||
质量门禁:
|
||||
|
||||
- TypeScript 不允许类型错误。
|
||||
- Lint 不允许错误。
|
||||
- 核心测试必须通过。
|
||||
- 构建产物大小超限需要提醒。
|
||||
|
||||
## 19. 开发流程
|
||||
|
||||
### 19.1 第一阶段:前端原型
|
||||
|
||||
目标:快速给老板确认。
|
||||
|
||||
内容:
|
||||
|
||||
- 初始化项目。
|
||||
- 接入 UI 组件库。
|
||||
- 完成路由和布局。
|
||||
- 完成老板驾驶舱 Mock 页面。
|
||||
- 完成 KPI、趋势图、快报、排行、风险预警。
|
||||
- 完成 H5 底部 Tab、吸顶 Header、筛选弹层和手机端适配。
|
||||
|
||||
### 19.2 第二阶段:接口契约
|
||||
|
||||
目标:冻结前后端字段。
|
||||
|
||||
内容:
|
||||
|
||||
- 输出接口字段文档。
|
||||
- 前端 Mock 与接口文档保持一致。
|
||||
- 后端按契约实现。
|
||||
- 前端通过数据适配器兼容后端字段差异。
|
||||
|
||||
### 19.3 第三阶段:真实联调
|
||||
|
||||
目标:替换 Mock。
|
||||
|
||||
内容:
|
||||
|
||||
- 接入真实 API。
|
||||
- 保留 Mock 作为开发和演示能力。
|
||||
- 校验金额、日期、趋势、风险口径。
|
||||
- 完成下钻联动。
|
||||
|
||||
### 19.4 第四阶段:上线增强
|
||||
|
||||
目标:稳定可用。
|
||||
|
||||
内容:
|
||||
|
||||
- 权限控制。
|
||||
- 错误监控。
|
||||
- 性能优化。
|
||||
- 导出能力。
|
||||
- 深色移动端主题。
|
||||
|
||||
## 20. 推荐里程碑
|
||||
|
||||
- 第 1 到 2 天:项目初始化、H5 路由、移动端布局、主题、Mock 框架。
|
||||
- 第 3 到 5 天:老板驾驶舱 H5 核心页面,包括 KPI、今日快报、趋势、排行、风险。
|
||||
- 第 6 到 7 天:老板确认与页面调整。
|
||||
- 第 8 到 10 天:接口契约冻结,接入第一批真实 API。
|
||||
- 第 11 到 14 天:完整联调、权限、错误处理、基础测试。
|
||||
- 第 15 天以后:快照、导出、监控、深色主题和必要的 PC 预览适配。
|
||||
|
||||
## 21. 不建议第一版做的事
|
||||
|
||||
- 不做复杂自定义 BI 报表编辑器。
|
||||
- 不做多租户平台化架构。
|
||||
- 不做拖拽式大屏搭建。
|
||||
- 不做 PC 后台完整版本。
|
||||
- 不做全量数据仓库前端建模。
|
||||
- 不把所有历史后台功能重新做一遍。
|
||||
- 不在前端处理复杂财务计算,前端只展示后端已确认口径的数据。
|
||||
|
||||
## 22. 最终建议
|
||||
|
||||
建议采用:
|
||||
|
||||
- `React 19 + TypeScript + Vite`
|
||||
- `Vant React` 或等价移动端组件库
|
||||
- `TanStack Query + Zustand`
|
||||
- `ECharts`
|
||||
- `MSW`
|
||||
- `Vitest + Testing Library + Playwright`
|
||||
|
||||
第一版以“老板在手机上能看懂、能确认、能下钻”为核心,先交付独立 H5 Mock Dashboard。确认后再接真实接口和快照数据,避免后端先做大量数据聚合后发现展示口径需要重改。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user