15 Commits

Author SHA1 Message Date
danaisuiyuan
ede01c06d2 docs: add ops and merchandise status repair guides 2026-05-27 13:00:32 +08:00
danaisuiyuan
9b4020d44f fix(points): prevent stale integral balance updates
Use atomic integral updates during balance payment so older user snapshots cannot overwrite newer self-bonus rewards.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 08:57:13 +08:00
danaisuiyuan
8c3ff509fc merge: 合并远程 czleilei240 部署配置并统一 leilei 环境域名
整合 monorepo 侧 Docker 部署改动,冲突处保留 czleilei240 合同 PDF、API 域名与抢购跳转地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 10:55:57 +08:00
danaisuiyuan
93d6a58a2b feat(czleilei240): 配置池州雷蕾商贸积分商城环境
新增 czleilei240 Spring profile、合同 PDF 与部署文档,并将前后端域名、合同落库前缀和后台 API 地址切换到 leilei 环境。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-18 10:54:24 +08:00
danaisuiyuan
cef4398a5a feat(deploy): configure czleilei240 integral shop
Add czleilei240 runtime profiles, frontend domains, contract paths, and Docker deployment updates so the integral shop can run against the Leilei environment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 19:13:01 +08:00
danaisuiyuan
fb76270882 feat(deploy): 完整 Docker 部署方案 — 寄卖商城 + 积分商城
新增两步独立 Docker 部署方案(czleilei240 环境):

步骤一 寄卖商城(integral-resell)
- step1-integral/docker-compose.yml:redis(Alpine自建) + houtai(webman PHP8) + h5(Nginx)
- houtai.Dockerfile:PHP 8.0 + 阿里云镜像源 + webman.bin entrypoint
- h5.Dockerfile:Nginx + configs.js 环境变量动态重写
- redis.Dockerfile:Alpine + apk 构建,绕过 DockerHub 镜像源问题
- 宿主机 bind-mount:/www/wwwroot/leileiadmin.czchunfang.com(FTP可直接更新程序)

步骤二 积分商城(single-shop-22)
- step2-single-shop/docker-compose.yml:redis + admin-api + front-api + admin-web + h5
- Java Dockerfiles:OpenJDK 17 + --add-opens Spring Boot 2.2.6 兼容

公共配置
- nginx/:四个域名宝塔 Nginx 反代配置(HTTP→HTTPS 301、SSL 终止)
- scripts/:sync-to-server.sh / deploy-step1.sh / remote-up.sh
- DOCKER_DEPLOY.md:完整部署文档

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 17:24:08 +08:00
danaisuiyuan
a89825e23c docs(dashboard): 从 shjjy153 合并 dashboard 相关文档
补充 docs/dashboard/ 目录,包含:
- dashboard-frontend-dev-spec.md
- dashboard-frontend-technical-architecture.md
- boss-dashboard-development-guide.md
- crmeb-front-mysql-remote-connections.md

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 19:30:13 +08:00
danaisuiyuan
ccb70ca10e Merge branch 'sxsy80' into czleilei240 2026-05-16 18:36:18 +08:00
danaisuiyuan
ee32ecc995 feat(dashboard-frontend): 新增 dashboard 前端目录与构建产物
- 引入 dashboard-frontend/dist 静态资源占位(Vite 产物:assets + favicon + index.html + mockServiceWorker.js)
- .gitignore 排除 dashboard-frontend/node_modules 及日志,避免误入库(~304MB)
- src 暂留占位待后续接入

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 18:31:47 +08:00
danaisuiyuan
fd4255d982 docs(xsj33): 迁移说明移入 docs/
与其他迁移文档(byjyw149/czrt6/sxsy80 等)同位,便于统一索引

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-16 18:31:26 +08:00
danaisuiyuan
cf2918cfe2 chore(adminend): 开发环境改用本地 VUE_APP_BASE_API
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:30:26 +08:00
danaisuiyuan
e2d52ba4cc docs(czrt6): 0511 迁移说明写入 16 位用户 uid/id 范围
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 09:25:03 +08:00
danaisuiyuan
d8ad6cde20 feat(integral-external): 新增寄卖外部免认证三件套页面 2026-05-02 06:13:41 +08:00
danaisuiyuan
49900919c6 chore(admin): 恢复 crmeb-admin application-byjyw149.yml
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 04:30:02 +08:00
danaisuiyuan
5cbca4ba76 revert(env,uniapp): 保留 sxsy80 业务合并,恢复 byjyw149 环境与合同 PDF
- 恢复 admin/front application.yml、admin 端 env、WaUserController 合同模板与域名
- 恢复 single_uniapp22miao 中与域名/签约相关的页面与 config
- 移除合并中新增的多客户 profile 与 sxsy80/czcf82 合同 PDF 资源

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-02 04:26:38 +08:00
116 changed files with 9861 additions and 277 deletions

17
.gitignore vendored
View File

@@ -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
View 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.8Java 17 编译器向下兼容 |
| `single-admin-api` / `single-front-api` 运行 | `eclipse-temurin:17-jre-jammy` (Ubuntu 22.04) | **glibc** | 匹配主机 openjdk 17.0.18Spring 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-classLinux kernel 6.1.164/ x86_64 / Docker + BuildKit。
> 所有 `--platform=linux/amd64` 在主机上都是原生执行,**无 QEMU 模拟**;只有在 Apple Silicon 上本地 build 时才会走 QEMU。
> Java 后端最终镜像在 glibc 上运行,与主机 OS 完全同源;这样可避免 Alpine + musl 时验证码 / 中文字体 / POI 个别场景的兼容性问题。
### 1. integral-houtaiPHP 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-apiSpring 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-webVue 管理后台)
- **多阶段构建**
- 构建阶段:`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-h5uni-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.51 | 256512 MBwebman 多 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 模板
每个文件都已生成可直接使用的版本。

View File

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

View File

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

View File

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

View File

@@ -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: '今日抢单用户' },
},
],
};

View File

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

View File

@@ -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'] },

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

View File

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

View 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="卖家ID0=平台)"
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>

View File

@@ -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);
}
// ========================================================================
// 今日抢单用户列表
// ========================================================================
/**
* 今日抢单用户列表(免认证)
* 过滤口径:今日购买总金额 &gt; 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);
}
}

View File

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

View File

@@ -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的密码

View File

@@ -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的密码

View File

@@ -38,7 +38,7 @@ server:
spring:
profiles:
active: sxsy80
active: czleilei240
servlet:
multipart:
max-file-size: 50MB #设置单个文件大小

View File

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

View File

@@ -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 = "团队长 IDwa_users.id为空时按团队长分组返回所有团队")
private Integer leaderId;
@ApiModelProperty(value = "查询日期 Dyyyy-MM-dd缺省为昨天")
private String date;
@ApiModelProperty(value = "是否包含禁用成员,默认 false")
private Boolean includeDisabled = Boolean.FALSE;
@ApiModelProperty(value = "限定成员 ID 列表(前端勾选过滤)")
private List<Integer> memberIds;
}

View File

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

View File

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

View File

@@ -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("查询日期 Dyyyy-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;
}

View File

@@ -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("查询日期 Dyyyy-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;
}

View File

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

View File

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

View File

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

View File

@@ -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的密码

View File

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

View File

@@ -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的密码

View File

@@ -32,7 +32,7 @@ server:
spring:
profiles:
active: sxsy80
active: czleilei240
servlet:
multipart:
max-file-size: 50MB #设置单个文件大小

View File

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

View File

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

View File

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

View File

@@ -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 {
/**
* 分页查询当日已发生买单且金额 &gt; 0 的用户列表。
* 排序:今日购买总金额 DESC相同时按 id DESC。
*
* @param request 搜索条件
* @param pageParamRequest 分页参数
* @return PageInfo&lt;ExternalGrabUserResponse&gt;
*/
PageInfo<ExternalGrabUserResponse> list(ExternalGrabUserRequest request,
PageParamRequest pageParamRequest);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
/**
* 会员分组
*

View File

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

View File

@@ -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 ? "正常" : "禁用";
}
}

View File

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

View File

@@ -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 &gt;= #{todayStart}
AND pay_time &lt;= #{todayEnd}
GROUP BY buyer_id
HAVING SUM(total_money) &gt; 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 &gt;= #{todayStart}
AND pay_time &lt;= #{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 &gt;= #{yesterdayStart}
AND pay_time &lt;= #{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>

View File

@@ -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 &gt;= #{prevStart}
AND pay_time &lt;= #{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 &gt;= #{dStart}
AND pay_time &lt;= #{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 &gt;= #{dStart}
AND pay_time &lt;= #{dEnd}
GROUP BY buyer_id
) buy ON buy.uid = u.id
<where>
<choose>
<when test="leaderId != null">
u.pid = #{leaderId}
</when>
<otherwise>
u.pid &gt; 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>

View File

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

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

View 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,
}
}

View 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/uploadoss 表示走阿里云 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
View File

@@ -0,0 +1,3 @@
# 部署敏感文件不入库
.env
integral-resell/.env

113
deploy/docker/README.md Normal file
View 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 模式则任何依赖都不需要。

View 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

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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
View File

@@ -0,0 +1 @@
server.env

View 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

View 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 "══════════════════════════════════════════════════════"

View 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

View 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 + upyes / no
AUTO_UP=yes

View 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 rsyncmacOS 内置版本 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

View File

@@ -0,0 +1,54 @@
# =============================================================
# 积分商城 管理端 APImiao-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}"]

View 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

View 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

View File

@@ -0,0 +1,54 @@
# =============================================================
# 积分商城 用户端 APImiao-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}"]

View File

@@ -0,0 +1,55 @@
# =============================================================
# 积分商城 用户端 H5uni-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

View 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;
}
}

View 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
View File

@@ -0,0 +1,7 @@
# 忽略证书文件,不入库
*.pem
*.key
*.crt
*.cer
*.p12
*.pfx

View 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

View File

@@ -0,0 +1,2 @@
.env
houtai.env

View 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_PASSWORDRDS 密码、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 | **同上** |

View 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 APIwebman.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

View 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'

View 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"]

View 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 Nginxleilei.czchunfang.com提供对外访问
CRMEB_IMAGE_DIR=/www/wwwroot/leilei.czchunfang.com/crmebimage
# ---------- 前端静态目录bind-mountrsync 更新后立即生效) ----------
# 积分商城 H5uni-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

View File

@@ -0,0 +1 @@
.env

View 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 JARSpring 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/
```
> 更新 JARFTP 替换宿主机文件 → `docker compose --env-file .env restart single-front-api`
### 2. 前端静态文件
```bash
# H5uni-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` | 积分商城 H5uni-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`)完全独立,数据互不干扰。

View 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:
# ---------- RedisAlpine 本地构建,无需拉取 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
# 更新 JARFTP 替换宿主机文件 → 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-mountFTP 更新 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
# 更新 JARFTP 替换宿主机文件 → 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 WebVue 管理后台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-mountrsync 更新宿主机目录后立即生效
- ${SINGLE_ADMIN_WEB_DIR}:/usr/share/nginx/html
depends_on:
- single-admin-api
# ---------- H5 前端uni-app SPANginx 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

View File

@@ -0,0 +1,18 @@
# =============================================================
# RedisAlpine + 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"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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
View 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. 积分商城domainhttps://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
、、、

View 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文件

View 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 页面顺畅查看核心指标、排行和风险。
## 里程碑
- 前端 Mock1-2 天,交付可访问的 H5 经营驾驶舱页面,用于老板手机确认页面和指标。
- 后端实时接口2-4 天,交付 BI 查询接口,用真实数据替换 Mock。
- 联调验收1-2 天,完成指标和明细对账,形成可用 MVP。
- 快照任务3-5 天,交付汇总表、快照表和定时任务。
## 不在第一版范围
- PC 后台完整看板。
- 多公司统一看板。
- 复杂权限隔离。
- 自定义指标配置。
- 老板微信/企微推送。
- 完整 BI 报表编辑器。
- 所有历史日报回填。
这些可在老板确认 MVP 价值后再做。

View 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` |

File diff suppressed because it is too large Load Diff

View 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