20 Commits

Author SHA1 Message Date
danaisuiyuan
0e2fded96e fix: preserve channel code params for miniapp signup 2026-05-27 20:12:08 +08:00
danaisuiyuan
da09739ae1 docs: update syj deployment guide 2026-05-21 06:50:53 +08:00
danaisuiyuan
673a19b3f7 feat: add channel code integral reward 2026-05-21 06:46:18 +08:00
danaisuiyuan
ab65147b82 fix: send verification SMS synchronously 2026-05-20 20:44:52 +08:00
danaisuiyuan
f4e8dab88a feat(uniapp_v2): gate sign-in on 签到广告 article view
Before triggering setSignIntegral, fetch the random required article;
when one is returned, show a fullscreen overlay with title, image, and
rich-text content (loaded via getArticleDetails) plus a 10s countdown
gating the confirm button. Falls through to direct sign-in when the
endpoint returns null or fails.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:48:13 +08:00
danaisuiyuan
b3a1dabf87 feat(api): add sign required-article endpoint
GET /api/sign/required_article returns one random article from the
"签到广告" article category (status=1, is_del=0, hidden=0). Empty
category or no articles returns null so clients can fall through to
direct sign-in. Powers the pre-sign-in reading gate per
docs/project-shaoyaoju/prd-require.md §6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:47:48 +08:00
danaisuiyuan
9559031536 fix(deploy): bash 3.2 compat, sshpass auth, sync admin dist to public/admin
- Replace bash 4.2+ printf %(...)T with date subshell so script runs on
  the macOS-default bash 3.2.
- Add SSH_PASS_FILE / SSH_PASSWORD support via sshpass for environments
  where password auth is the only path.
- After admin npm build, rsync view/admin/dist/ into public/admin/
  (preserving UEditor and favicon) so the project rsync ships the
  admin to the path nginx actually serves from.
- Simplify rsync exclude list to drop view/admin/ wholesale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 15:47:24 +08:00
danaisuiyuan
b1882f1f11 chore(admin): rebrand visible CRMEB references to syj.fsgx.cn
Strip CRMEB/crmeb from user-visible copy on auth, kefu, supplier login,
setting guides, storage docs, mobile page mocks, and system maintain
file editor. Replace external crmeb.com URLs with syj.fsgx.cn (sms
console iframe and license-protected file headers/comments left
untouched). Default brand_name on wechat membership card switches to
"芍药居".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:06:19 +08:00
danaisuiyuan
c96b074dda feat(api): strip CRMEB-PRO prefix from getVersion response
The admin auth page rendered the raw version string from the .version
file (e.g. "CRMEB-PRO v3.5.1"); strip the vendor brand prefix at the
controller boundary so display matches the rebranded UI without
touching internal version detection.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 12:06:19 +08:00
danaisuiyuan
b9d70438b1 feat(syj): seed agent levels per prd-require.md
Re-init eb_agent_level (4 tiers: 普通会员/业务主管/业务经理/业务总监)
and eb_agent_level_task (3 upgrade tasks: 直推 3 / 团队 30 单 / 团队
100 单) based on docs/project-shaoyaoju/prd-require.md §7.2.

TRUNCATE-based; orphan cleanup of eb_agent_level_task_record left
commented for the operator to opt in.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 10:30:51 +08:00
danaisuiyuan
b8643e085f feat: add syj variant automated deploy script
One-shot release pipeline for syj-shop variant: admin build -> rsync to
8.140.50.89 -> remote .env switch + cache clear + Swoole reload ->
healthcheck with auto-rollback on failure. Includes operations manual
under docs/project-syj/.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 09:13:29 +08:00
danaisuiyuan
79436c011c Fix uniapp v2 login and sass compatibility 2026-05-03 15:56:26 +08:00
danaisuiyuan
8472bb4639 Merge remote-tracking branch 'origin/syj-bypass-auth' into syj-bypass-auth 2026-05-03 14:54:19 +08:00
danaisuiyuan
00318cf247 Merge feature fsgx uniapp v2 updates 2026-05-03 14:52:04 +08:00
apple
0e07a65e3f feat: add syj promote workflow 2026-05-03 14:44:12 +08:00
danaisuiyuan
12c2431d4e docs: add shaoyaoju secondary development guide 2026-05-02 01:59:59 +08:00
apple
f71efbfd40 add fsgx deployment env docs 2026-05-01 22:21:36 +08:00
apple
bb714a598b fix admin route html response 2026-05-01 20:54:27 +08:00
apple
b15ed12309 Merge remote-tracking branch 'origin/hjf-bypass-auth' into fsgx-bypass-auth 2026-05-01 20:10:02 +08:00
panchengyong
364d5333d7 fix(hjf): 保单商品多份购买分润计算全链路修复
- 公排入队:按件数拆分为 N 条独立记录,每条单份金额,逐条触发退款检测
- 周期佣金:位次统计改为按报单商品总件数(cart_num 之和),而非订单数
- 分销等级任务:type 6/7 订单数统计改为按 cart_num 累计保单商品份数
- 推荐返佣与积分奖励:验证 cart_num 倍乘逻辑正确

Made-with: Cursor
2026-04-06 02:40:01 +08:00
158 changed files with 6716 additions and 247 deletions

342
docs/deploy-hjf.md Normal file
View File

@@ -0,0 +1,342 @@
# HJF项目代码更新发布部署文档
本文档用于在黄精粉线上环境已经完成初始化部署后进行代码修改后的更新发布。该环境不是首次部署场景服务器目录、Nginx、PHP、Swoole 和宝塔进程守护均已配置并验证正常;发布流程只处理代码同步、管理后台构建产物更新、缓存清理和 Swoole 进程重启。
## 1. 部署信息
| 项目 | 值 |
| --- | --- |
| 服务器 | `182.92.142.158` |
| SSH 用户 | `root` |
| SSH 密码 | 使用 CI/CD 密钥变量保存,例如 `DEPLOY_SSH_PASSWORD` |
| 远程部署目录 | `/www/wwwroot/hjf.suzhouyuqi.com` |
| 绑定域名 | `hjf.fsgx.cn` |
| 管理后台 | `https://hjf.fsgx.cn/admin` |
| 后端入口目录 | `/www/wwwroot/hjf.suzhouyuqi.com/public` |
| 管理后台静态目录 | `/www/wwwroot/hjf.suzhouyuqi.com/public/admin` |
| 面板环境 | 宝塔面板 |
| Nginx 状态 | 已配置并正常运行 |
| PHP / Swoole 状态 | 已配置并正常运行 |
| Swoole 管理方式 | 宝塔进程守护 |
不要把 SSH 密码写入 Git 仓库。自动化部署建议使用 SSH key如果必须用密码放在 CI/CD Secret 中,并通过 `sshpass -p "$DEPLOY_SSH_PASSWORD"` 注入。
## 2. 发布边界
本流程是更新部署,不包含以下首次部署事项:
- 不初始化服务器目录。
- 不安装 Nginx、PHP、Composer、Node.js、Swoole 扩展。
- 不创建站点、不重新绑定域名。
- 不重新配置 SSL。
- 不重建数据库。
- 不覆盖线上 `.env`
- 不修改宝塔站点和反向代理配置。
每次发布只做:
- 拉取指定 Git 分支代码。
- 安装 / 校验 PHP 依赖。
- 构建管理后台 `view/admin`
- 同步后端代码到 `/www/wwwroot/hjf.suzhouyuqi.com`
- 同步管理后台静态产物到 `public/admin`
- 清理 ThinkPHP 缓存。
- 通过宝塔进程守护重启 Swoole 服务。
## 3. 本地准备
在项目根目录执行:
```bash
cd /path/to/huangjingfen
git fetch origin
git checkout fsgx-bypass-auth
git pull --ff-only origin fsgx-bypass-auth
```
确认 PHP 依赖存在:
```bash
cd pro_v3.5.1
composer install --no-dev --prefer-dist --optimize-autoloader
```
构建管理后台:
```bash
cd pro_v3.5.1/view/admin
npm install
npm run build
```
构建产物路径:
```text
pro_v3.5.1/view/admin/dist/
```
## 4. 推荐自动化变量
CI/CD 中建议配置:
```bash
DEPLOY_HOST=182.92.142.158
DEPLOY_USER=root
DEPLOY_PATH=/www/wwwroot/hjf.suzhouyuqi.com
DEPLOY_DOMAIN=hjf.fsgx.cn
DEPLOY_SSH_PASSWORD=******
```
如果使用 SSH key则不需要 `DEPLOY_SSH_PASSWORD`
## 5. 服务器备份
虽然不是首次部署,但每次更新发布前仍建议备份当前线上目录:
```bash
ssh ${DEPLOY_USER}@${DEPLOY_HOST} "\
mkdir -p /www/backup && \
tar -czf /www/backup/hjf_fsgx_$(date +%Y%m%d_%H%M%S).tar.gz \
-C /www/wwwroot hjf.suzhouyuqi.com"
```
使用密码自动化时:
```bash
sshpass -p "$DEPLOY_SSH_PASSWORD" ssh -o StrictHostKeyChecking=no ${DEPLOY_USER}@${DEPLOY_HOST} "\
mkdir -p /www/backup && \
tar -czf /www/backup/hjf_fsgx_$(date +%Y%m%d_%H%M%S).tar.gz \
-C /www/wwwroot hjf.suzhouyuqi.com"
```
## 6. 上传后端代码
从仓库根目录执行:
```bash
rsync -avz --delete \
--exclude='.git' \
--exclude='.DS_Store' \
--exclude='.env' \
--exclude='.env-*' \
--exclude='runtime/cache' \
--exclude='runtime/log' \
--exclude='runtime/temp' \
--exclude='public/uploads' \
--exclude='view/admin/node_modules' \
--exclude='view/admin/dist' \
--exclude='view/uniapp/node_modules' \
pro_v3.5.1/ \
${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/
```
使用密码自动化时,在 `rsync` 中指定 SSH
```bash
rsync -avz --delete \
-e "sshpass -p '$DEPLOY_SSH_PASSWORD' ssh -o StrictHostKeyChecking=no" \
--exclude='.git' \
--exclude='.DS_Store' \
--exclude='.env' \
--exclude='.env-*' \
--exclude='runtime/cache' \
--exclude='runtime/log' \
--exclude='runtime/temp' \
--exclude='public/uploads' \
--exclude='view/admin/node_modules' \
--exclude='view/admin/dist' \
--exclude='view/uniapp/node_modules' \
pro_v3.5.1/ \
${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/
```
线上 `.env` 不建议由代码仓库覆盖,应在服务器保留并单独维护。
## 7. 上传管理后台
```bash
rsync -avz --delete \
pro_v3.5.1/view/admin/dist/ \
${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/public/admin/
```
使用密码自动化时:
```bash
rsync -avz --delete \
-e "sshpass -p '$DEPLOY_SSH_PASSWORD' ssh -o StrictHostKeyChecking=no" \
pro_v3.5.1/view/admin/dist/ \
${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/public/admin/
```
## 8. 远程收尾命令
```bash
ssh ${DEPLOY_USER}@${DEPLOY_HOST} "\
cd ${DEPLOY_PATH} && \
php think clear && \
chown -R www:www runtime public/uploads public/admin 2>/dev/null || true && \
chmod -R 755 runtime public/admin 2>/dev/null || true"
```
重启后端常驻服务。按服务器实际进程管理方式选择其一:
```bash
# 宝塔进程守护
# 推荐在宝塔面板中重启对应的 Swoole 进程守护任务。
# 如果宝塔进程守护任务配置了命令行启动 php think swoole可在面板点击“重启”。
# 命令行兜底,仅当确认不会和宝塔进程守护冲突时使用:
pkill -f 'php think swoole' || true
cd /www/wwwroot/hjf.suzhouyuqi.com
nohup php think swoole > runtime/swoole.log 2>&1 &
```
当前环境使用 SwoolePHP-FPM 不是主要入口。只有在站点同时依赖 PHP-FPM 时才需要重载:
```bash
systemctl reload php-fpm || true
```
## 9. Nginx 配置要点
当前服务器 Nginx 已经由宝塔配置并运行正常,更新发布一般不需要修改 Nginx。下面只作为检查项和故障排查参考。
站点根目录应指向 `public`
```nginx
server {
listen 80;
listen 443 ssl http2;
server_name hjf.fsgx.cn;
root /www/wwwroot/hjf.suzhouyuqi.com/public;
index index.html index.php;
location /admin/ {
try_files $uri $uri/ /admin/index.html;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
# 按服务器现有方式转发到 Swoole 或 PHP-FPM
}
}
```
如果管理后台访问白屏,重点检查:
```text
/www/wwwroot/hjf.suzhouyuqi.com/public/admin/index.html
/www/wwwroot/hjf.suzhouyuqi.com/public/admin/static/
```
## 10. 验证清单
部署完成后执行:
```bash
curl -I https://hjf.fsgx.cn/admin
curl -I https://hjf.fsgx.cn/admin/
curl -I https://hjf.fsgx.cn/admin/index.html
```
浏览器验证:
```text
https://hjf.fsgx.cn/admin
```
后台登录后重点验证:
- 商品列表可打开。
- 商品 `type_header` 接口不再返回 `config/auth.php:82`
- 系统菜单、首页统计、订单列表、用户列表可以正常请求。
- 静态资源没有返回 HTML浏览器控制台无 `Unexpected token '<'`
## 11. 一键更新部署脚本模板
该脚本面向“代码更新发布”,默认不修改宝塔站点配置、不覆盖线上 `.env`。Swoole 重启优先通过宝塔面板完成;脚本末尾保留 `supervisorctl`/命令行兜底示例,需要按服务器实际进程守护名称调整。
```bash
#!/usr/bin/env bash
set -euo pipefail
DEPLOY_HOST="${DEPLOY_HOST:-182.92.142.158}"
DEPLOY_USER="${DEPLOY_USER:-root}"
DEPLOY_PATH="${DEPLOY_PATH:-/www/wwwroot/hjf.suzhouyuqi.com}"
SSH_CMD="ssh -o StrictHostKeyChecking=no"
if [ -n "${DEPLOY_SSH_PASSWORD:-}" ]; then
SSH_CMD="sshpass -p ${DEPLOY_SSH_PASSWORD} ssh -o StrictHostKeyChecking=no"
RSYNC_SSH="sshpass -p ${DEPLOY_SSH_PASSWORD} ssh -o StrictHostKeyChecking=no"
else
RSYNC_SSH="ssh -o StrictHostKeyChecking=no"
fi
git fetch origin
git checkout fsgx-bypass-auth
git pull --ff-only origin fsgx-bypass-auth
cd pro_v3.5.1
composer install --no-dev --prefer-dist --optimize-autoloader
cd view/admin
npm install
npm run build
cd ../../..
$SSH_CMD ${DEPLOY_USER}@${DEPLOY_HOST} "\
mkdir -p /www/backup && \
tar -czf /www/backup/hjf_fsgx_\$(date +%Y%m%d_%H%M%S).tar.gz \
-C /www/wwwroot hjf.suzhouyuqi.com"
rsync -avz --delete -e "$RSYNC_SSH" \
--exclude='.git' \
--exclude='.DS_Store' \
--exclude='.env' \
--exclude='.env-*' \
--exclude='runtime/cache' \
--exclude='runtime/log' \
--exclude='runtime/temp' \
--exclude='public/uploads' \
--exclude='view/admin/node_modules' \
--exclude='view/admin/dist' \
--exclude='view/uniapp/node_modules' \
pro_v3.5.1/ \
${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/
rsync -avz --delete -e "$RSYNC_SSH" \
pro_v3.5.1/view/admin/dist/ \
${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PATH}/public/admin/
$SSH_CMD ${DEPLOY_USER}@${DEPLOY_HOST} "\
cd ${DEPLOY_PATH} && \
php think clear && \
chown -R www:www runtime public/uploads public/admin 2>/dev/null || true && \
chmod -R 755 runtime public/admin 2>/dev/null || true && \
echo '请通过宝塔进程守护重启 Swoole 服务'"
curl -I https://hjf.fsgx.cn/admin
```
## 12. 回滚
查看备份:
```bash
ssh ${DEPLOY_USER}@${DEPLOY_HOST} "ls -lh /www/backup | grep hjf_fsgx"
```
回滚示例:
```bash
ssh ${DEPLOY_USER}@${DEPLOY_HOST} "\
cd /www/wwwroot && \
mv hjf.suzhouyuqi.com hjf.suzhouyuqi.com.bad.$(date +%Y%m%d_%H%M%S) && \
mkdir -p hjf.suzhouyuqi.com && \
tar -xzf /www/backup/备份文件名.tar.gz -C /www/wwwroot && \
supervisorctl restart hjfshop-swoole || true"
```

View File

@@ -1,4 +1,11 @@
# 测试问题
## 提现页面pages/users/user_cash/index
- 选择支付宝提现点击“立即提现”提交后跳转到pages/users/user_spread_money/index?type=1
- **已修复**选择支付宝提现点击“立即提现”提交后跳转到pages/users/user_spread_money/index?type=1
## 保单商品一次购买多份下的分润计算
- **已修复**推荐返佣按照一次购买多单的分润计算如购买5份则返佣5份而不是1份。
- **已修复**积分奖励同上
- **已修复**分销等级任务中订单数统计改为按购买保单商品份数计算如购买5份保单商品则订单数统计为5份而不是1份。
- **已修复**公排入队按件数拆分为 N 条独立记录PRD §3.1.2),每条单份金额,逐条触发退款检测
- **已修复**周期佣金位次统计改为按报单商品总件数(而非订单数),确保跨订单轮巡位次连续

View File

@@ -0,0 +1,787 @@
# 芍药居小程序 · 产品需求文档PRDV1.0
> 技术底座CRMEB Pro v3.5 + fsgx 分支二次开发成果
> 文档日期2026-05-02
> 文档状态:当前分支需求草案
> 核心原则:基于 fsgx 项目代码做最小修改,最大程度复用现有 CRMEB 与 fsgx 能力
> 业务需求来源:`docs/project-shaoyaoju/prd-require.md`
> 配套差异文档:`docs/project-shaoyaoju/syj-fsgx-diff.md`
---
## 1. 文档说明
### 1.1 文档目的
本文档基于 `docs/project-shaoyaoju/prd-require.md` 重新梳理后的业务口径,并结合当前 `fsgx-bypass-auth` / `syj-bypass-auth` 分支代码能力重新整理。
`prd-require.md` 已将需求拆分为“推四免一主线规则、签到规则、补充等级与积分规则、奖励使用限制、招商合作、待确认问题”。待确认问题已由业务侧补充确认,并追加新的落地反馈:每满 4333 元生成的推广任务,具体指“推四免一的有效推广任务维度”,需等价为当前代码中的 1 个报单商品订单触发基准,用于复用现有分销奖金、积分奖励和分销等级升级任务。
本项目不从零开发新商城,而是在 fsgx 现有代码基础上做最小差异改造:商品、订单、支付、推荐关系、分销奖金、积分奖励、分销等级、佣金流水、提现、分销员管理、签到等现有能力继续复用;仅新增或改造芍药居必须具备的“消费累计生成推广任务、任务等价触发现有奖励链路、任务进度、提前兑现、任务审计”能力。
### 1.2 设计原则
| 原则 | 说明 |
|---|---|
| 最大复用 | 复用 CRMEB 商品、订单、支付、用户、推广关系、佣金、提现、签到、文章、后台权限 |
| 延续 fsgx | 延续 fsgx 对 `is_queue_goods` 报单商品、周期佣金、返佣配置、分销等级、积分奖励、积分日志等已有改造 |
| 主线优先 | 首版只以“4333 元生成任务、推荐 4 单、10/20/30/40、完成 4 单不扣 7%”作为确定口径 |
| 任务等价订单 | 每 1 个推四免一有效推广任务等价当前代码 1 个报单商品订单,用于触发分销奖金、积分奖励和分销等级任务 |
| 最小新增 | 新增代码只覆盖“消费累计与推广任务适配层”不可复用部分 |
| 配置优先 | 能通过现有配置完成的,不新增表单和接口 |
| 首版隔离 | 推三免一、20/30/50、奖励仅消费、招商合作等口径不进入首版 |
| 不破坏旧链路 | 保留 fsgx 分销奖金、积分奖励、分销等级和等级任务配置,只调整触发基准 |
| 可追溯 | 所有任务、推荐订单、奖励、税费、人工调整都有流水 |
### 1.3 文档范围
- 芍药居用户端小程序需求
- 管理后台需求
- 推四免一核心业务规则
- 与 fsgx 当前代码能力的复用关系
- 最小改造范围
- 分销奖金、积分奖励、分销等级任务的复用口径
- 奖励用途、招商合作的首版取舍
- 数据与接口建议
- 验收标准
### 1.4 不在本期范围
- 重写 CRMEB 商品、订单、支付、用户体系
- 重写 fsgx 已有分销等级、等级任务、分销奖金、积分奖励、佣金流水、提现流程
- 新建独立于当前代码之外的会员等级/积分体系
- 公排池/排队退款机制
- 将补充说明中的“推三免一”或 20%、30%、50% 作为首版结算规则
- 奖励仅限平台内消费、下单黄金等非现金账户限制能力
- 代理商招募、19800 元招商包、加权分红、股东权益、字画赠品等招商合作能力
- 全量视觉改版
- 独立客服、供应商、ERP 等非核心链路改造
### 1.5 首版业务口径取舍
| 需求点 | `prd-require.md` 口径 | 首版 PRD 处理 |
|---|---|---|
| 主推广模式 | 推四免一为主线,同时补充内容出现推三免一 | 已确认首版固定推四免一,推三免一作为不进入首版的旧口径 |
| 奖励比例 | 主线 10%、20%、30%、40%;补充内容出现 20%、30%、50% | 首版固定 10%、20%、30%、40% |
| 税费/手续费 | 原整理稿默认奖励结算扣 7% | 已确认:完成 4 单不扣 7%;提前兑现仍按 7% 扣费 |
| 奖励用途 | 主线写发放/提现,补充内容写不能提现、可下单黄金 | 已确认采纳建议:首版按 CRMEB 佣金账户入账并复用提现 |
| 分销奖金和积分奖励 | 新反馈要求推四免一有效推广任务等价当前报单商品订单 | 复用当前代码逻辑,由推四免一有效推广任务触发现有分销奖金和积分奖励 |
| 会员等级 | 新反馈要求复用当前代码分销等级 | 复用当前分销等级;升级条件使用后台配置的分销等级任务 |
| 招商合作 | 补充内容包含 49 名代理商、19800 元产品、分红、股东权益、字画 | 不纳入首版功能,作为独立合规/运营方案 |
| 有效商品/套餐包 | 已确认任意商品订单都是有效订单 | 新增商品默认 `is_queue_goods=1`,订单默认参与待推广金额累计与推荐计数 |
---
## 2. 当前代码基础说明
### 2.1 当前分支基础
芍药居新分支 `syj-bypass-auth` 基于 `fsgx-bypass-auth` 最新代码创建,当前 fsgx 代码已具备以下能力:
| 能力 | 当前代码/模块 | 芍药居策略 |
|---|---|---|
| 商品、购物车、订单、支付 | CRMEB 原生模块 | 直接复用 |
| 推荐关系绑定 | `spread_uid``spread` 参数 | 直接复用 |
| 分销员/推广员 | CRMEB `agent` 模块 | 直接复用 |
| 分销奖金/周期佣金配置 | `brokerage_cycle_count``brokerage_cycle_rates` | 直接复用现有发放逻辑;触发基准由报单商品订单扩展为推广任务 |
| 积分奖励 | fsgx 积分奖励、积分日志、积分释放相关能力 | 直接复用现有逻辑;推四免一有效推广任务等价 1 个报单商品订单作为触发基准 |
| 分销等级 | 当前代码分销等级和后台等级任务配置 | 直接复用;升级任务使用后台配置的分销等级任务 |
| 返佣范围 | `brokerage_scope` | 默认所有新增商品参与,使用 `is_queue_goods` 保留排除能力 |
| 返佣时机 | `brokerage_timing` | 复用当前代码逻辑和后台配置,不为芍药居单独固定节点 |
| 参与规则商品标记 | `is_queue_goods` | 新增商品默认设为 1继续作为任务消费与推荐有效订单识别字段 |
| 佣金流水 | CRMEB `UserBrokerage` / `UserBill` | 复用为奖励结算流水 |
| 提现 | CRMEB `extract` | 复用提现流程;完成任务不扣 7%,提前兑现扣费在任务结算时处理 |
| 用户资产 | `/api/hjf/assets/overview` | 可复用并改文案/字段 |
| 后台分销管理 | `/admin/agent/*` | 直接复用 |
| 后台积分日志 | `/admin/hjf/points/log` | 直接复用,用于查看推广任务触发的积分奖励 |
| 签到 | CRMEB `sign/*` 接口与 `user_sgin` 页面 | 复用,新增文章浏览 10 秒前置条件 |
### 2.2 需要最小新增的能力
fsgx 当前是“报单商品订单触发分销奖金、积分奖励和分销等级任务”。芍药居新增的核心差异是:用户自己的消费每满 4333 元先生成 1 个推四免一有效推广任务,该任务在系统侧等价当前代码的 1 个报单商品订单,作为现有分销奖金、积分奖励、分销等级任务的触发基准;同时,该任务继续承载推四免一进度、完成结算和提前兑现。
新增能力最小集合:
| 新增能力 | 原因 |
|---|---|
| 待推广金额累计 | fsgx 没有“用户自身消费累计余额”概念 |
| 推广任务 | fsgx 没有按 4333 元消费自动生成的任务实体 |
| 任务等价订单适配 | 需要将 1 个推广任务映射为当前代码 1 个报单商品订单触发基准 |
| 任务推荐进度 | fsgx 只有周期佣金,没有任务维度 |
| 提前兑现 | fsgx 订单佣金即时/确认后发放,不支持任务中途关闭结算 |
| 任务结算流水 | 需要记录任务完成、提前兑现、税费、到账和关联奖励流水 |
| 任务后台 | 运营要按任务排查进度、订单和结算 |
---
## 3. 产品概述
### 3.1 产品定位
| 维度 | 描述 |
|---|---|
| 产品形态 | 微信小程序 + PC 管理后台 |
| 基础代码 | fsgx 项目代码 + CRMEB Pro v3.5 |
| 核心商品 | 芍药居新增商品默认参与规则,任务基准金额 4333 元 |
| 核心模式 | 消费生成推四免一有效推广任务 + 任务等价报单商品订单触发现有奖励链路 + 推荐 4 单完成任务 |
| 核心体验 | 用户能看到待推广金额、任务进度、可兑现奖励和到账金额 |
| 运营目标 | 在尽量少改代码的前提下,快速上线推四免一规则 |
### 3.2 用户角色
| 角色 | 诉求 | 主要页面/能力 |
|---|---|---|
| 普通会员 | 购买商品并累计任务资格 | 商品、订单、个人中心 |
| 推广会员 | 查看任务、分享、兑现奖励、获得积分/等级成长 | 我的推广任务、任务详情、分享海报、资产/积分记录 |
| 被推荐用户 | 通过分享进入并下单 | 商品详情、下单支付 |
| 运营人员 | 配置规则、查看任务和异常、维护分销等级任务 | 推广任务管理、规则配置、订单管理、分销等级配置 |
| 财务人员 | 核对奖励、税费和提现 | 结算流水、提现审核、导出 |
补充等级会员、代理商、招商合作方已确认不纳入首版,首版不新增独立页面和结算流程。
---
## 4. 核心业务规则
### 4.0 术语边界
本文档中的“推广任务”如无特别说明,均特指“推四免一有效推广任务维度”:
- 该任务由用户有效消费累计每满 4333 元自动生成。
- 该任务用于记录推四免一的 4 单推荐进度、完成结算和提前兑现。
- 该任务在系统侧等价当前代码 1 个报单商品订单,用于触发现有分销奖金、积分奖励和分销等级任务。
- 该任务不是后台配置的“分销等级任务”。分销等级任务仍复用当前代码后台配置,推广任务只是计入该等级任务统计的业务来源之一。
### 4.1 参与规则商品
芍药居继续复用 fsgx 的 `is_queue_goods` 字段:
- `is_queue_goods = 1`:参与规则商品,参与待推广金额累计和推荐有效订单判断。
- `is_queue_goods = 0`:普通商品,不参与推四免一任务。
- 新增的所有商品默认设置 `is_queue_goods = 1`,因此新增商品订单默认都是有效订单。
- 若运营后续需要排除个别商品,可将该商品设置为 `is_queue_goods = 0`
默认规则:
| 项 | 规则 |
|---|---|
| 任务基准金额 | 4333 元 |
| 任务目标单数 | 4 单 |
| 推荐奖励比例 | 10%、20%、30%、40% |
| 有效订单节点 | 待推广金额累计默认确认收货后;推荐进度计入复用后台 `brokerage_timing``on_pay` 支付成功后计入,`on_confirm` 确认收货后计入 |
| 分销奖金触发 | 每 1 个推四免一有效推广任务等价当前代码 1 个报单商品订单,复用现有分销奖金逻辑 |
| 积分奖励触发 | 每 1 个推四免一有效推广任务等价当前代码 1 个报单商品订单,复用现有积分奖励逻辑 |
| 会员等级 | 复用当前分销等级,升级任务由后台分销等级任务配置决定 |
| 税费/手续费 | 完成 4 单不扣 7%1-3 单提前兑现扣 7% |
| 奖励入账 | 已确认首版进入 CRMEB 佣金账户并复用提现链路 |
### 4.2 待推广金额累计
用户购买参与规则商品后,订单确认收货时,将订单实际支付金额计入该用户的待推广金额。由于新增商品默认 `is_queue_goods = 1`,首版新增商品订单默认都参与累计。
计入口径:
| 金额项 | 是否计入 |
|---|---|
| 微信/支付宝实际支付 | 计入 |
| 余额支付 | 计入,视为实际支付 |
| 积分抵扣 | 不计入 |
| 优惠券抵扣 | 默认不计入,若运营确认不同口径需单独配置 |
| 运费 | 默认不计入,除非运营另行确认 |
| 退款金额 | 需要回滚或进入异常处理 |
示例:
| 订单金额 | 优惠/积分抵扣 | 实际支付 | 计入待推广金额 |
|---:|---:|---:|---:|
| 5000 元 | 500 元 | 4500 元 | 4500 元 |
| 4333 元 | 0 元 | 4333 元 | 4333 元 |
| 3000 元 | 3000 元 | 0 元 | 0 元 |
### 4.3 推四免一有效推广任务生成
当用户待推广金额大于或等于 4333 元时,系统自动生成 1 个推四免一有效推广任务。
规则:
1. 每满 4333 元生成 1 个推四免一有效推广任务。
2. 生成任务后,待推广金额扣除 4333 元。
3. 剩余金额保留,继续累计。
4. 一次确认收货金额可生成多个任务。
5. 多个推四免一有效推广任务可同时进行。
6. 每生成 1 个推四免一有效推广任务,系统侧按“当前代码 1 个报单商品订单触发基准”处理,用于触发现有分销奖金、积分奖励和分销等级任务。
7. 推四免一有效推广任务不要求新建真实商城订单;实现上可采用任务实体 + 触发适配服务,确保现有奖励逻辑拿到等价的订单金额、用户、上级关系和来源流水。
示例:
| 待推广金额 | 生成任务数 | 扣除金额 | 剩余待推广金额 |
|---:|---:|---:|---:|
| 4333 元 | 1 | 4333 元 | 0 元 |
| 5200 元 | 1 | 4333 元 | 867 元 |
| 10000 元 | 2 | 8666 元 | 1334 元 |
任务触发现有奖励链路的建议口径:
| 触发项 | 口径 |
|---|---|
| 触发金额 | 4333 元/任务 |
| 触发用户 | 推四免一有效推广任务所属用户 |
| 上级关系 | 复用当前 `spread_uid` / `spread` 推荐关系 |
| 触发商品类型 | 等价当前代码的报单商品订单 |
| 分销奖金 | 复用当前分销奖金/佣金计算与流水逻辑 |
| 积分奖励 | 复用当前积分奖励、积分日志、积分释放相关逻辑 |
| 分销等级任务 | 复用后台配置的分销等级升级任务 |
| 追溯来源 | 奖励/积分/等级任务流水需关联推四免一有效推广任务 ID`syj_promote_task_id`)或任务编号 |
### 4.4 推荐有效订单
推荐有效订单继续复用 CRMEB 推广关系。
默认有效条件:
1. 被推荐用户的 `spread_uid` 为任务所属用户。
2. 被推荐订单包含参与规则商品;首版新增商品默认都是参与规则商品。
3. 被推荐订单达到后台返佣设置中的佣金发放时机:`brokerage_timing=on_pay` 时支付成功后计入,`brokerage_timing=on_confirm` 时确认收货后计入。
4. 订单未退款、未撤单、未被风控标记无效。
推荐计入规则:
- 只计入推荐人当前最早创建的进行中任务。
- 当前任务满 4 单后,后续推荐订单计入下一个进行中任务。
- 推荐人没有进行中任务时,推荐订单不产生任务进度;可只保留 CRMEB 原始推荐关系和订单记录。
### 4.5 奖励计算
单个任务基准金额为 4333 元,推荐成功单数按阶梯比例累计奖励。
| 成功单数 | 本档比例 | 本档奖励 | 累计比例 | 累计税前奖励 |
|---:|---:|---:|---:|---:|
| 第 1 单 | 10% | 433.30 元 | 10% | 433.30 元 |
| 第 2 单 | 20% | 866.60 元 | 30% | 1299.90 元 |
| 第 3 单 | 30% | 1299.90 元 | 60% | 2599.80 元 |
| 第 4 单 | 40% | 1733.20 元 | 100% | 4333.00 元 |
结算规则:
```text
税前奖励 = 4333 × 累计奖励比例
完成 4 单:到账金额 = 4333税费/手续费 = 0
提前兑现 1-3 单:税费/手续费 = 税前奖励 × 7%,到账金额 = 税前奖励 - 税费/手续费
```
金额精度:
- 存储使用分。
- 展示保留 2 位小数。
- 提前兑现税费和到账金额统一四舍五入到分。
### 4.6 任务完成
当任务推荐成功达到 4 单:
1. 任务状态变为已完成。
2. 生成任务结算记录。
3. 税前奖励为 4333 元。
4. 完成 4 单不扣 7%。
5. 到账金额为 4333 元。
6. 奖励入账到 CRMEB 佣金账户,并复用现有提现链路。
### 4.7 提前兑现
用户可在任务未完成 4 单时提前兑现。
规则:
| 条件 | 处理 |
|---|---|
| 0 单成功 | 不允许提前兑现 |
| 1-3 单成功 | 可提交提前兑现申请,按当前累计比例计算税前奖励 |
| 审核方式 | 后台审核,复用当前代码已有审核能力 |
| 审核通过后 | 扣除 7% 税费/手续费后发放 |
| 提前兑现后 | 审核通过并结算后任务关闭,不再接收推荐订单 |
| 已占用 4333 元 | 不退回待推广金额 |
提前兑现示例:
| 成功单数 | 税前奖励 | 税费 7% | 到账金额 |
|---:|---:|---:|---:|
| 1 | 433.30 元 | 30.33 元 | 402.97 元 |
| 2 | 1299.90 元 | 90.99 元 | 1208.91 元 |
| 3 | 2599.80 元 | 181.99 元 | 2417.81 元 |
### 4.8 上级任务联动
原始需求提出:“生成推广任务时,系统检查直属上级是否有进行中的任务;如果有,上级推广成功数量 +1。”
为保持最小改造,建议将该规则落到同一套任务计数服务中:
1. 用户生成新任务时,读取该用户直属上级。
2. 若上级存在进行中任务,则为上级最早任务增加 1 个成功来源。
3. 来源类型标记为 `task_created`,与订单推荐来源 `order_pay` / `order_confirm` 区分;订单推荐来源由后台 `brokerage_timing` 决定。
4. 若上级任务因此达到 4 单,则触发完成结算。
注意:该规则与“推荐有效订单”并存,可能导致上级同时因下级任务创建和下级订单确认获得进度。上线前需运营确认是否二选一。默认按原始需求保留“下级生成任务也可计入上级任务”的能力,但后台配置可关闭。
### 4.9 签到规则
签到复用 CRMEB 原签到模块,新增“先浏览文章 10 秒”的门槛。
规则:
1. 用户进入签到页时先展示产品功效文章。
2. 浏览满 10 秒后,签到按钮解锁。
3. 每个自然日只允许签到一次。
4. 原 CRMEB 签到奖励继续可配。
5. 若运营不需要签到奖励,可配置为 0仅记录签到行为。
### 4.10 分销等级与积分奖励复用
根据最新反馈,会员等级和积分奖励不再作为后续隔离能力,而是复用当前代码:
| 能力 | 首版处理 | 说明 |
|---|---|---|
| 会员等级 | 复用当前代码的分销等级 | 不新增独立等级体系 |
| 升级任务 | 复用后台配置的分销等级任务 | 推四免一有效推广任务等价报单商品订单后,进入现有等级任务统计 |
| 积分奖励 | 复用当前积分奖励逻辑 | 每个推四免一有效推广任务按 1 个报单商品订单触发积分奖励 |
| 积分日志 | 复用当前积分日志 | 日志需能追溯到推广任务 |
| 积分释放 | 如当前代码已启用,则复用现有释放逻辑 | 是否释放、释放比例、释放周期以现有配置为准 |
| 平级/级差规则 | 复用当前分销等级和积分奖励规则 | 不单独开发新规则 |
| 奖励不能提现、可下单黄金 | 已确认采纳 CRMEB 佣金账户和提现链路,不实现奖励用途限制 | 如需限制用途,后续另立需求 |
| 49 名代理商与 19800 元招商包 | 已确认不进入首版小程序功能 | 后续需独立招商合同、合规审核和后台管理方案 |
---
## 5. 用户端需求
### 5.1 首页与商品
| 功能 | 复用/改造 | 说明 |
|---|---|---|
| 首页 DIY | 直接复用 | 后台配置芍药居素材、参与规则商品入口 |
| 商品列表 | 直接复用 | 保留 CRMEB 列表能力 |
| 商品详情 | 小改文案 | 新增商品默认展示“参与推四免一任务”提示 |
| 下单支付 | 直接复用 | 保留 CRMEB 下单、支付、余额、优惠券 |
| 订单确认收货 | 小改后端 | 确认收货后触发待推广金额累计 |
### 5.2 我的推广任务
新增用户端页面,建议路径:
```text
pro_v3.5.1/view/uniapp/pages/syj/promote_task/index.vue
pro_v3.5.1/view/uniapp/pages/syj/promote_task/detail.vue
```
页面能力:
| 模块 | 字段/行为 |
|---|---|
| 资产摘要 | 待推广金额、已生成任务数、进行中任务数、累计奖励、积分概览、分销等级 |
| 任务列表 | 任务编号、进度、当前税前奖励、预计到账、状态 |
| 任务详情 | 4 档进度、推荐订单来源、结算预览、已触发的分销奖金/积分奖励 |
| 提前兑现 | 1-3 单时可提交申请0 单置灰,提交后等待后台审核 |
| 分享入口 | 复用 CRMEB 分享参数,生成带 `spread` 的分享链接/海报 |
### 5.3 个人中心入口
在现有个人中心/推广中心增加入口:
| 入口 | 策略 |
|---|---|
| 我的推广任务 | 新增入口,指向芍药居任务页 |
| 推广中心 | 复用 CRMEB 原推广中心,文案按芍药居调整 |
| 佣金明细 | 复用 CRMEB 佣金明细 |
| 积分明细 | 复用当前积分日志页面或入口 |
| 分销等级 | 复用当前分销等级展示 |
| 提现 | 复用 CRMEB 提现 |
### 5.4 签到页
在现有 `pages/users/user_sgin/index.vue` 基础上改造:
| 改造点 | 说明 |
|---|---|
| 签到前置文章 | 进入页面先展示指定文章或弹层 |
| 10 秒倒计时 | 倒计时结束前签到按钮禁用 |
| 签到提交 | 倒计时结束后调用原签到接口 |
| 后台配置 | 文章 ID、浏览秒数可配置 |
---
## 6. 管理后台需求
### 6.1 复用后台模块
| 模块 | 路径/能力 | 芍药居策略 |
|---|---|---|
| 商品管理 | `product` | 新增商品默认 `is_queue_goods=1`;支持运营手动排除个别商品 |
| 订单管理 | `order` | 继续处理订单、发货、确认收货、退款 |
| 分销员管理 | `/admin/agent/agent_manage/index` | 复用推荐关系、分销员数据 |
| 分销等级 | 当前分销等级配置模块 | 复用等级和后台配置的等级任务 |
| 佣金记录 | 财务/佣金相关页面 | 复用奖励结算流水展示 |
| 积分日志 | HJF/fsgx 积分日志页面 | 复用推广任务触发的积分奖励记录 |
| 积分释放 | 当前积分释放配置和定时任务 | 如当前项目启用则复用,不新增释放引擎 |
| 提现审核 | CRMEB 提现模块 | 复用审核流程;避免与任务结算 7% 重复扣费 |
| 签到配置 | CRMEB 签到配置 | 复用,新增文章浏览配置 |
| 内容文章 | CMS/文章 | 复用为签到前置文章 |
### 6.2 新增后台模块:推四免一任务管理
建议新增轻量后台菜单:
```text
营销 / 芍药居推广 / 推广任务
营销 / 芍药居推广 / 任务结算
营销 / 芍药居推广 / 规则配置
```
任务列表字段:
| 字段 | 说明 |
|---|---|
| 任务编号 | 唯一任务号 |
| 用户 | UID、昵称、手机号 |
| 基准金额 | 默认 4333 元 |
| 任务进度 | 成功数量 / 4 |
| 当前税前奖励 | 按成功数量计算 |
| 税费 | 完成任务为 0提前兑现为当前奖励 × 7% |
| 到账金额 | 完成任务为 4333 元;提前兑现为税前奖励 - 税费 |
| 状态 | 进行中、已完成、提前兑现待审核、已提前兑现、异常关闭 |
| 创建时间 | 待推广金额达标时间 |
任务详情:
- 消费金额来源流水
- 推荐订单来源
- 下级任务创建来源
- 等价报单商品订单触发记录
- 分销奖金/佣金流水
- 积分奖励/积分释放流水
- 分销等级任务进度
- 结算记录
- 操作日志
后台操作:
| 操作 | 说明 |
|---|---|
| 查看详情 | 查看任务、订单、结算全链路 |
| 查看奖励触发 | 查看该任务触发的分销奖金、积分奖励、等级任务记录 |
| 审核提前兑现 | 对用户提前兑现申请进行通过/驳回,复用当前审核能力 |
| 标记异常 | 风控或售后场景使用 |
| 重新计算 | 按当前规则重算任务金额,仅管理员 |
| 导出 | 导出任务、结算、税费 |
### 6.3 规则配置
优先复用 fsgx 已有返佣配置字段:
| 配置项 | 建议字段 | 默认值 |
|---|---|---|
| 任务基准金额 | 新增 `syj_task_base_amount` | 4333 |
| 任务目标单数 | 可复用 `brokerage_cycle_count` | 4 |
| 奖励比例 | 可复用 `brokerage_cycle_rates` | `[10,20,30,40]` |
| 返佣范围 | 复用 `brokerage_scope`,新增商品默认 `is_queue_goods=1` | `queue_only` |
| 生效节点 | 复用当前代码 `brokerage_timing` 配置 | 沿用当前配置 |
| 推四免一有效推广任务等价报单订单 | 新增 `syj_task_as_queue_order_enable` | 1 |
| 任务触发分销奖金 | 新增 `syj_task_trigger_brokerage_enable` 或复用现有开关 | 1 |
| 任务触发积分奖励 | 新增 `syj_task_trigger_points_enable` 或复用现有开关 | 1 |
| 任务触发分销等级任务 | 新增 `syj_task_trigger_agent_level_enable` 或复用现有开关 | 1 |
| 提前兑现税费比例 | 建议新增 `syj_early_cash_tax_rate`,避免与提现手续费混淆 | 7 |
| 完成任务税费比例 | 新增 `syj_complete_tax_rate` 或固定规则 | 0 |
| 下级任务生成是否计入上级 | 新增 `syj_parent_task_on_child_task` | 1 |
| 是否允许提前兑现 | 新增 `syj_early_cash_enable` | 1 |
| 提前兑现是否审核 | 新增 `syj_early_cash_audit_enable` 或复用当前审核配置 | 1 |
| 奖励用途模式 | 可新增 `syj_reward_usage_mode` | `brokerage_withdrawable` |
| 是否启用招商合作 | 可新增 `syj_agent_recruit_enable` | 0 |
---
## 7. 数据模型建议
### 7.1 新增表最小集合
#### 7.1.1 推广任务表 `eb_syj_promote_task`
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| task_no | varchar | 任务编号 |
| uid | int | 任务所属用户 |
| base_amount | int | 基准金额,单位分 |
| target_count | int | 目标数量,默认 4 |
| success_count | int | 当前成功数量 |
| reward_amount | int | 当前税前奖励,单位分 |
| tax_amount | int | 税费,单位分;完成任务为 0提前兑现按 7% |
| arrival_amount | int | 到账金额,单位分 |
| trigger_amount | int | 等价报单商品订单触发金额,默认 433300 分 |
| reward_trigger_status | tinyint | 现有奖励链路触发状态0 未触发1 已触发2 触发失败 |
| reward_trigger_time | int | 分销奖金、积分奖励、等级任务触发时间 |
| status | tinyint | 0 进行中1 已完成2 提前兑现待审核3 已提前兑现4 异常关闭 |
| completed_at | datetime | 完成时间 |
| cashed_at | datetime | 提前兑现时间 |
| create_time | int | 创建时间 |
| update_time | int | 更新时间 |
#### 7.1.2 待推广金额流水表 `eb_syj_promote_amount_log`
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| uid | int | 用户 ID |
| type | varchar | consume/task_create/refund/admin_adjust |
| amount | int | 变动金额,单位分,可正可负 |
| balance | int | 变动后待推广金额,单位分 |
| order_id | int | 关联订单 ID |
| task_id | int | 关联任务 ID |
| remark | varchar | 说明 |
| create_time | int | 创建时间 |
#### 7.1.3 任务进度明细表 `eb_syj_promote_task_record`
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| task_id | int | 任务 ID |
| uid | int | 任务所属用户 |
| source_type | varchar | order_pay/order_confirm/task_created/manual |
| source_uid | int | 触发用户 |
| order_id | int | 来源订单 ID可为空 |
| child_task_id | int | 来源下级任务 ID可为空 |
| step_no | int | 第几单1-4 |
| step_rate | int | 本档比例,按百分比存整数 |
| step_reward | int | 本档奖励,单位分 |
| status | tinyint | 1 有效0 无效 |
| create_time | int | 创建时间 |
#### 7.1.4 任务结算表 `eb_syj_promote_settlement`
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| task_id | int | 任务 ID |
| uid | int | 收益用户 |
| settle_type | varchar | complete/early_cash/manual |
| success_count | int | 结算时成功数量 |
| reward_amount | int | 税前奖励 |
| tax_amount | int | 税费;完成任务为 0提前兑现按 7% |
| arrival_amount | int | 到账金额 |
| user_brokerage_id | int | 关联 CRMEB 佣金记录 |
| user_bill_id | int | 关联 CRMEB 账单 |
| status | tinyint | 待审核、审核通过、审核驳回、已发放、已撤销 |
| audit_uid | int | 审核管理员 ID可复用当前审核链路时按实际字段落库 |
| audit_time | int | 审核时间,可复用当前审核链路时按实际字段落库 |
| create_time | int | 创建时间 |
| paid_time | int | 发放时间 |
#### 7.1.5 任务奖励触发记录表 `eb_syj_promote_reward_trigger`
该表用于记录“推四免一有效推广任务等价当前报单商品订单”后,触发现有分销奖金、积分奖励、分销等级任务的结果。若当前代码已有统一奖励流水,可不新增独立表,但必须能从现有流水反查到推四免一有效推广任务。
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| task_id | int | 推广任务 ID |
| task_no | varchar | 推广任务编号 |
| uid | int | 触发用户 |
| spread_uid | int | 当前推荐上级 |
| trigger_amount | int | 触发金额,默认 433300 分 |
| trigger_type | varchar | brokerage/points/agent_level |
| ref_id | int | 关联现有佣金、积分或等级任务流水 ID |
| status | tinyint | 0 待处理1 成功2 失败 |
| error_msg | varchar | 失败原因 |
| create_time | int | 创建时间 |
| update_time | int | 更新时间 |
### 7.2 用户字段建议
优先不改 `eb_user`。待推广金额可通过流水表聚合,也可为性能增加冗余字段:
| 字段 | 是否必需 | 说明 |
|---|---|---|
| syj_pending_promote_amount | 可选 | 待推广金额,单位分 |
如担心改用户表影响范围,首版可仅使用流水表 + 聚合缓存。
---
## 8. 接口建议
### 8.1 用户端接口
| 接口 | 方法 | 说明 |
|---|---|---|
| `/api/syj/promote/overview` | GET | 我的待推广金额、任务统计、奖励统计 |
| `/api/syj/promote/tasks` | GET | 我的任务列表 |
| `/api/syj/promote/task/:id` | GET | 任务详情 |
| `/api/syj/promote/task/:id/cash` | POST | 提交提前兑现申请,进入后台审核 |
| `/api/syj/promote/share` | GET | 分享链接/海报数据,复用 CRMEB 推荐参数 |
### 8.2 后台接口
| 接口 | 方法 | 说明 |
|---|---|---|
| `/adminapi/syj/promote/config` | GET | 规则配置 |
| `/adminapi/syj/promote/config` | POST | 保存规则配置 |
| `/adminapi/syj/promote/tasks` | GET | 任务列表 |
| `/adminapi/syj/promote/task/:id` | GET | 任务详情 |
| `/adminapi/syj/promote/task/:id/recalculate` | POST | 重新计算 |
| `/adminapi/syj/promote/amount_logs` | GET | 待推广金额流水 |
| `/adminapi/syj/promote/settlements` | GET | 结算列表 |
| `/adminapi/syj/promote/settlement/:id/audit` | POST | 审核提前兑现申请,通过后发放、驳回后保留任务状态 |
| `/adminapi/syj/promote/reward_triggers` | GET | 查询推广任务触发的分销奖金、积分奖励、等级任务记录 |
### 8.3 签到接口
签到优先复用现有接口:
| 能力 | 现有接口 | 改造 |
|---|---|---|
| 签到配置 | `sign/config` | 增加文章 ID / 浏览秒数配置 |
| 签到提交 | `sign/user``sign/integral` | 提交时校验浏览时长 |
| 签到记录 | `sign/list``sign/calendar` | 直接复用 |
---
## 9. 实现建议
### 9.1 后端最小改造点
| 位置 | 改造说明 |
|---|---|
| 订单确认收货链路 | 参与规则商品确认收货后累计购买人待推广金额,并尝试创建任务;新增商品默认参与 |
| 推广任务生成链路 | 每生成 1 个推四免一有效推广任务,按当前代码 1 个报单商品订单触发现有分销奖金、积分奖励和分销等级任务 |
| 推荐订单计入链路 | 复用后台 `brokerage_timing``on_pay` 支付成功后计入任务进度,`on_confirm` 确认收货后计入任务进度;任务进度与现有奖励触发记录分开追溯 |
| fsgx 佣金计算链路 | 保持现有分销奖金计算逻辑,增加推四免一有效推广任务作为等价触发来源 |
| 积分奖励链路 | 复用当前积分奖励、积分日志和积分释放逻辑,增加推广任务来源标识 |
| 分销等级链路 | 复用当前分销等级与后台配置的等级任务,推广任务进入等级任务统计 |
| 佣金/余额入账 | 完成任务直接按推四免一口径入账;提前兑现经后台审核通过后入账;分销奖金仍走现有逻辑 |
| 系统配置 | 增加芍药居任务相关配置项 |
| 后台路由 | 增加 `syj/promote/*` 管理接口 |
### 9.2 前端最小改造点
| 端 | 改造说明 |
|---|---|
| UniApp | 新增“我的推广任务”列表/详情/提前兑现页面 |
| UniApp | 个人中心或推广中心增加入口 |
| UniApp | 商品详情/支付结果增加芍药居规则文案 |
| UniApp | 签到页加文章浏览 10 秒门槛 |
| Admin | 新增轻量任务管理页面 |
| Admin | 规则配置页面可复用系统配置表单样式 |
### 9.3 默认配置建议
| 配置项 | 值 |
|---|---|
| `brokerage_cycle_count` | `4` |
| `brokerage_cycle_rates` | `[10,20,30,40]` |
| `brokerage_scope` | `queue_only` |
| `brokerage_timing` | 沿用当前配置 |
| `hjf_queue_pool_enable` | `0` |
| `hjf_umbrella_reward_enable` | `0` |
| `syj_task_base_amount` | `433300` |
| `syj_task_as_queue_order_enable` | `1` |
| `syj_task_trigger_brokerage_enable` | `1` |
| `syj_task_trigger_points_enable` | `1` |
| `syj_task_trigger_agent_level_enable` | `1` |
| `syj_complete_tax_rate` | `0` |
| `syj_early_cash_tax_rate` | `7` |
| `syj_early_cash_enable` | `1` |
| `syj_early_cash_audit_enable` | `1` |
| `syj_reward_usage_mode` | `brokerage_withdrawable` |
| `syj_agent_recruit_enable` | `0` |
---
## 10. 验收标准
### 10.1 待推广金额与任务
- 新增商品默认 `is_queue_goods=1`
- 任意新增商品订单确认收货 4333 元后,用户自动生成 1 个任务。
- 任意新增商品订单确认收货 10000 元后,用户生成 2 个任务,剩余待推广金额 1334 元。
- 积分、优惠券抵扣部分不计入待推广金额。
- 仅被运营设置为 `is_queue_goods=0` 的商品订单不生成任务。
- 每生成 1 个推四免一有效推广任务,系统按当前代码 1 个报单商品订单触发基准记录奖励触发来源。
### 10.2 推荐进度
- 被推荐订单达到后台 `brokerage_timing` 对应节点后,推荐人最早进行中任务成功数 +1`on_pay` 支付成功后计入,`on_confirm` 确认收货后计入。
- 推荐人无进行中任务时,不生成任务进度。
- 任务达到 4/4 后自动完成。
- 多任务并行时按创建时间顺序计入。
### 10.3 奖励结算
- 1 单成功可申请提前兑现,税前 433.30 元,审核通过后扣 7% 到账 402.97 元。
- 2 单成功可申请提前兑现,税前 1299.90 元,审核通过后扣 7% 到账 1208.91 元。
- 3 单成功可申请提前兑现,税前 2599.80 元,审核通过后扣 7% 到账 2417.81 元。
- 4 单完成税前奖励 4333.00 元,不扣 7%,到账 4333.00 元。
- 所有结算均能在 CRMEB 佣金/账单中追溯。
### 10.4 分销奖金、积分与等级
- 推四免一有效推广任务生成后,能够触发现有分销奖金/佣金逻辑,并在现有佣金流水中追溯到该任务。
- 推四免一有效推广任务生成后,能够触发现有积分奖励逻辑,并在积分日志中追溯到该任务。
- 若当前积分释放配置启用,推四免一有效推广任务产生的积分进入现有积分释放逻辑。
- 会员等级复用当前分销等级,升级条件按后台配置的分销等级任务计算。
- 分销等级任务统计中,推四免一有效推广任务按等价报单商品订单计入。
### 10.5 提前兑现
- 0 单任务不可兑现。
- 提前兑现需二次确认并提交后台审核。
- 审核通过后任务关闭,后续订单不再计入该任务。
- 审核驳回后任务保持进行中,可继续接收推荐订单。
- 已占用的 4333 元不退回待推广金额。
### 10.6 签到
- 签到页先展示产品文章。
- 10 秒内签到按钮不可点击。
- 10 秒后可签到。
- 同日重复签到返回明确提示。
### 10.7 补充规则隔离
- 首版用户端不出现“推三免一”结算口径。
- 首版任务结算不使用 20%、30%、50% 作为推四免一奖励比例。
- 首版不新增独立等级积分体系,等级、积分、升级任务均复用当前代码。
- 首版不出现代理商 19800 元招商、加权分红、股东权益、字画赠品等入口。
- 已确认首版奖励进入 CRMEB 佣金账户并沿用提现链路,不做下单黄金或不能提现限制。
---
## 11. 问题确认记录
| 编号 | 问题 | 确认结果 | PRD 落地口径 |
|---|---|---|---|
| Q1 | 推荐有效订单是否必须是 4333 元报单商品? | 新增的所有商品默认设置 `is_queue_goods=1`,所有新增商品订单都是有效订单 | 新增商品默认参与待推广金额累计和推荐计数;保留 `is_queue_goods=0` 作为运营排除个别商品的开关 |
| Q2 | 主线到底是“推四免一”还是补充说明中的“推三免一”? | 按“推四免一”执行 | 首版不出现推三免一结算口径 |
| Q3 | 奖励比例是 10%/20%/30%/40%,还是 20%/30%/50% | 推广任务按 10%/20%/30%/40% | 首版任务奖励比例固定为 10%、20%、30%、40% |
| Q4 | 奖励是否能提现,还是仅可用于平台内消费/下单黄金? | 采纳复用 CRMEB 佣金账户和提现链路的建议 | 首版不做下单黄金或不能提现限制 |
| Q5 | 积分等级体系是否进入首版? | 根据最新反馈,积分奖励和会员等级复用当前代码 | 首版复用当前积分奖励、积分日志、分销等级和后台配置的分销等级任务 |
| Q6 | 下级“生成任务”是否也计入上级任务,还是只按下级订单达到返佣时机节点计入? | 采纳配置开关建议 | 首版保留“下级生成任务计入上级任务”能力,并通过配置控制;推荐订单计入节点复用后台 `brokerage_timing` |
| Q7 | 任意套餐包是否都可计入有效订单? | 任意商品订单都是有效订单 | 新增商品默认 `is_queue_goods=1`,因此任意新增商品/套餐包订单默认有效 |
| Q8 | 完成 4 单是否也扣 7% | 不扣除 | 完成 4 单到账 4333 元1-3 单提前兑现仍扣 7% |
| Q9 | 招商合作是否需要系统功能支持? | 采纳建议 | 招商合作不纳入首版默认需求 |
| Q10 | 提前兑现是否需要后台审核? | 后台审核,复用当前代码 | 提前兑现提交申请,后台审核通过后发放;驳回后任务继续进行 |
| Q11 | 签到是否发奖励? | 采纳建议 | 复用 CRMEB 签到奖励配置,运营可配置为 0 |
| Q12 | 推广任务与当前报单商品订单是什么关系? | 每满 4333 元生成 1 个推四免一有效推广任务1 个推四免一有效推广任务等价当前代码 1 个报单商品订单 | 推四免一有效推广任务作为现有分销奖金、积分奖励和分销等级任务的触发基准 |
---
## 12. 研发拆分
| 阶段 | 范围 | 交付 |
|---|---|---|
| Phase 1 | 配置与数据表 | 配置项、任务表、流水表、结算表 |
| Phase 2 | 订单确认收货挂钩 | 消费累计、任务生成 |
| Phase 3 | 推荐任务进度 | 推荐订单计入任务、任务完成 |
| Phase 4 | 奖励触发与结算 | 推广任务触发现有分销奖金、积分奖励、分销等级任务;完成结算、提前兑现审核、佣金/账单入账 |
| Phase 5 | 用户端页面 | 我的任务、任务详情、兑现交互 |
| Phase 6 | 后台页面 | 任务列表、详情、结算、导出 |
| Phase 7 | 签到改造 | 文章浏览 10 秒后签到 |
| Phase 8 | 回归与配置 | 芍药居默认配置落库,验证现有分销奖金、积分奖励、分销等级任务复用正确 |
| Phase 9 | 补充规则评审 | 招商合作、奖励用途限制是否进入后续版本 |

View File

@@ -0,0 +1,314 @@
# syj 商城变体部署手册
少药局线上站点:`https://syj.fsgx.cn`
一键发布脚本:`pro_v3.5.1/deploy/release-syj.sh`
发布方式:本地当前分支构建 admin → 同步 `public/admin/` 和后端代码 → 远端切换 `.env-syj``php think clear` → Swoole reload → 健康检查 → 失败自动回滚。
覆盖范围:
- 后端 PHP 代码
- admin 前端构建产物 `public/admin/`
- 远端 `.env` 使用 `.env-syj`
- Swoole 平滑 reload
- 远端备份与回滚
不覆盖:
- `view/uniapp_v2/` 小程序端,需要 HBuilderX / 微信开发者工具单独发布
- `view/uniapp/`
- Composer 依赖安装
- 数据库结构迁移和配置项插入
---
## 1. 本次上线
本次已推送分支:`syj-bypass-auth`
本次提交:
```bash
673a19b3 feat: add channel code integral reward
```
主要内容:
- `admin/agent/agent_manage/index` 增加“小程序推广码”列,并将“推广用户数量”移动到“小程序推广码”后面。
- 积分配置新表单增加:
- 渠道码积分奖励
- 渠道分销员UID
- 渠道码奖励积分
- 新用户扫描指定分销员小程序分享二维码注册后,可按配置发放积分,仅新用户注册事件触发,且通过积分账单防重复。
- 修复 `/admin/system/maintain/clear/index` 清除缓存、清除日志在目录不存在时可能报错的问题。
上线命令:
```bash
cd /Users/mac/scott2026/huangjingfen/pro_v3.5.1
git fetch origin
git checkout syj-bypass-auth
git pull --ff-only origin syj-bypass-auth
bash deploy/release-syj.sh
```
如果确认 admin 已经构建过,可跳过构建:
```bash
bash deploy/release-syj.sh --skip-build
```
---
## 2. 发布前检查
```bash
cd /Users/mac/scott2026/huangjingfen
git branch --show-current
git log -1 --oneline
git status --short
```
正常应在:
```bash
syj-bypass-auth
```
确认远端可访问:
```bash
ssh root@8.140.50.89 'cd /www/wwwroot/syj.fsgx.cn && pwd && php -v | head -1'
curl -ksS -o /dev/null -w "api/version -> %{http_code}\n" https://syj.fsgx.cn/api/version
curl -ksS -o /dev/null -w "admin -> %{http_code}\n" https://syj.fsgx.cn/admin/
```
确认本次代码在本地:
```bash
rg -n "channel_code_integral_status|channel_code_agent_uid|channel_code_give_integral" \
pro_v3.5.1/app/services/system/config/SystemConfigServices.php \
pro_v3.5.1/app/listener/user/Register.php
rg -n "小程序推广码|推广用户数量|showXcxCode" \
pro_v3.5.1/view/admin/src/pages/agent/agentManage.vue
```
---
## 3. 数据库配置项
发布脚本不会自动写数据库。上线前确认 `eb_system_config` 已存在以下配置项,并归属积分配置分组 `config_tab_id = 11`
查询:
```bash
mysql -h8.140.50.89 -P3306 -usyj-shop -p syj-shop -e "
SELECT menu_name, config_tab_id, type, input_type, info, value, \`desc\`, parameter, sort, status
FROM eb_system_config
WHERE menu_name IN (
'channel_code_integral_status',
'channel_code_agent_uid',
'channel_code_give_integral'
);"
```
缺失时插入:
```sql
INSERT INTO eb_system_config
(config_tab_id, menu_name, type, input_type, config_tab, parameter, upload_type, required, width, high, value, info, `desc`, sort, status)
SELECT 11, 'channel_code_integral_status', 'radio', '', '', '1=>开启\n0=>关闭', 0, '', 0, 0, '0',
'渠道码积分奖励',
'开启后,新用户扫描指定分销员的小程序分享二维码注册可获得积分奖励,仅限新用户领取一次',
7, 1
WHERE NOT EXISTS (SELECT 1 FROM eb_system_config WHERE menu_name = 'channel_code_integral_status');
INSERT INTO eb_system_config
(config_tab_id, menu_name, type, input_type, config_tab, parameter, upload_type, required, width, high, value, info, `desc`, sort, status)
SELECT 11, 'channel_code_agent_uid', 'text', 'number', '', '', 0, '', 0, 0, '0',
'渠道分销员UID',
'填写分销员UID该分销员的小程序推广二维码将作为渠道码',
6, 1
WHERE NOT EXISTS (SELECT 1 FROM eb_system_config WHERE menu_name = 'channel_code_agent_uid');
INSERT INTO eb_system_config
(config_tab_id, menu_name, type, input_type, config_tab, parameter, upload_type, required, width, high, value, info, `desc`, sort, status)
SELECT 11, 'channel_code_give_integral', 'text', 'number', '', '', 0, '', 0, 0, '0',
'渠道码奖励积分',
'新用户扫描渠道码注册后获得的积分数量设置为0则不发放',
5, 1
WHERE NOT EXISTS (SELECT 1 FROM eb_system_config WHERE menu_name = 'channel_code_give_integral');
```
后台配置路径:
```text
admin/marketing/integral/system_config/3/11
```
配置说明:
- `渠道码积分奖励`:开启后生效。
- `渠道分销员UID`:填写作为渠道码来源的分销员 UID。
- `渠道码奖励积分`:新用户扫描该分销员小程序推广码注册后获得的积分数。
---
## 4. 日常发布
```bash
cd /Users/mac/scott2026/huangjingfen/pro_v3.5.1
bash deploy/release-syj.sh
```
脚本阶段:
1. `Pre-flight checks`:校验分支、`.env-syj`、远端目录、admin 生产 API 地址。
2. `Build admin`:在 `view/admin` 执行 `npm run build`
3. `Sync admin dist -> public/admin/`:把 `view/admin/dist/` 同步到 `public/admin/`
4. `Release tag`:生成 `YYYYMMDD-HHMMSS-<git sha>`
5. `rsync`:同步项目到 `/www/wwwroot/syj.fsgx.cn`,旧文件备份到 `/www/wwwroot/syj.fsgx.cn-bak/<tag>/`
6. `Remote`:远端 `.env-syj` 覆盖 `.env`,执行 `php think clear`reload Swoole。
7. `Healthcheck`:检查 `https://syj.fsgx.cn/api/version``https://syj.fsgx.cn/admin/`
8. 成功后写入本地 `pro_v3.5.1/deploy/.last-release`
可选参数:
- `--dry-run`:只预览 rsync 改动,不改远端。
- `--skip-build`:复用已有 `view/admin/dist/`
- `--rollback <tag>`:回滚指定备份。
---
## 5. 上线后验收
基础健康:
```bash
curl -ksS -o /dev/null -w "api/version -> %{http_code}\n" https://syj.fsgx.cn/api/version
curl -ksS -o /dev/null -w "admin -> %{http_code}\n" https://syj.fsgx.cn/admin/
```
远端代码确认:
```bash
ssh root@8.140.50.89 'cd /www/wwwroot/syj.fsgx.cn && \
grep -n "channel_code_integral_status" app/services/system/config/SystemConfigServices.php && \
grep -n "giveChannelCodeIntegral" app/listener/user/Register.php && \
grep -n "小程序推广码" view/admin/src/pages/agent/agentManage.vue public/admin/js/*.js 2>/dev/null | head'
```
后台页面验收:
- `admin/marketing/integral/system_config/3/11` 能看到 3 个渠道码积分配置项。
- `admin/agent/agent_manage/index` 列顺序为:`小程序推广码``推广用户数量``上级推广人`
- 点击“小程序推广码”的“查看”能打开二维码弹窗。
- `/admin/system/maintain/clear/index` 清除缓存、清除日志不再报错。
业务验收:
1. 在积分配置页开启“渠道码积分奖励”。
2. 设置“渠道分销员UID”和“渠道码奖励积分”。
3. 使用该分销员的小程序推广码新注册用户。
4. 新用户积分增加对应数量。
5. `eb_user_bill` 中出现 `type = channel_code_add` 的积分账单。
6. 同一用户不会重复领取。
查询账单:
```sql
SELECT uid, category, type, title, number, balance, mark, add_time
FROM eb_user_bill
WHERE type = 'channel_code_add'
ORDER BY id DESC
LIMIT 20;
```
---
## 6. 回滚
查看最近备份:
```bash
ssh root@8.140.50.89 'ls -1t /www/wwwroot/syj.fsgx.cn-bak | head -10'
```
执行回滚:
```bash
cd /Users/mac/scott2026/huangjingfen/pro_v3.5.1
bash deploy/release-syj.sh --rollback 20260521-005301-673a19b3
```
说明:
- `.env` 不在回滚范围内,避免误覆盖运行环境。
- 数据库配置项和业务数据不随脚本回滚。
- 若本次上线已经产生积分账单,代码回滚不会自动撤销账单,需要业务人工处理。
---
## 7. 故障排查
| 现象 | 排查 |
|------|------|
| 后台看不到新增配置项 | 确认远端代码已更新、Swoole 已 reload、`eb_system_config` 三条配置存在且 `config_tab_id = 11` |
| admin 页面还是旧列顺序 | 确认 `public/admin/` 是最新构建产物,浏览器强刷或清缓存 |
| 新用户未获得积分 | 检查配置是否开启、分销员 UID 是否正确、扫码二维码是否 `third_type = spread_routine` |
| 重复发积分 | 查询 `eb_user_bill.type = channel_code_add`,代码按用户已有该账单防重复 |
| 清缓存/清日志失败 | 看 `runtime/log/``app/services/system/log/ClearServices.php` 是否已更新 |
| `/api/version` 不是 200 | 看 Swoole 是否运行、Nginx 反代、ThinkPHP 日志 |
| reload 后代码未生效 | 宝塔 Supervisor 兜底:`/www/server/panel/pyenv/bin/supervisorctl restart pro3.5.1:pro3.5.1_00` |
| rsync 提示 `.user.ini` | `deploy/rsync-exclude.txt` 已排除 `public/.user.ini`,不需要删除远端文件 |
远端常用命令:
```bash
ssh root@8.140.50.89 'cd /www/wwwroot/syj.fsgx.cn && php think clear'
ssh root@8.140.50.89 'ps aux | grep "php think swoole" | grep -v grep'
ssh root@8.140.50.89 'tail -100 /www/wwwroot/syj.fsgx.cn/runtime/swoole/swoole.log'
ssh root@8.140.50.89 'tail -100 /www/server/panel/plugin/supervisor/log/pro3.5.1.out.log'
```
---
## 8. 本机环境
启动本机后端和 admin
```bash
cd /Users/mac/scott2026/huangjingfen/pro_v3.5.1
bash deploy/start-local-syj.sh start
```
常用命令:
```bash
bash deploy/start-local-syj.sh status
bash deploy/start-local-syj.sh restart
bash deploy/start-local-syj.sh logs
bash deploy/start-local-syj.sh stop
```
本机地址:
- 后端:`http://127.0.0.1:20199`
- admin`http://localhost:8085/admin/`
注意:
- `view/admin/.env.dev` 需要指向 `http://127.0.0.1:20199/adminapi` 才会请求本机后端。
- `view/uniapp_v2/` 不由本脚本启动。
---
## 9. 服务器留档
路径:
- 项目:`/www/wwwroot/syj.fsgx.cn/`
- 备份:`/www/wwwroot/syj.fsgx.cn-bak/`
- Swoole 日志:`/www/wwwroot/syj.fsgx.cn/runtime/swoole/swoole.log`
- Supervisor 日志:`/www/server/panel/plugin/supervisor/log/pro3.5.1.out.log`
- ThinkPHP 日志:`/www/wwwroot/syj.fsgx.cn/runtime/log/`
Nginx 要点:
- `/admin/` 指向 `public/admin/`
- `/api/``/adminapi/` 由 Swoole 处理
- Swoole 监听端口:`20199`
`.env-syj`
- `[DATABASE]` 指向 `8.140.50.89`
- `[REDIS]` 指向 `8.140.50.89`
- 生产建议 `APP_DEBUG = false`,当前脚本不会强制改该值

View File

@@ -0,0 +1,242 @@
# 芍药居小程序 - 业务需求与推广规则整理
> 文档定位:对已补充/修改过的原始需求进行结构化梳理,作为 `PRD_shaoyaoju_V1.0.md` 的业务口径来源。
> 整理日期2026-05-01
> 当前状态:需求整理稿,含待确认口径。
---
## 一、核心结论
芍药居小程序的主线推广模式为“推四免一”:
- 会员每累计 4333 元有效消费,可获得 1 个推广任务资格。
- 每个推广任务目标为推荐 4 个有效订单。
- 4 单全部完成后,任务税前奖励合计 4333 元。
- 未完成 4 单时,会员可按已完成单数申请提前兑现。
- 奖励按 10%、20%、30%、40% 四档递进计算,并扣除 7% 税费/手续费后发放。
补充内容中还包含会员等级(暨分销等级)、积分奖励、积分释放、招商合作等规则。这些规则与主线“推四免一”存在部分口径冲突,本文已单独归类为“补充规则”和“待确认问题”。
---
## 二、核心概念
| 概念 | 说明 |
|---|---|
| 会员 | 通过分享码注册并完成首单购买的用户 |
| 有效消费 | 会员购买参与规则的商品后,确认收货且未退款的实际支付金额 |
| 实际支付金额 | 用户真实支付金额;积分抵扣部分不计入,优惠/抵扣部分需按运营口径确认 |
| 待推广金额 | 会员有效消费的累计余额,用于生成推广任务 |
| 推广任务 | 每满 4333 元待推广金额自动生成的任务,目标为推荐 4 个有效订单 |
| 有效订单 | 被推荐用户完成购买、确认收货且未退款/撤单/风控无效的订单 |
| 提前兑现 | 任务未满 4 单时,按已成功单数结算并关闭任务 |
| 税费/手续费 | 奖励结算时扣除 7%,到账金额为税前奖励扣除费用后的金额 |
| 积分 | 补充等级规则中的奖励资产,是否纳入首版需确认 |
---
## 三、主流程
```text
会员消费
-> 确认收货后累计待推广金额
-> 每满 4333 元自动生成 1 个推广任务
-> 会员分享并推荐有效订单
-> 推荐订单计入任务进度
-> 满 4 单自动完成,或 1-3 单时提前兑现
-> 按 10% + 20% + 30% + 40% 计算奖励
-> 扣除 7% 税费/手续费后发放
```
---
## 四、推四免一规则
### 4.1 消费累计
- 会员每次购物确认收货后,订单实际支付金额计入“待推广金额”。
- 积分抵扣部分不计入待推广金额。
- 示例:订单金额 5000 元,积分抵扣 500 元,实际支付 4500 元,则计入待推广金额 4500 元。
### 4.2 任务生成
- 待推广金额大于或等于 4333 元时,系统自动生成 1 个推广任务。
- 生成任务后,待推广金额扣除 4333 元,剩余金额继续保留累计。
- 一次消费金额较大时,可连续生成多个任务。
| 待推广金额 | 生成任务数 | 扣除金额 | 剩余待推广金额 |
|---:|---:|---:|---:|
| 4333 元 | 1 | 4333 元 | 0 元 |
| 5200 元 | 1 | 4333 元 | 867 元 |
| 10000 元 | 2 | 8666 元 | 1334 元 |
### 4.3 多任务并行
- 一个任务未完成时,待推广金额再次满 4333 元,可继续生成新任务。
- 会员可同时拥有多个进行中的推广任务。
- 推荐订单建议按任务创建时间顺序,优先计入最早的进行中任务。
### 4.4 推荐计数
- 每个推广任务目标为 4 个有效订单。
- 推荐订单确认收货后,计入推荐人的任务进度。
- 当前任务达到 4 单后,后续推荐订单计入下一个进行中任务。
- 推荐人没有进行中任务时,推荐订单是否保留但不计奖,需按系统实现口径确认。
### 4.5 上级关联
原始需求包含以下规则:
- 生成推广任务时,系统检查直属上级是否有进行中的任务。
- 如果直属上级存在进行中任务,则上级的“推广成功数量”加 1。
- 上级也能从下级的推广成果中获益。
该规则与“推荐订单确认收货计入上级任务”可能重复,需要运营确认最终计数口径。建议系统实现时保留来源类型,区分“订单确认”和“下级任务生成”。
### 4.6 提前兑现
- 任务未完成 4 单时,会员可选择提前结束任务。
- 0 单成功时不建议允许提前兑现。
- 1-3 单成功时,按实际成功单数计算奖励。
- 提前兑现后,该任务关闭,不再接收新的推荐订单。
- 提前兑现后,已占用的 4333 元消费额不退回待推广金额。
---
## 五、奖励计算
单个任务基准金额为 4333 元。
| 推荐成功单数 | 本档比例 | 本档奖励 | 累计比例 | 累计税前奖励 |
|---:|---:|---:|---:|---:|
| 第 1 单 | 10% | 433.30 元 | 10% | 433.30 元 |
| 第 2 单 | 20% | 866.60 元 | 30% | 1299.90 元 |
| 第 3 单 | 30% | 1299.90 元 | 60% | 2599.80 元 |
| 第 4 单 | 40% | 1733.20 元 | 100% | 4333.00 元 |
计算公式:
```text
税前奖励 = 4333 × 累计奖励比例
税费/手续费 = 税前奖励 × 7%
到账金额 = 税前奖励 - 税费/手续费
```
提前兑现参考:
| 成功单数 | 税前奖励 | 税费/手续费 7% | 到账金额 |
|---:|---:|---:|---:|
| 1 单 | 433.30 元 | 30.33 元 | 402.97 元 |
| 2 单 | 1299.90 元 | 90.99 元 | 1208.91 元 |
| 3 单 | 2599.80 元 | 181.99 元 | 2417.81 元 |
| 4 单 | 4333.00 元 | 303.31 元 | 4029.69 元 |
---
## 六、签到规则
- 签到前,用户需先浏览产品功效介绍文章。
- 浏览满 10 秒后,才可签到。
- 每个自然日是否只能签到一次、签到奖励是否发放积分,沿用平台签到配置或由运营另行确认。
---
## 七、补充等级与积分规则
以下内容来自新增的“全方位盈利指南”类说明,建议作为二期/配置化能力评估,是否进入首版需确认。
### 7.1 基础规则
- 项目产品:澳的利草本饮料小程序。
- 对外话术:采用“推四免一”分享模式。
- 用户通过分享码注册并完成首单购买后,获得分享资格。
### 7.2 会员等级/分销等级
| 等级 | 晋升条件 | 权益/奖励 |
|---|---|---|
| 普通会员 | 扫描分享码注册并完成首单购买 | 获得分享资格;原补充说明中提到分享订单享受 20%、30%、50% 阶梯奖励 |
| 业务主管 | 直推 3 位有效会员 | 团队每新增 1 单奖励 500 积分;积分按每日 0.4%释放 |
| 业务经理 | 团队累计 30 单 | 直推每单额外奖励 800 积分;团队级差奖励 300 积分/单 |
| 业务总监 | 团队累计 100 单 | 直推每单额外奖励 1000 积分;享受团队差补,业务经理差补 200 积分/单,业务主管差补 500 积分/单 |
### 7.3 积分与平级规则
- 小程序内任意套餐包均可购买。
- 在商城选购商品达到套餐包价值,同样计算为成功购买/分享一单。
- 平级会员不可同时享受积分,新增奖励归属新增级。
- 积分按千分之四每日释放。
- 已释放积分可在商城兑换产品。
### 7.4 奖励使用限制
补充说明中提到“所得奖励不能提现,可以直接在平台下单黄金”。该口径与主规则中的“扣除 7% 后发放/提现”存在冲突,需要确认:
- 奖励是否允许提现。
- 奖励是否仅可用于平台内消费。
- “黄金”是否属于平台商品、积分兑换品或特定业务资产。
### 7.5 招商合作
补充说明中包含代理商招募规则:
- 招募代理商限定名额49 名。
- 加入条件:一次性购买 19800 元产品。
- 代理商权益:
- 获得 19800 元产品。
- 每月加权分红,直到累计达到 3 倍为止。
- 达到 3 倍后,业绩前 10 名进入永久股东。
- 上市前拥有优先认购原始股权利。
- 赠送价值 2 万元的张大师字画一副。
该部分涉及招商、分红、股东权益和商品赠品,建议作为独立合规/运营方案确认,不默认纳入小程序首版功能。
---
## 八、场景示例
### 场景一:正常完成
1. 会员 A 消费累计 5000 元。
2. 系统生成 1 个推广任务,待推广金额剩余 667 元。
3. 会员 A 推荐好友 B、C、D、E 各完成 1 个有效订单。
4. 任务完成,税前奖励 4333 元。
5. 扣除 7% 税费/手续费后,到账 4029.69 元。
### 场景二:提前兑现
1. 会员 A 消费累计 4333 元。
2. 系统生成 1 个推广任务。
3. 会员 A 推荐成功 2 单后申请提前兑现。
4. 税前奖励为 4333 × (10% + 20%) = 1299.90 元。
5. 扣除 7% 税费/手续费 90.99 元后,到账 1208.91 元。
6. 任务关闭,后续订单不再计入该任务。
### 场景三:多任务并行
1. 会员 A 消费累计 10000 元。
2. 系统生成 2 个推广任务,待推广金额剩余 1334 元。
3. 任务 1 完成 4 单,任务 2 完成 2 单。
4. 任务 1 自动完成结算,任务 2 可继续推进或提前兑现。
---
## 九、待确认问题
| 编号 | 问题 | 当前整理建议 |
|---|---|---|
| Q1 | 主线到底是“推四免一”还是补充说明中的“推三免一”? | 首版按“推四免一”执行,推三免一视为旧口径或待确认内容 |
| Q2 | 奖励比例是 10%/20%/30%/40%,还是 20%/30%/50% | 推广任务按 10%/20%/30%/40%20%/30%/50% 标为待确认补充规则 |
| Q3 | 奖励是否能提现? | 当前主规则按扣 7% 后发放;“不能提现、用于下单黄金”需另行确认 |
| Q4 | 积分等级体系是否进入首版? | 建议首版先聚焦推四免一,等级积分作为后续配置化能力 |
| Q5 | 下级生成任务是否计入上级任务? | 建议保留配置开关,避免与推荐订单确认收货重复计数 |
| Q6 | 任意套餐包是否都可计入有效订单? | 建议明确只有参与规则的报单商品/套餐包计入 |
| Q7 | 完成 4 单是否也扣 7% | 当前整理为所有结算均扣 7% |
| Q8 | 招商合作是否需要系统功能支持? | 建议作为独立运营方案,不纳入首版默认需求 |
---
## 十、一句话总结
每累计 4333 元有效消费生成 1 个“推四免一”推广任务,推荐 4 个有效订单可获得 4333 元税前奖励,也可在完成 1-3 单时提前兑现;等级积分、奖励使用限制和招商合作属于补充口径,需运营确认后再进入产品方案。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,289 @@
# 芍药居项目与 fsgx 当前代码差异对比
> 文档日期2026-05-02
> 当前分支:`syj-bypass-auth`
> 对比基线fsgx 项目代码与文档(`fsgx-bypass-auth` / `docs/project-fsgx/*`
> 业务需求来源:`docs/project-shaoyaoju/prd-require.md` 与后续确认反馈
> 目标:明确“最小修改、最大复用”的落地边界
---
## 1. 总体结论
芍药居不需要重做商城主体,也不需要重写当前 fsgx 已有的分销奖金、积分奖励、分销等级和提现能力。当前代码已经提供大量可复用能力:
- 商品、订单、支付、售后
- `spread_uid` 推荐关系
- 报单商品 `is_queue_goods`
- 分销奖金/周期佣金配置
- 积分奖励、积分日志、积分释放
- 分销等级与后台配置的分销等级任务
- 佣金记录与提现
- 分销员管理
- 用户资产聚合
- 签到基础接口与页面
芍药居与 fsgx 的核心差异不是“重做奖励体系”,而是新增一层“消费累计生成推四免一有效推广任务”的适配层。本文档中的“推广任务”如无特别说明,均特指“推四免一有效推广任务维度”,不是后台配置的分销等级任务。
```text
用户有效消费累计每满 4333 元
-> 自动生成 1 个推四免一有效推广任务
-> 该任务在系统侧等价当前代码 1 个报单商品订单
-> 复用现有分销奖金、积分奖励、分销等级任务
-> 同时承载推四免一进度、完成结算和提前兑现
```
因此研发重点是让推广任务成为当前报单商品订单奖励链路的触发基准,而不是另起一套分销、积分或会员等级系统。
---
## 2. 核心规则差异
| 维度 | fsgx 当前规则/代码 | 芍药居目标规则 | 改造方式 |
|---|---|---|---|
| 触发基准 | 报单商品订单触发分销奖金、积分奖励、分销等级任务 | 每满 4333 元生成 1 个推四免一有效推广任务,任务等价 1 个报单商品订单 | 新增任务生成与触发适配层 |
| 任务资格 | 无“用户自身消费累计生成推四免一有效推广任务”概念 | 用户自身有效消费累计满 4333 元生成推四免一有效推广任务 | 新增待推广金额和推广任务 |
| 推四免一进度 | 当前代码无任务维度 | 推荐 4 个达到 `brokerage_timing` 对应节点的有效订单完成任务 | 新增任务进度记录,计入节点复用后台返佣时机 |
| 分销奖金 | 由报单商品订单触发 | 由推四免一有效推广任务等价触发 | 复用现有分销奖金逻辑,增加任务来源标识 |
| 积分奖励 | 由当前订单/报单逻辑触发 | 由推四免一有效推广任务等价触发 | 复用现有积分奖励、积分日志、积分释放逻辑 |
| 会员等级 | 当前分销等级与后台等级任务配置 | 继续复用当前分销等级 | 推四免一有效推广任务计入后台配置的分销等级任务 |
| 奖励比例 | fsgx 旧口径可能为 20/30/50 | 芍药居推四免一为 10/20/30/40 | 配置为 4 档比例 |
| 完成结算 | 当前无推四免一任务完成结算 | 4 单完成到账 4333 元,不扣 7% | 新增任务完成结算 |
| 提前兑现 | 当前无任务中途兑现 | 1-3 单可申请,后台审核,通过后扣 7% | 新增提前兑现申请与审核 |
| 上级联动 | 推荐订单按推广关系计佣 | 下级生成任务也可计入上级任务 | 新增配置开关并记录来源类型 |
| 奖励用途 | 当前佣金可进入提现链路 | 首版复用佣金账户和提现链路 | 不做下单黄金或不能提现限制 |
| 招商合作 | 无代理商招商/分红/股东权益 | 补充需求包含 19800 元招商包等 | 不进入首版,独立评审 |
| 签到 | CRMEB 原签到 | 浏览产品文章 10 秒后签到 | 复用签到页,新增前置文章门槛 |
---
## 3. 代码复用矩阵
### 3.1 直接复用
| 能力 | 当前代码位置/模块 | 复用说明 |
|---|---|---|
| 商品管理 | `StoreProduct`、后台商品页面 | 新增商品默认 `is_queue_goods=1`,允许运营排除个别商品 |
| 下单支付 | 订单创建、支付、确认收货链路 | 复用订单生命周期,用于累计待推广金额 |
| 推荐关系 | `spread_uid``spread` 参数、用户服务 | 继续作为上下级关系唯一依据 |
| 分销奖金 | 当前分销/佣金服务、`UserBrokerage` / `UserBill` | 推四免一有效推广任务等价报单商品订单后复用 |
| 积分奖励 | fsgx 积分奖励、积分日志、积分释放 | 推四免一有效推广任务等价报单商品订单后复用 |
| 分销等级 | 当前分销等级模块与后台等级任务配置 | 会员等级复用当前分销等级 |
| 提现 | CRMEB `extract` | 奖励入账后走原提现链路 |
| 资产总览 | `/api/hjf/assets/overview` | 可改文案后复用或新增 `/api/syj/assets/overview` 包装 |
| 签到 | `view/uniapp/pages/users/user_sgin/index.vue``sign/*` API | 加 10 秒文章门槛 |
| 文章内容 | CRMEB 文章/CMS | 签到前置文章直接复用 |
### 3.2 复用但需改配置/小改
| 能力 | 当前 fsgx | 芍药居改造 |
|---|---|---|
| 周期人数 | `brokerage_cycle_count = 3` | 改为 `4` |
| 周期比例 | `[20,30,50]` | 改为 `[10,20,30,40]` |
| 返佣范围 | `brokerage_scope` | 新增商品默认 `is_queue_goods=1`,仍可按 `queue_only` 控制 |
| 返佣节点 | 当前代码配置决定 | 芍药居复用当前代码逻辑和后台配置,不单独固定节点 |
| 积分奖励触发 | 当前订单/报单商品触发 | 增加推广任务触发来源 |
| 分销等级任务 | 后台配置任务 | 推四免一有效推广任务按等价报单商品订单计入 |
| 用户推广中心 | CRMEB 推广中心/fsgx 佣金进度 | 增加推广任务入口和任务触发记录 |
### 3.3 必须新增
| 新增项 | 原因 |
|---|---|
| `SyjPromoteTaskServices` | 处理任务生成、进度、完成、提前兑现 |
| 任务等价订单触发适配服务 | 将推四免一有效推广任务映射为当前代码 1 个报单商品订单触发基准 |
| `eb_syj_promote_task` | fsgx 没有按 4333 元生成的任务实体 |
| `eb_syj_promote_amount_log` | fsgx 没有待推广金额累计流水 |
| `eb_syj_promote_task_record` | 需要记录每个任务的推荐来源 |
| `eb_syj_promote_settlement` | 任务完成和提前兑现需记录税费、到账、关联账单 |
| `eb_syj_promote_reward_trigger` 或等价追溯能力 | 记录任务触发分销奖金、积分奖励、分销等级任务的结果 |
| 用户端任务列表/详情页 | fsgx 没有任务页面 |
| 后台任务管理页 | 运营需要按任务排查进度、订单和奖励触发 |
| 提前兑现接口和审核 | fsgx 没有任务中途关闭结算 |
### 3.4 建议隐藏或关闭
| 能力/文案 | 芍药居处理 |
|---|---|
| 公排队列相关旧文件 | 保留代码,确保 `hjf_queue_pool_enable = 0` |
| fsgx “邀请 3 人免单”文案 | 全部改为“推四免一任务” |
| 20/30/50 阶梯奖励文案 | 不作为推四免一任务口径展示 |
| 奖励下单黄金/不可提现入口 | 首版不新增,避免与佣金提现链路冲突 |
| 代理商招商、分红、股东权益入口 | 首版不新增,等待独立方案确认 |
注意:积分奖励、积分日志、积分释放、分销等级不再隐藏或关闭,首版按当前代码逻辑复用。
---
## 4. 业务流程差异
### 4.1 fsgx 当前奖励触发流程
```text
用户 A 分享
-> 用户 B 绑定 A 为上级
-> B 购买报单商品
-> 订单达到当前触发节点
-> 系统触发 A 的分销奖金
-> 系统触发积分奖励/积分日志/积分释放
-> 系统计入分销等级任务
```
### 4.2 芍药居目标流程
```text
用户 A 自己购买商品
-> 累计 A 的待推广金额
-> 每满 4333 元生成 1 个推四免一有效推广任务
-> 该任务等价当前 1 个报单商品订单
-> 触发现有分销奖金、积分奖励、分销等级任务
-> A 分享给 B/C/D/E
-> B/C/D/E 的有效订单依次计入 A 的任务进度
-> 满 4 单完成任务,到账 4333 元,不扣 7%
-> 1-3 单可申请提前兑现,后台审核通过后扣 7%
```
### 4.3 推荐订单处理
```text
推荐订单
-> 判断订单是否参与规则商品
-> 等待订单达到后台 brokerage_timing 对应节点
- on_pay: 支付成功后
- on_confirm: 确认收货后
-> 查推荐人 spread_uid
-> 查推荐人是否有进行中 syj 任务
-> 有任务:写入任务进度
-> 无任务:不产生 syj 任务进度,仅保留 CRMEB 订单/关系数据
```
### 4.4 推四免一有效推广任务奖励触发处理
```text
推四免一有效推广任务创建
-> 写入 syj_promote_task
-> 生成奖励触发来源记录
-> 以“等价报单商品订单”参数调用当前分销奖金逻辑
-> 调用当前积分奖励/积分日志/积分释放逻辑
-> 调用当前分销等级任务统计逻辑
-> 记录每条现有流水与 syj_task_id 的关联
```
---
## 5. 文件级改造建议
### 5.1 后端新增文件
| 文件 | 说明 |
|---|---|
| `app/model/syj/SyjPromoteTask.php` | 推广任务模型 |
| `app/model/syj/SyjPromoteAmountLog.php` | 待推广金额流水模型 |
| `app/model/syj/SyjPromoteTaskRecord.php` | 任务进度明细模型 |
| `app/model/syj/SyjPromoteSettlement.php` | 任务结算模型 |
| `app/model/syj/SyjPromoteRewardTrigger.php` | 任务触发现有奖励链路记录,可按实际复用现有流水替代 |
| `app/dao/syj/*Dao.php` | 对应 DAO |
| `app/services/syj/SyjPromoteTaskServices.php` | 核心任务服务 |
| `app/services/syj/SyjPromoteRewardTriggerServices.php` | 推四免一有效推广任务等价报单订单触发适配 |
| `app/controller/api/v1/syj/SyjPromote.php` | 用户端任务接口 |
| `app/controller/admin/v1/syj/SyjPromote.php` | 后台任务接口 |
### 5.2 后端修改文件
| 文件 | 修改点 |
|---|---|
| `route/api.php` | 增加 `/api/syj/promote/*` |
| `route/admin.php` | 增加 `/adminapi/syj/promote/*` |
| `StoreOrderTakeServices.php` | 确认收货时触发消费累计与任务生成 |
| 当前分销奖金服务 | 增加推广任务来源,或提供可被任务适配服务调用的统一入口 |
| 当前积分奖励服务 | 增加推广任务来源,复用积分奖励与日志逻辑 |
| 当前分销等级任务服务 | 允许推四免一有效推广任务按等价报单商品订单计入后台配置任务 |
| `SystemConfigServices.php` | 增加芍药居任务相关配置 |
| `SystemTimerServices.php` | 若当前积分释放已启用,确保任务触发积分进入现有释放链路 |
### 5.3 UniApp 新增/修改
| 文件 | 修改点 |
|---|---|
| `view/uniapp/api/syjPromote.js` | 新增任务接口封装 |
| `view/uniapp/pages/syj/promote_task/index.vue` | 我的任务列表 |
| `view/uniapp/pages/syj/promote_task/detail.vue` | 任务详情、奖励触发记录与提前兑现 |
| `view/uniapp/pages/users/user_spread_user/index.vue` | 增加“我的推广任务”入口 |
| `view/uniapp/pages/users/user_sgin/index.vue` | 增加文章浏览 10 秒门槛 |
| 推广中心/资产页 | 展示分销奖金、积分、分销等级沿用当前页面或文案 |
| 商品详情/支付结果页 | 文案从 fsgx 改为芍药居 |
### 5.4 Admin 新增/修改
| 文件 | 修改点 |
|---|---|
| `view/admin/src/api/syjPromote.js` | 后台任务接口 |
| `view/admin/src/router/modules/syj.js` | 芍药居后台菜单 |
| `view/admin/src/pages/syj/promoteTask/index.vue` | 任务列表 |
| `view/admin/src/pages/syj/promoteTask/detail.vue` | 任务详情、奖励触发、积分触发、等级任务触发 |
| `view/admin/src/pages/syj/promoteConfig/index.vue` | 规则配置,可复用表单组件 |
| 当前分销等级配置页 | 继续作为会员等级和升级任务配置入口 |
| 当前积分日志页 | 显示推广任务来源 |
---
## 6. 配置差异
| 配置 | fsgx 值 | 芍药居值 |
|---|---|---|
| `brokerage_cycle_count` | 3 | 4 |
| `brokerage_cycle_rates` | `[20,30,50]` | `[10,20,30,40]` |
| `brokerage_scope` | `queue_only` 或按 fsgx 配置 | `queue_only`,新增商品默认 `is_queue_goods=1` |
| `brokerage_timing` | 当前配置 | 沿用当前配置 |
| `hjf_queue_pool_enable` | 0 | 0 |
| `hjf_umbrella_reward_enable` | 视 fsgx | 0 |
| `syj_task_base_amount` | 无 | 433300 |
| `syj_task_as_queue_order_enable` | 无 | 1 |
| `syj_task_trigger_brokerage_enable` | 无 | 1 |
| `syj_task_trigger_points_enable` | 无 | 1 |
| `syj_task_trigger_agent_level_enable` | 无 | 1 |
| `syj_complete_tax_rate` | 无 | 0 |
| `syj_early_cash_tax_rate` | 无 | 7 |
| `syj_early_cash_enable` | 无 | 1 |
| `syj_early_cash_audit_enable` | 无 | 1 |
| `syj_parent_task_on_child_task` | 无 | 1 |
| `syj_reward_usage_mode` | 无 | `brokerage_withdrawable` |
| `syj_agent_recruit_enable` | 无 | 0 |
分销等级、等级任务、积分奖励、积分释放的业务参数优先沿用当前后台配置,不新增平行配置体系。
---
## 7. 风险点
| 风险 | 说明 | 建议 |
|---|---|---|
| 双重奖励 | 推四免一有效推广任务等价订单触发当前奖励,若真实订单也触发同一奖励可能重复 | 明确只有推四免一有效推广任务作为芍药居分销奖金/积分触发基准,真实消费订单只负责累计待推广金额 |
| 触发幂等 | 任务生成后重复调用分销奖金/积分/等级任务会重复发放 | 增加 `task_id + trigger_type` 唯一约束或幂等锁 |
| 任务进度与奖励触发混淆 | 任务生成触发现有奖励,推荐订单计入推四免一进度,两者不是同一事件 | 流水区分 `task_created``order_pay``order_confirm` 等来源类型 |
| 积分释放兼容 | 当前积分释放可能只识别订单来源 | 增加推广任务来源标识,确保进入现有释放链路 |
| 分销等级统计偏差 | 后台等级任务可能按订单、人数、金额等维度统计 | 明确推四免一有效推广任务按 4333 元报单商品订单等价计入 |
| 推荐计数重复 | 下级订单达到 `brokerage_timing` 节点与下级任务创建都可能计入上级推四免一任务 | 增加配置开关并记录来源类型 |
| 退款冲正复杂 | 已生成任务并触发奖励后发生退款 | 首版进入异常待处理,后续设计奖励冲正 |
| 旧页面文案误导 | fsgx 页面仍有推荐三人/20/30/50 叙事 | 上线前做文案 grep 和页面走查 |
| 招商合规风险 | 19800 元招商包、加权分红、股东权益、赠品不属于商城主线 | 不进入首版,等待独立合规和运营方案 |
---
## 8. 验收对照
| 验收项 | 标准 |
|---|---|
| 任务生成 | 用户订单累计满 4333 元后生成 1 个推广任务 |
| 多任务 | 用户订单累计 10000 元后生成 2 个任务,剩余待推广金额 1334 元 |
| 等价触发 | 每个推四免一有效推广任务按当前代码 1 个报单商品订单触发基准记录 |
| 分销奖金 | 推四免一有效推广任务生成后触发现有分销奖金逻辑,并可从佣金流水反查任务 |
| 积分奖励 | 推四免一有效推广任务生成后触发现有积分奖励逻辑,并可从积分日志反查任务 |
| 分销等级 | 推四免一有效推广任务计入后台配置的分销等级任务 |
| 推荐计数 | 被推荐有效订单达到后台 `brokerage_timing` 对应节点后,推荐人最早进行中任务 +1 |
| 无任务不计进度 | 推荐人没有任务时,被推荐订单不产生 syj 任务进度 |
| 完成奖励 | 4 单完成税前 4333 元,不扣 7%,到账 4333 元 |
| 提前兑现 | 2 单提前兑现税前 1299.90 元,审核通过扣 7% 后到账 1208.91 元 |
| 签到 | 浏览文章满 10 秒才可签到 |
| fsgx 旧文案 | 三人免单、20/30/50 文案不在芍药居核心页面出现 |
| 补充规则隔离 | 奖励下单黄金、代理商招商、分红股东权益不在首版用户端和后台默认出现 |

126
docs/project-syj/deploy.md Normal file
View File

@@ -0,0 +1,126 @@
# syj 商城变体发布手册
一键脚本:`pro_v3.5.1/deploy/release-syj.sh`
覆盖范围:后端 PHP + admin 前端dist+ `.env` 切换 + Swoole 平滑 reload + 健康检查 + 失败自动回滚。
**不覆盖**`view/uniapp_v2/`HBuilderX 手动)、`view/uniapp/`本次不发布、composer 依赖、数据库迁移。
---
## 1. 首次配置
### 1.1 本机
- 安装 `rsync``ssh`、Node 16+admin 构建)
- 配置 SSH 免密:`ssh-copy-id -p 22 root@8.140.50.89`
- 验证:`ssh root@8.140.50.89 "ls /www/wwwroot/syj.fsgx.cn"`
- 脚本参数集中在 `pro_v3.5.1/deploy/syj.conf`,按实际环境修改
### 1.2 服务器(首次部署后)
- 路径:`/www/wwwroot/syj.fsgx.cn/`CRMEB 项目根,对应仓库的 `pro_v3.5.1/`
- PHP 8.0 + Swoole 4.x 已装好;`config/swoole.php` 建议显式设置 `pid_file => runtime/swoole/swoole.pid`
- Swoole 进程通过 systemd 或 screen 长驻。systemd 单元示例:
```ini
# /etc/systemd/system/syj-swoole.service
[Unit]
Description=syj-shop Swoole HTTP/WS
After=network.target mysql.service redis.service
[Service]
Type=simple
WorkingDirectory=/www/wwwroot/syj.fsgx.cn
ExecStart=/usr/bin/php think swoole
ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure
RestartSec=2
User=www
Group=www
[Install]
WantedBy=multi-user.target
```
`systemctl daemon-reload && systemctl enable --now syj-swoole`
- Nginx 反代关键片段(端口 443 → Swoole 20199
```nginx
location / {
proxy_pass http://127.0.0.1:20199;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /admin/ {
alias /www/wwwroot/syj.fsgx.cn/view/admin/dist/;
try_files $uri $uri/ /admin/index.html;
}
```
### 1.3 `.env-syj` 字段含义
- `[DATABASE]` HOSTNAME=8.140.50.89 → 与 web 同机的 MySQL
- `[REDIS]` HOSTNAME=8.140.50.89 → 同机 Redis
- `APP_DEBUG = true` ⚠️ **生产建议改为 false**(脚本不强制改,避免吞掉用户配置)
---
## 2. 日常发布
```bash
cd /Users/mac/scott2026/huangjingfen/pro_v3.5.1
bash deploy/release-syj.sh
```
阶段日志说明:
1. `Pre-flight checks` — 校验分支、`.env-syj` 存在、远端目录可达
2. `Build admin` — `view/admin` 下 `npm run build`,产物 `view/admin/dist/`
3. `Release tag: 20260510-093015-79436c01` — 时间戳 + git short SHA
4. `rsync ->` — 推送代码(带 `--backup-dir` 把被覆盖的旧文件备份到远端 `/www/wwwroot/syj.fsgx.cn-bak/<tag>/`
5. `Remote: switch .env, php think clear, Swoole reload` — 远端拷贝 `.env-syj` → `.env`、清缓存、`kill -USR1 master`
6. `Healthcheck` — 串行 curl `https://syj.fsgx.cn/api/version` 与 `/admin/`,每个 URL 重试 5 次
7. 成功:写 `deploy/.last-release` 并清理超出 `KEEP_BACKUPS=10` 的旧备份
可选标志:
- `--dry-run` 只打印 rsync 改动,不动远端
- `--skip-build` 复用已有 `view/admin/dist/`
- `--rollback <tag>` 回滚到指定备份(见下)
---
## 3. 回滚
### 3.1 自动回滚
健康检查失败时脚本自动执行:用 `<tag>` 备份覆盖远端代码 → 清缓存 → reload → 再次健康检查。仍失败则脚本退出 2备份目录保留供人工排查。
### 3.2 手动回滚
查看远端备份:
```bash
ssh root@8.140.50.89 'ls -1t /www/wwwroot/syj.fsgx.cn-bak | head -10'
```
执行:
```bash
bash deploy/release-syj.sh --rollback 20260510-093015-79436c01
```
`.env` 不在回滚范围内(`rsync --exclude='.env'`),避免误覆盖运行中的环境配置。
---
## 4. 故障排查
| 现象 | 排查 |
|------|------|
| admin 502 | Swoole 是否在跑:`systemctl status syj-swoole` 或 `ps aux \| grep "think swoole"` |
| `/api/version` 不是 200 | 看远端 `runtime/log/<日期>/cli.log`、Swoole 是否成功 reload |
| reload 后代码未生效 | 检查 `runtime/swoole/swoole.pid` 是否过期;删除后让 Swoole 重启重生 |
| `.env` 错配(连不上 DB | `cat /www/wwwroot/syj.fsgx.cn/.env` 对比 `.env-syj` |
| 健康检查超时 | 调大 `HEALTH_INTERVAL` / `HEALTH_RETRY`(在 `syj.conf` |
| `public/uploads/` 被清掉 | `rsync-exclude.txt` 有 `public/uploads/`,不会被同步;若已被覆盖,从 `<tag>` 备份恢复 |
| 分支不对脚本仍要继续 | 输入 `y` 二次确认;建议先 `git checkout syj-bypass-auth` |
远端 Swoole 日志:`/www/wwwroot/syj.fsgx.cn/runtime/swoole/swoole.log`
本地最近一次发布的 tag`pro_v3.5.1/deploy/.last-release`

45
pro_v3.5.1/.env-fsgx Normal file
View File

@@ -0,0 +1,45 @@
APP_DEBUG = true
DEFAULT_LANG = zh-cn
[APP]
DEFAULT_TIMEZONE = Asia/Shanghai
APP_KEY = 6cbfc3f329ebdee85e045c2e07ea5cfe
SYS_SECURE = false
[DATABASE]
DRIVER = mysql
TYPE = mysql
HOSTNAME = 47.94.76.64
HOSTPORT = 3306
USERNAME = root
PASSWORD = 8c4651a2cfce9076
DATABASE = fsgx-shop
PREFIX = eb_
CHARSET = utf8mb4
DEBUG = true
[REDIS]
HOSTNAME = 47.94.76.64
PORT = 6379
PASSWORD = 123456
SELECT = 0
PREFIX = 0187f3f97e956474526ccb9655799ba4
#微信支付证书配置
[RECEPTACLE]
ENABLE = false
PAYCERT = #PAYCERT#
PAYKEY = #PAYKEY#
[QUEUE]
ENABLE = false
[TIMER]
ENABLE = false
[QUEUE]
ENABLE = false
LISTEN_NAME = CRMEB_PRO
BATCH_LISTEN_NAME = CRMEB_PRO_BATCH

View File

@@ -0,0 +1,45 @@
APP_DEBUG = true
DEFAULT_LANG = zh-cn
[APP]
DEFAULT_TIMEZONE = Asia/Shanghai
APP_KEY = 6cbfc3f329ebdee85e045c2e07ea5cfe
SYS_SECURE = false
[DATABASE]
DRIVER = mysql
TYPE = mysql
HOSTNAME = 182.92.142.158
HOSTPORT = 3306
USERNAME = root
PASSWORD = 50401beb19713d5e
DATABASE = hjfshop
PREFIX = eb_
CHARSET = utf8mb4
DEBUG = true
[REDIS]
HOSTNAME = 182.92.142.158
PORT = 6379
PASSWORD = 123456
SELECT = 0
PREFIX = 0187f3f97e956474526ccb9655799ba4
#微信支付证书配置
[RECEPTACLE]
ENABLE = false
PAYCERT = #PAYCERT#
PAYKEY = #PAYKEY#
[QUEUE]
ENABLE = false
[TIMER]
ENABLE = false
[QUEUE]
ENABLE = false
LISTEN_NAME = CRMEB_PRO
BATCH_LISTEN_NAME = CRMEB_PRO_BATCH

View File

@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace app\controller\admin\v1\syj;
use app\controller\admin\AuthController;
use app\services\syj\SyjPromoteConfigServices;
use app\services\syj\SyjPromoteRewardTriggerServices;
use app\services\syj\SyjPromoteSettlementServices;
use app\services\syj\SyjPromoteTaskServices;
use think\annotation\Inject;
class PromoteController extends AuthController
{
#[Inject]
protected SyjPromoteTaskServices $services;
#[Inject]
protected SyjPromoteSettlementServices $settlementServices;
#[Inject]
protected SyjPromoteConfigServices $configServices;
#[Inject]
protected SyjPromoteRewardTriggerServices $triggerServices;
public function taskList(): mixed
{
$where = $this->request->getMore([
['keyword', ''],
['status', ''],
['reward_trigger_status', ''],
['start_time', ''],
['end_time', ''],
['page', 1],
['limit', 20],
]);
$page = (int)$where['page'];
$limit = (int)$where['limit'];
unset($where['page'], $where['limit']);
return $this->success($this->services->adminTasks($where, $page, $limit));
}
public function taskDetail(int $id): mixed
{
return $this->success($this->services->adminTaskDetail($id));
}
public function taskRecords(int $id): mixed
{
return $this->success($this->services->records($id));
}
public function cashoutList(): mixed
{
$where = $this->request->getMore([
['keyword', ''],
['audit_status', ''],
['page', 1],
['limit', 20],
]);
$where['settle_type'] = 'early_cashout';
$page = (int)$where['page'];
$limit = (int)$where['limit'];
unset($where['page'], $where['limit']);
return $this->success($this->settlementServices->adminList($where, $page, $limit));
}
public function settlementList(): mixed
{
$where = $this->request->getMore([
['keyword', ''],
['audit_status', ''],
['settle_type', ''],
['page', 1],
['limit', 20],
]);
$page = (int)$where['page'];
$limit = (int)$where['limit'];
unset($where['page'], $where['limit']);
return $this->success($this->settlementServices->adminList($where, $page, $limit));
}
public function auditCashout(int $id): mixed
{
$data = $this->request->postMore([
['status', 1],
['remark', ''],
]);
$this->settlementServices->auditCashout($id, (int)$this->adminId, (int)$data['status'], (string)$data['remark']);
return $this->success('审核成功');
}
public function getConfig(): mixed
{
return $this->success($this->configServices->getConfig());
}
public function saveConfig(): mixed
{
$data = $this->request->postMore([
['base_amount', 4333],
['target_count', 4],
['reward_rates', [10, 20, 30, 40]],
['early_cashout_fee_rate', 7],
['task_generate_timing', 'on_confirm'],
['task_order_dedupe', 'order'],
['reward_trigger_enable', 1],
]);
$this->configServices->saveConfig($data);
return $this->success('保存成功');
}
public function retryTrigger(int $taskId): mixed
{
$this->triggerServices->retry($taskId);
return $this->success('重试完成');
}
}

View File

@@ -487,10 +487,12 @@ class SystemConfig extends AuthController
public function getVersion()
{
$version = get_crmeb_version();
$label = strripos($version, 'min') === false ? 3 : 2;
$displayVersion = preg_replace('/^CRMEB-PRO\s*/i', '', (string)$version);
return $this->success([
'version' => $version,
'label' => strripos($version, 'min') === false ? 3 : 2
'version' => $displayVersion,
'label' => $label,
]);
}

View File

@@ -51,8 +51,7 @@ class Clear extends AuthController
public function delete_log()
{
$this->services->deleteLog();
return $this->success('数据缓存刷新成功!');
return $this->success('系统日志清除成功!');
}
}

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace app\controller\api\v1\syj;
use app\Request;
use app\services\syj\SyjPromoteSettlementServices;
use app\services\syj\SyjPromoteTaskServices;
use think\annotation\Inject;
class PromoteController
{
#[Inject]
protected SyjPromoteTaskServices $services;
#[Inject]
protected SyjPromoteSettlementServices $settlementServices;
public function overview(Request $request): mixed
{
return app('json')->success($this->services->overview((int)$request->uid()));
}
public function taskList(Request $request): mixed
{
[$page, $limit] = $this->page($request);
$where = ['status' => $request->param('status', '')];
return app('json')->success($this->services->userTasks((int)$request->uid(), $where, $page, $limit));
}
public function taskDetail(Request $request, int $id): mixed
{
return app('json')->success($this->services->userTaskDetail((int)$request->uid(), $id));
}
public function taskRecords(Request $request, int $id): mixed
{
$task = $this->services->userTaskDetail((int)$request->uid(), $id);
return app('json')->success($task['records'] ?? []);
}
public function cashout(Request $request, int $id): mixed
{
return app('json')->success($this->settlementServices->applyCashout((int)$request->uid(), $id), '提交成功');
}
public function amountLog(Request $request): mixed
{
[$page, $limit] = $this->page($request);
return app('json')->success($this->services->amountLogs((int)$request->uid(), $page, $limit));
}
public function settlementList(Request $request): mixed
{
[$page, $limit] = $this->page($request);
return app('json')->success($this->settlementServices->listForUser((int)$request->uid(), $page, $limit));
}
private function page(Request $request): array
{
return [
max(1, (int)$request->param('page', 1)),
min(50, max(1, (int)$request->param('limit', 15))),
];
}
}

View File

@@ -6,6 +6,7 @@
namespace app\controller\api\v1\user;
use app\Request;
use app\services\article\ArticleServices;
use app\services\user\member\MemberCardServices;
use app\services\user\UserServices;
use app\services\user\UserSignServices;
@@ -147,4 +148,26 @@ class UserSign
$this->services->setSignRemind($uid, $status);
return app('json')->success('设置成功');
}
/**
* 签到前置阅读:从"签到广告"分类下随机返回一篇文章
* 分类不存在 / 无文章 时返回 null前端跳过门槛直接签到
*/
public function required_article(ArticleServices $articleServices)
{
$article = $articleServices->getRandomByCategoryTitle('签到广告');
if (!$article) {
return app('json')->successful(null);
}
$img = $article['image_input'] ?? '';
if (is_array($img)) {
$img = $img[0] ?? '';
}
return app('json')->successful([
'id' => (int)($article['id'] ?? 0),
'title' => (string)($article['title'] ?? ''),
'image' => (string)$img,
'view_required_seconds' => 10,
]);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace app\dao\syj;
use app\dao\BaseDao;
use app\model\syj\PromoteAmountLog;
class PromoteAmountLogDao extends BaseDao
{
protected function setModel(): string
{
return PromoteAmountLog::class;
}
public function getUserList(int $uid, int $page, int $limit): array
{
$model = $this->getModel()->where('uid', $uid);
$count = (clone $model)->count();
$list = $model->order('add_time', 'desc')->page($page, $limit)->select()->toArray();
return compact('list', 'count');
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace app\dao\syj;
use app\dao\BaseDao;
use app\model\syj\PromoteRewardTrigger;
class PromoteRewardTriggerDao extends BaseDao
{
protected function setModel(): string
{
return PromoteRewardTrigger::class;
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace app\dao\syj;
use app\dao\BaseDao;
use app\model\syj\PromoteSettlement;
class PromoteSettlementDao extends BaseDao
{
protected function setModel(): string
{
return PromoteSettlement::class;
}
public function getUserList(int $uid, int $page, int $limit): array
{
$model = $this->getModel()->where('uid', $uid);
$count = (clone $model)->count();
$list = $model->order('add_time', 'desc')->page($page, $limit)->select()->toArray();
return compact('list', 'count');
}
public function getAdminList(array $where, int $page, int $limit): array
{
$model = $this->getModel()->alias('s')
->leftJoin('user u', 'u.uid = s.uid')
->leftJoin('syj_promote_task t', 't.id = s.task_id')
->field('s.*,t.task_no,u.nickname,u.phone');
if (isset($where['audit_status']) && $where['audit_status'] !== '') {
$model = $model->where('s.audit_status', (int)$where['audit_status']);
}
if (!empty($where['settle_type'])) {
$model = $model->where('s.settle_type', $where['settle_type']);
}
if (!empty($where['keyword'])) {
$model = $model->where('t.task_no|u.nickname|u.phone|u.uid', 'like', '%' . $where['keyword'] . '%');
}
$count = (clone $model)->count();
$list = $model->order('s.add_time', 'desc')->page($page, $limit)->select()->toArray();
return compact('list', 'count');
}
}

View File

@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace app\dao\syj;
use app\dao\BaseDao;
use app\model\syj\PromoteTask;
class PromoteTaskDao extends BaseDao
{
protected function setModel(): string
{
return PromoteTask::class;
}
public function getUserList(int $uid, array $where, int $page, int $limit): array
{
$model = $this->getModel()->where('uid', $uid);
if (isset($where['status']) && $where['status'] !== '') {
$model = $model->where('status', (int)$where['status']);
}
$count = (clone $model)->count();
$list = $model->order('add_time', 'desc')->page($page, $limit)->select()->toArray();
return compact('list', 'count');
}
public function getAdminList(array $where, int $page, int $limit): array
{
$model = $this->getModel()->alias('t')
->leftJoin('user u', 'u.uid = t.uid')
->field('t.*,u.nickname,u.phone');
if (!empty($where['keyword'])) {
$keyword = '%' . $where['keyword'] . '%';
$model = $model->where('t.task_no|t.source_order_no|u.nickname|u.phone|u.uid', 'like', $keyword);
}
if (isset($where['status']) && $where['status'] !== '') {
$model = $model->where('t.status', (int)$where['status']);
}
if (isset($where['reward_trigger_status']) && $where['reward_trigger_status'] !== '') {
$model = $model->where('t.reward_trigger_status', (int)$where['reward_trigger_status']);
}
if (!empty($where['start_time'])) {
$model = $model->where('t.add_time', '>=', strtotime($where['start_time']));
}
if (!empty($where['end_time'])) {
$model = $model->where('t.add_time', '<=', strtotime($where['end_time']) + 86399);
}
$count = (clone $model)->count();
$list = $model->order('t.add_time', 'desc')->page($page, $limit)->select()->toArray();
return compact('list', 'count');
}
public function getEarliestActiveTask(int $uid): ?array
{
$row = $this->getModel()
->where('uid', $uid)
->where('status', 0)
->order('add_time', 'asc')
->lock(true)
->find();
return $row ? $row->toArray() : null;
}
public function getCashoutAvailableTasks(int $uid): array
{
return $this->getModel()
->where('uid', $uid)
->where('status', 0)
->whereBetween('progress_count', [1, 3])
->select()
->toArray();
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace app\dao\syj;
use app\dao\BaseDao;
use app\model\syj\PromoteTaskRecord;
class PromoteTaskRecordDao extends BaseDao
{
protected function setModel(): string
{
return PromoteTaskRecord::class;
}
public function getTaskRecords(int $taskId, int $uid = 0): array
{
$model = $this->getModel()->where('task_id', $taskId);
if ($uid > 0) {
$model = $model->where('uid', $uid);
}
return $model->order('step_no', 'asc')->select()->toArray();
}
}

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace app\dao\syj;
use app\dao\BaseDao;
use app\model\syj\PromoteUserAmount;
class PromoteUserAmountDao extends BaseDao
{
protected function setModel(): string
{
return PromoteUserAmount::class;
}
public function lockByUid(int $uid): ?array
{
$row = $this->getModel()->where('uid', $uid)->lock(true)->find();
return $row ? $row->toArray() : null;
}
}

View File

@@ -34,11 +34,44 @@ class HjfOrderPayJob extends BaseJobs
public function doJob(int $uid, string $orderId, float $amount = 3600.00): bool
{
// 先查订单与购物车,计算报单商品总件数(公排入队 + 积分奖励共用)
$orderRow = Db::name('store_order')
->where('order_id', $orderId)
->where('is_queue_goods', 1)
->field('id,uid,is_queue_goods')
->find();
$queueQty = 1;
if ($orderRow) {
try {
$cartRows = Db::name('store_order_cart_info')
->where('oid', (int)$orderRow['id'])
->column('cart_info');
$qtySum = 0;
foreach ($cartRows as $row) {
$item = is_string($row) ? json_decode($row, true) : $row;
if (!empty($item['productInfo']['is_queue_goods'])) {
$qtySum += (int)($item['cart_num'] ?? 1);
}
}
if ($qtySum > 0) {
$queueQty = $qtySum;
}
} catch (\Throwable $qe) {
Log::warning("[HjfOrderPay] 计算报单商品数量异常使用默认值1: " . $qe->getMessage());
}
}
// PRD §3.1.2:一次购买多份时,拆分为多个独立记录分别进入公排池
$unitAmount = $queueQty > 1 ? round($amount / $queueQty, 2) : $amount;
try {
/** @var QueuePoolServices $queueServices */
$queueServices = app()->make(QueuePoolServices::class);
$queueServices->enqueue($uid, $orderId, $amount);
Log::info("[HjfOrderPay] 公排入队成功 uid={$uid} orderId={$orderId}");
for ($i = 0; $i < $queueQty; $i++) {
$subOrderId = $queueQty > 1 ? $orderId . '-' . ($i + 1) : $orderId;
$queueServices->enqueue($uid, $subOrderId, $unitAmount);
}
Log::info("[HjfOrderPay] 公排入队成功 uid={$uid} orderId={$orderId} qty={$queueQty} unitAmount={$unitAmount}");
} catch (ValidateException $e) {
Log::warning("[HjfOrderPay] 入队被锁,延迟重试 uid={$uid} orderId={$orderId}: " . $e->getMessage());
static::dispatchSece(5, [$uid, $orderId, $amount]);
@@ -77,40 +110,15 @@ class HjfOrderPayJob extends BaseJobs
}
// 等级升级完成后发放积分奖励(确保使用升级后的 agent_level
try {
$orderRow = Db::name('store_order')
->where('order_id', $orderId)
->where('is_queue_goods', 1)
->field('id,uid,is_queue_goods')
->find();
if ($orderRow) {
// fsgx B3计算订单中报单商品的总数量积分按数量倍乘
$queueQty = 1;
try {
$cartRows = Db::name('store_order_cart_info')
->where('oid', (int)$orderRow['id'])
->column('cart_info');
$qtySum = 0;
foreach ($cartRows as $row) {
$item = is_string($row) ? json_decode($row, true) : $row;
if (!empty($item['productInfo']['is_queue_goods'])) {
$qtySum += (int)($item['cart_num'] ?? 1);
}
}
if ($qtySum > 0) {
$queueQty = $qtySum;
}
} catch (\Throwable $qe) {
Log::warning("[HjfOrderPay] 计算报单商品数量异常使用默认值1: " . $qe->getMessage());
}
if ($orderRow) {
try {
/** @var PointsRewardServices $pointsService */
$pointsService = app()->make(PointsRewardServices::class);
$pointsService->reward($uid, $orderId, (int)$orderRow['id'], $preUpgradeLevels, $queueQty);
Log::info("[HjfOrderPay] 积分奖励发放完成 uid={$uid} orderId={$orderId} qty={$queueQty}");
} catch (\Throwable $e) {
Log::error("[HjfOrderPay] 积分奖励发放失败 uid={$uid} orderId={$orderId}: " . $e->getMessage());
}
} catch (\Throwable $e) {
Log::error("[HjfOrderPay] 积分奖励发放失败 uid={$uid} orderId={$orderId}: " . $e->getMessage());
}
return true;

View File

@@ -29,6 +29,7 @@ use app\services\order\StoreOrderComputedServices;
use app\services\order\StoreOrderCreateServices;
use app\services\order\StoreOrderInvoiceServices;
use app\services\order\StoreOrderTakeServices;
use app\services\syj\SyjPromoteTaskServices;
use app\services\user\channel\ChannelMerchantServices;
use app\services\user\UserMoneyServices;
use app\services\user\UserServices;
@@ -69,6 +70,17 @@ class Pay implements ListenerInterface
}
}
if (!empty($orderInfo['is_queue_goods'])) {
try {
/** @var SyjPromoteTaskServices $syjServices */
$syjServices = app()->make(SyjPromoteTaskServices::class);
$syjServices->handleOrderEffective(is_array($orderInfo) ? $orderInfo : $orderInfo->toArray(), 'order_pay');
$syjServices->handleRecommendedOrder(is_array($orderInfo) ? $orderInfo : $orderInfo->toArray(), 'order_pay');
} catch (\Throwable $e) {
Log::error('[SYJ] 支付事件处理失败 order_id=' . ($orderInfo['id'] ?? 0) . ': ' . $e->getMessage());
}
}
//创建拼团
if ($orderInfo['activity_id'] && !$orderInfo['refund_status']) {
//拼团

View File

@@ -11,6 +11,7 @@ use app\jobs\supplier\SupplierFinanceJob;use app\jobs\system\CapitalFlowJob;
use app\services\order\StoreOrderInvoiceServices;
use app\services\order\StoreOrderServices;
use app\services\order\StoreOrderStatusServices;
use app\services\syj\SyjPromoteTaskServices;
use app\services\user\UserServices;
use crmeb\interfaces\ListenerInterface;
@@ -48,6 +49,16 @@ class Refund implements ListenerInterface
//订单退款消息推送
event('notice.notice', [['data' => $data, 'order' => $order], 'order_refund']);
if (!empty($order['is_queue_goods'])) {
try {
/** @var SyjPromoteTaskServices $syjServices */
$syjServices = app()->make(SyjPromoteTaskServices::class);
$syjServices->handleRefund($order, $data);
} catch (\Throwable $e) {
\think\facade\Log::error('[SYJ] 退款事件处理失败 order_id=' . ($order['id'] ?? 0) . ': ' . $e->getMessage());
}
}
//检测主订单 是否全部退款
if ($order['pid']) {
$id = (int)$order['pid'];

View File

@@ -14,6 +14,7 @@ use app\jobs\user\UserJob;
use app\jobs\user\UserMoneyJob;
use app\jobs\user\UserSpreadJob;
use app\services\community\CommunityUserServices;
use app\services\other\QrcodeServices;
use app\services\user\UserBillServices;
use app\services\user\UserServices;
use crmeb\interfaces\ListenerInterface;
@@ -51,6 +52,7 @@ class Register implements ListenerInterface
UserSpreadJob::dispatch([$uid, $spreadUid]);
}
if ($is_new) {
$this->giveChannelCodeIntegral($uid, $userInfo);
//修改用户会员卡激活状态
UserJob::dispatchDo('setUserLevel', [$uid]);
if (app()->make(UserServices::class)->getUserOnlyTrashedCount($uid)) {
@@ -75,4 +77,49 @@ class Register implements ListenerInterface
event('notice.notice', [['spreadUid' => $spread_uid, 'user_type' => $userInfo['user_type'], 'nickname' => $userInfo['nickname']], 'bind_spread_uid']);
}
}
/**
* 新用户扫指定分销员小程序码后赠送积分,一名新用户仅在注册事件中尝试一次。
*/
protected function giveChannelCodeIntegral(int $uid, array $userInfo): void
{
if (!$uid || !(int)sys_config('channel_code_integral_status', 0)) {
return;
}
$agentUid = (int)sys_config('channel_code_agent_uid', 0);
$integral = (int)sys_config('channel_code_give_integral', 0);
$qrcodeId = (int)($userInfo['spread_qrcode_id'] ?? 0);
if (!$agentUid || $integral <= 0 || !$qrcodeId) {
return;
}
/** @var QrcodeServices $qrcodeServices */
$qrcodeServices = app()->make(QrcodeServices::class);
$qrcodeInfo = $qrcodeServices->get($qrcodeId);
if (!$qrcodeInfo || (int)$qrcodeInfo['third_id'] !== $agentUid || ($qrcodeInfo['third_type'] ?? '') !== 'spread_routine') {
return;
}
/** @var UserBillServices $userBillServices */
$userBillServices = app()->make(UserBillServices::class);
if ($userBillServices->count(['uid' => $uid, 'type' => 'channel_code_add'])) {
return;
}
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
$balance = (int)$userServices->value(['uid' => $uid], 'integral');
$newBalance = $balance + $integral;
$userServices->bcInc($uid, 'integral', (string)$integral, 'uid', 0);
$userBillServices->income(
'channel_code_give_integral',
$uid,
$integral,
$newBalance,
$qrcodeId,
0,
'扫码渠道码赠送' . $integral . '积分'
);
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace app\model\syj;
use crmeb\basic\BaseModel;
use crmeb\traits\ModelTrait;
class PromoteAmountLog extends BaseModel
{
use ModelTrait;
protected $pk = 'id';
protected $name = 'syj_promote_amount_log';
protected $autoWriteTimestamp = false;
public function setAddTimeAttr(): int
{
return time();
}
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace app\model\syj;
use crmeb\basic\BaseModel;
use crmeb\traits\ModelTrait;
class PromoteRewardTrigger extends BaseModel
{
use ModelTrait;
protected $pk = 'id';
protected $name = 'syj_promote_reward_trigger';
protected $autoWriteTimestamp = 'int';
protected $createTime = 'add_time';
protected $updateTime = 'update_time';
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace app\model\syj;
use crmeb\basic\BaseModel;
use crmeb\traits\ModelTrait;
class PromoteSettlement extends BaseModel
{
use ModelTrait;
protected $pk = 'id';
protected $name = 'syj_promote_settlement';
protected $autoWriteTimestamp = 'int';
protected $createTime = 'add_time';
protected $updateTime = 'update_time';
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace app\model\syj;
use crmeb\basic\BaseModel;
use crmeb\traits\ModelTrait;
class PromoteTask extends BaseModel
{
use ModelTrait;
protected $pk = 'id';
protected $name = 'syj_promote_task';
protected $autoWriteTimestamp = 'int';
protected $createTime = 'add_time';
protected $updateTime = 'update_time';
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace app\model\syj;
use crmeb\basic\BaseModel;
use crmeb\traits\ModelTrait;
class PromoteTaskRecord extends BaseModel
{
use ModelTrait;
protected $pk = 'id';
protected $name = 'syj_promote_task_record';
protected $autoWriteTimestamp = 'int';
protected $createTime = 'add_time';
protected $updateTime = 'update_time';
}

View File

@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace app\model\syj;
use crmeb\basic\BaseModel;
use crmeb\traits\ModelTrait;
class PromoteUserAmount extends BaseModel
{
use ModelTrait;
protected $pk = 'id';
protected $name = 'syj_promote_user_amount';
protected $autoWriteTimestamp = 'int';
protected $createTime = 'add_time';
protected $updateTime = 'update_time';
}

View File

@@ -417,7 +417,31 @@ class AgentLevelTaskServices extends BaseServices
}
/**
* 统计直推下级的报单订单数type=6 任务
* 根据订单 ID 列表统计其中报单商品的总件数cart_num 之和
*
* 一笔订单购买 N 份报单商品时 cart_num=N本方法返回所有订单的 N 之和,
* 而非订单行数。与 HjfOrderPayJob / StoreOrderCreateServices 中的 B3/B6 逻辑一致。
*/
private function sumQueueGoodsQty(array $orderIds): int
{
if (empty($orderIds)) {
return 0;
}
$cartRows = Db::name('store_order_cart_info')
->whereIn('oid', $orderIds)
->column('cart_info');
$total = 0;
foreach ($cartRows as $row) {
$item = is_string($row) ? json_decode($row, true) : $row;
if (!empty($item['productInfo']['is_queue_goods'])) {
$total += (int)($item['cart_num'] ?? 1);
}
}
return $total;
}
/**
* 统计直推下级的报单商品总份数type=6 任务)
*
* @param int $uid 用户 ID
* @return int
@@ -431,14 +455,14 @@ class AgentLevelTaskServices extends BaseServices
if (empty($directUids)) {
return 0;
}
// fsgx B5补充 refund_status 检查,与其他任务类型保持一致,排除已全额退款订单
return (int)Db::name('store_order')
$orderIds = Db::name('store_order')
->whereIn('uid', $directUids)
->where('is_queue_goods', 1)
->where('paid', 1)
->where('is_del', 0)
->whereIn('refund_status', [0, 3])
->count();
->column('id');
return $this->sumQueueGoodsQty($orderIds);
}
/**
@@ -486,14 +510,14 @@ class AgentLevelTaskServices extends BaseServices
if ($childGrade >= 2) {
continue;
}
// fsgx B5补充 refund_status 检查,排除已全额退款订单
$total += (int)Db::name('store_order')
$childOrderIds = Db::name('store_order')
->where('uid', $child['uid'])
->where('is_queue_goods', 1)
->where('paid', 1)
->where('is_del', 0)
->whereIn('refund_status', [0, 3])
->count();
->column('id');
$total += $this->sumQueueGoodsQty($childOrderIds);
$total += $this->recursiveUmbrellaCount((int)$child['uid'], $remainDepth - 1);
}
return $total;

View File

@@ -119,6 +119,37 @@ class ArticleServices extends BaseServices
return $this->dao->update($id, ['product_id' => $product_id]);
}
/**
* 按分类标题获取一条随机文章(用于签到前置阅读门槛)
* @param string $categoryTitle 分类标题(如"签到广告"
* @return array|null id/title/image_input/visit分类或文章不存在时返回 null
*/
public function getRandomByCategoryTitle(string $categoryTitle)
{
/** @var ArticleCategoryServices $categoryServices */
$categoryServices = app()->make(ArticleCategoryServices::class);
$cat = $categoryServices->getOne([
'title' => $categoryTitle,
'status' => 1,
'is_del' => 0,
'hidden' => 0,
], 'id');
if (!$cat) {
return null;
}
$where = ['cid' => (int)$cat['id'], 'status' => 1, 'hide' => 0];
$ids = $this->dao->getColumn($where, 'id');
if (!$ids) {
return null;
}
$pickId = (int)$ids[array_rand($ids)];
$article = $this->dao->getOne(['id' => $pickId], 'id,title,image_input,visit,add_time');
if (!$article) {
return null;
}
return is_array($article) ? $article : $article->toArray();
}
/**
* 获取数量
* @param array $where

View File

@@ -35,11 +35,13 @@ class SmsSendServices extends BaseServices
$services = app()->make(ServeServices::class);
$type = $this->smsType[sys_config('sms_type', 0)];
$templateMark = strtolower($template);
//获取短信ID
$templateId = CacheService::handler('TEMPLATE')->remember('NOTICE_SMS_' . $type . '_' . $template, function () use ($services, $template) {
$templateId = CacheService::handler('TEMPLATE')->remember('NOTICE_SMS_' . $type . '_' . $templateMark, function () use ($template, $templateMark) {
/** @var SystemNotificationServices $notifyServices */
$notifyServices = app()->make(SystemNotificationServices::class);
return $notifyServices->value(['mark' => $template], 'sms_id') ?? 0;
return $notifyServices->value(['mark' => $template], 'sms_id')
?: ($templateMark === $template ? 0 : ($notifyServices->value(['mark' => $templateMark], 'sms_id') ?? 0));
});
//获取发送短信驱动类型
$smsMake = $services->sms($type);

View File

@@ -1022,13 +1022,25 @@ class StoreOrderCreateServices extends BaseServices
return '0';
}
// fsgx B6统计推荐人已完成的报单订单数作为起始位次基准
$completedCount = (int)\think\facade\Db::name('store_order')
// fsgx B6统计推荐人已完成的报单商品总件数(非订单数,作为起始位次基准
$completedOrderIds = \think\facade\Db::name('store_order')
->where('spread_uid', $spread_uid)
->where('is_queue_goods', 1)
->where('paid', 1)
->where('is_del', 0)
->count();
->column('id');
$completedCount = 0;
if ($completedOrderIds) {
$completedCartRows = \think\facade\Db::name('store_order_cart_info')
->whereIn('oid', $completedOrderIds)
->column('cart_info');
foreach ($completedCartRows as $ccRow) {
$ccItem = is_string($ccRow) ? json_decode($ccRow, true) : $ccRow;
if (!empty($ccItem['productInfo']['is_queue_goods'])) {
$completedCount += (int)($ccItem['cart_num'] ?? 1);
}
}
}
// fsgx B-2B逐件轮巡每件商品取下一个位次的佣金比例后累加
$total = '0';

View File

@@ -20,6 +20,7 @@ use app\services\hjf\PointsRewardServices;
use app\services\user\UserBillServices;
use app\services\user\UserBrokerageServices;
use app\services\user\UserServices;
use app\services\syj\SyjPromoteTaskServices;
use think\annotation\Inject;
use think\exception\ValidateException;
use think\facade\Log;
@@ -148,6 +149,17 @@ class StoreOrderTakeServices extends BaseServices
}, $isTran);
}
if ($res) {
if (!empty($order['is_queue_goods'])) {
try {
/** @var SyjPromoteTaskServices $syjServices */
$syjServices = app()->make(SyjPromoteTaskServices::class);
$orderData = is_array($order) ? $order : $order->toArray();
$syjServices->handleOrderEffective($orderData, 'order_confirm');
$syjServices->handleRecommendedOrder($orderData, 'order_confirm');
} catch (\Throwable $e) {
\think\facade\Log::error('[SYJ] 确认收货处理失败 order_id=' . ($order['id'] ?? 0) . ': ' . $e->getMessage());
}
}
//订单收货事件
event('order.take', [$order, $storeTitle, $isRecord]);
return true;

View File

@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
namespace app\services\syj;
use app\services\BaseServices;
use app\services\system\config\SystemConfigServices;
use crmeb\services\SystemConfigService;
use think\exception\ValidateException;
class SyjPromoteConfigServices extends BaseServices
{
public function getConfig(): array
{
return [
'base_amount' => $this->money('syj_task_base_amount', '4333'),
'target_count' => (int)SystemConfigService::get('syj_task_target_count', 4),
'reward_rates' => $this->rewardRates(),
'early_cashout_fee_rate' => $this->money('syj_early_cashout_fee_rate', '7'),
'task_generate_timing' => (string)SystemConfigService::get('syj_task_generate_timing', 'on_confirm'),
'task_order_dedupe' => (string)SystemConfigService::get('syj_task_order_dedupe', 'order'),
'reward_trigger_enable' => (int)SystemConfigService::get('syj_reward_trigger_enable', 1),
'brokerage_timing' => (string)SystemConfigService::get('brokerage_timing', 'on_confirm'),
];
}
public function saveConfig(array $data): void
{
$baseAmount = (float)($data['base_amount'] ?? 0);
$targetCount = (int)($data['target_count'] ?? 0);
$rates = $data['reward_rates'] ?? [];
if (is_string($rates)) {
$rates = json_decode($rates, true) ?: [];
}
$feeRate = (float)($data['early_cashout_fee_rate'] ?? -1);
$generateTiming = (string)($data['task_generate_timing'] ?? 'on_confirm');
if ($baseAmount <= 0) {
throw new ValidateException('任务基准金额必须大于0');
}
if ($targetCount <= 0) {
throw new ValidateException('目标单数必须大于0');
}
if (count($rates) !== $targetCount) {
throw new ValidateException('奖励比例数量必须与目标单数一致');
}
foreach ($rates as $rate) {
if ((float)$rate < 0 || (float)$rate > 100) {
throw new ValidateException('奖励比例必须在0到100之间');
}
}
if ($feeRate < 0 || $feeRate > 100) {
throw new ValidateException('扣费比例必须在0到100之间');
}
if (!in_array($generateTiming, ['on_confirm', 'on_pay'], true)) {
throw new ValidateException('任务生成节点不正确');
}
/** @var SystemConfigServices $configServices */
$configServices = app()->make(SystemConfigServices::class);
$map = [
'syj_task_base_amount' => $this->formatMoney($baseAmount),
'syj_task_target_count' => (string)$targetCount,
'syj_task_reward_rates' => json_encode(array_values(array_map('floatval', $rates)), JSON_UNESCAPED_UNICODE),
'syj_early_cashout_fee_rate' => $this->formatMoney($feeRate),
'syj_task_generate_timing' => $generateTiming,
'syj_task_order_dedupe' => (string)($data['task_order_dedupe'] ?? 'order'),
'syj_reward_trigger_enable' => (string)(int)($data['reward_trigger_enable'] ?? 1),
];
foreach ($map as $key => $value) {
$configServices->setConfig($key, $value);
}
}
public function rewardRates(): array
{
$value = SystemConfigService::get('syj_task_reward_rates', '[10,20,30,40]');
if (is_array($value)) {
return array_values(array_map('floatval', $value));
}
$decoded = json_decode((string)$value, true);
return is_array($decoded) ? array_values(array_map('floatval', $decoded)) : [10, 20, 30, 40];
}
public function rateForStep(int $step): float
{
$rates = $this->rewardRates();
return (float)($rates[$step - 1] ?? 0);
}
private function money(string $key, string $default): string
{
return $this->formatMoney((float)SystemConfigService::get($key, $default));
}
public function formatMoney(float|string $number): string
{
return number_format((float)$number, 2, '.', '');
}
}

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace app\services\syj;
use app\dao\syj\PromoteRewardTriggerDao;
use app\services\BaseServices;
use app\services\agent\AgentLevelServices;
use app\services\hjf\PointsRewardServices;
use app\services\user\UserServices;
use think\annotation\Inject;
use think\facade\Log;
class SyjPromoteRewardTriggerServices extends BaseServices
{
#[Inject]
protected PromoteRewardTriggerDao $dao;
public function triggerForTask(array $task): void
{
if ((int)sys_config('syj_reward_trigger_enable', 1) !== 1) {
return;
}
$taskId = (int)$task['id'];
$triggerNo = $task['reward_trigger_no'] ?: 'SYJ-TASK-' . $taskId;
if (!$this->dao->be(['task_id' => $taskId])) {
$this->dao->save([
'task_id' => $taskId,
'uid' => (int)$task['uid'],
'trigger_no' => $triggerNo,
'trigger_amount' => $task['base_amount'],
'brokerage_status' => 0,
'points_status' => 0,
'level_task_status' => 0,
'error_msg' => '',
]);
}
$status = [
'points_status' => 1,
'level_task_status' => 1,
'brokerage_status' => 1,
'error_msg' => '',
];
try {
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
$uids = [(int)$task['uid']];
$user = $userServices->get((int)$task['uid'], ['uid', 'spread_uid', 'agent_level']);
$spreadUid = $user ? (int)($user['spread_uid'] ?? 0) : 0;
if ($spreadUid > 0) {
$uids[] = $spreadUid;
$spreadUser = $userServices->get($spreadUid, ['uid', 'spread_uid', 'agent_level']);
$twoUid = $spreadUser ? (int)($spreadUser['spread_uid'] ?? 0) : 0;
if ($twoUid > 0) {
$uids[] = $twoUid;
}
}
$preUpgradeLevels = [];
foreach (array_unique($uids) as $uid) {
if ($uid <= 0) continue;
$row = $userServices->get((int)$uid, ['uid', 'agent_level']);
$preUpgradeLevels[(int)$uid] = $row ? (int)($row['agent_level'] ?? 0) : 0;
}
/** @var AgentLevelServices $agentLevelServices */
$agentLevelServices = app()->make(AgentLevelServices::class);
$agentLevelServices->checkUserLevelFinish((int)$task['uid'], array_keys($preUpgradeLevels));
} catch (\Throwable $e) {
$status['level_task_status'] = 2;
$status['error_msg'] = '等级任务触发失败:' . $e->getMessage();
Log::error('[SYJ] 等级任务触发失败 task=' . $taskId . ' ' . $e->getMessage());
}
try {
/** @var PointsRewardServices $pointsRewardServices */
$pointsRewardServices = app()->make(PointsRewardServices::class);
$pointsRewardServices->reward((int)$task['uid'], $triggerNo, (int)$task['source_order_id'], $preUpgradeLevels ?? [], 1);
} catch (\Throwable $e) {
$status['points_status'] = 2;
$status['error_msg'] = trim($status['error_msg'] . ' 积分触发失败:' . $e->getMessage());
Log::error('[SYJ] 积分触发失败 task=' . $taskId . ' ' . $e->getMessage());
}
$this->dao->update(['task_id' => $taskId], $status + ['retry_count' => $this->dao->value(['task_id' => $taskId], 'retry_count')]);
}
public function retry(int $taskId): void
{
/** @var SyjPromoteTaskServices $taskServices */
$taskServices = app()->make(SyjPromoteTaskServices::class);
$task = $taskServices->getTask($taskId);
if (!$task) {
return;
}
$row = $this->dao->getOne(['task_id' => $taskId]);
if ($row) {
$this->dao->update(['task_id' => $taskId], ['retry_count' => (int)$row['retry_count'] + 1]);
}
$this->triggerForTask($task);
$taskServices->refreshRewardStatus($taskId);
}
}

View File

@@ -0,0 +1,168 @@
<?php
declare(strict_types=1);
namespace app\services\syj;
use app\dao\syj\PromoteSettlementDao;
use app\dao\syj\PromoteTaskDao;
use app\services\BaseServices;
use app\services\user\UserBrokerageServices;
use app\services\user\UserServices;
use think\annotation\Inject;
use think\exception\ValidateException;
use think\facade\Db;
class SyjPromoteSettlementServices extends BaseServices
{
#[Inject]
protected PromoteSettlementDao $dao;
#[Inject]
protected PromoteTaskDao $taskDao;
public function complete(array $task): array
{
return Db::transaction(function () use ($task) {
$taskId = (int)$task['id'];
if ($this->dao->be(['task_id' => $taskId, 'settle_type' => 'complete'])) {
return $this->dao->getOne(['task_id' => $taskId, 'settle_type' => 'complete'])->toArray();
}
$gross = $this->money($task['base_amount']);
$settlement = $this->dao->save([
'task_id' => $taskId,
'uid' => (int)$task['uid'],
'settle_type' => 'complete',
'gross_amount' => $gross,
'fee_rate' => '0.00',
'fee_amount' => '0.00',
'net_amount' => $gross,
'audit_status' => 1,
'audit_time' => time(),
])->toArray();
$brokerageId = $this->grantBrokerage((int)$task['uid'], $gross, $taskId, '推四免一任务完成结算,任务号:' . $task['task_no']);
$this->dao->update((int)$settlement['id'], ['brokerage_id' => $brokerageId]);
$this->taskDao->update($taskId, ['status' => 1, 'finish_time' => time()]);
return $settlement;
});
}
public function applyCashout(int $uid, int $taskId): array
{
return Db::transaction(function () use ($uid, $taskId) {
$task = $this->taskDao->get($taskId);
if (!$task || (int)$task['uid'] !== $uid) {
throw new ValidateException('推广任务不存在');
}
if ((int)$task['status'] !== 0) {
throw new ValidateException('当前任务状态不可提前兑现');
}
$progress = (int)$task['progress_count'];
if ($progress < 1 || $progress >= (int)$task['target_count']) {
throw new ValidateException('当前进度不可提前兑现');
}
if ($this->dao->be(['task_id' => $taskId, 'settle_type' => 'early_cashout'])) {
throw new ValidateException('已提交提前兑现申请');
}
[$gross, $fee, $net] = $this->calcEarlyCashout($task->toArray());
$settlement = $this->dao->save([
'task_id' => $taskId,
'uid' => $uid,
'settle_type' => 'early_cashout',
'gross_amount' => $gross,
'fee_rate' => $this->money($task['fee_rate']),
'fee_amount' => $fee,
'net_amount' => $net,
'audit_status' => 0,
])->toArray();
$this->taskDao->update($taskId, ['status' => 5]);
return $settlement;
});
}
public function auditCashout(int $settlementId, int $auditUid, int $status, string $remark = ''): void
{
Db::transaction(function () use ($settlementId, $auditUid, $status, $remark) {
$settlement = $this->dao->get($settlementId);
if (!$settlement || $settlement['settle_type'] !== 'early_cashout') {
throw new ValidateException('提前兑现申请不存在');
}
if ((int)$settlement['audit_status'] !== 0) {
throw new ValidateException('申请已审核');
}
$task = $this->taskDao->get((int)$settlement['task_id']);
if (!$task || (int)$task['status'] !== 5) {
throw new ValidateException('任务状态不可审核');
}
if ($status === 1) {
$brokerageId = $this->grantBrokerage((int)$settlement['uid'], (string)$settlement['net_amount'], (int)$task['id'], '推四免一提前兑现到账,任务号:' . $task['task_no']);
$this->dao->update($settlementId, [
'audit_status' => 1,
'audit_uid' => $auditUid,
'audit_remark' => $remark,
'audit_time' => time(),
'brokerage_id' => $brokerageId,
]);
$this->taskDao->update((int)$task['id'], ['status' => 2, 'cashout_time' => time()]);
} elseif ($status === 2) {
$this->dao->update($settlementId, [
'audit_status' => 2,
'audit_uid' => $auditUid,
'audit_remark' => $remark,
'audit_time' => time(),
]);
$this->taskDao->update((int)$task['id'], ['status' => 0]);
} else {
throw new ValidateException('审核状态不正确');
}
});
}
public function listForUser(int $uid, int $page, int $limit): array
{
return $this->dao->getUserList($uid, $page, $limit);
}
public function adminList(array $where, int $page, int $limit): array
{
return $this->dao->getAdminList($where, $page, $limit);
}
private function calcEarlyCashout(array $task): array
{
$rates = json_decode((string)$task['reward_rates'], true);
if (!is_array($rates)) {
$rates = [10, 20, 30, 40];
}
$step = max(1, (int)$task['progress_count']);
$rate = (float)($rates[$step - 1] ?? 0);
$gross = bcmul((string)$task['base_amount'], bcdiv((string)$rate, '100', 4), 2);
$fee = bcmul($gross, bcdiv((string)$task['fee_rate'], '100', 4), 2);
$net = bcsub($gross, $fee, 2);
return [$gross, $fee, $net];
}
private function grantBrokerage(int $uid, string $amount, int $taskId, string $mark): int
{
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
$current = (string)($userServices->value(['uid' => $uid], 'brokerage_price') ?: '0');
$balance = bcadd($current, $amount, 2);
$userServices->bcInc($uid, 'brokerage_price', $amount, 'uid');
/** @var UserBrokerageServices $brokerageServices */
$brokerageServices = app()->make(UserBrokerageServices::class);
$row = $brokerageServices->income('syj_promote_settlement', $uid, [
'number' => $amount,
'mark' => $mark,
], $balance, $taskId);
if ($row && method_exists($row, 'getData')) {
return (int)$row->getData('id');
}
return 0;
}
private function money(float|string $amount): string
{
return number_format((float)$amount, 2, '.', '');
}
}

View File

@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
namespace app\services\syj;
use app\dao\syj\PromoteAmountLogDao;
use app\dao\syj\PromoteRewardTriggerDao;
use app\dao\syj\PromoteSettlementDao;
use app\dao\syj\PromoteTaskDao;
use app\dao\syj\PromoteTaskRecordDao;
use app\dao\syj\PromoteUserAmountDao;
use app\services\BaseServices;
use think\annotation\Inject;
use think\exception\ValidateException;
use think\facade\Db;
use think\facade\Log;
class SyjPromoteTaskServices extends BaseServices
{
#[Inject]
protected PromoteTaskDao $taskDao;
#[Inject]
protected PromoteUserAmountDao $userAmountDao;
#[Inject]
protected PromoteAmountLogDao $amountLogDao;
#[Inject]
protected PromoteTaskRecordDao $recordDao;
#[Inject]
protected PromoteSettlementDao $settlementDao;
#[Inject]
protected PromoteRewardTriggerDao $triggerDao;
public function handleOrderEffective(array $order, string $sourceType = 'order_confirm'): void
{
if (!$this->isQueueOrder($order) || empty($order['uid'])) {
return;
}
$generateTiming = (string)sys_config('syj_task_generate_timing', 'on_confirm');
if (($generateTiming === 'on_confirm' && $sourceType !== 'order_confirm') || ($generateTiming === 'on_pay' && $sourceType !== 'order_pay')) {
return;
}
Db::transaction(function () use ($order) {
$orderId = (int)$order['id'];
if ($this->amountLogDao->be(['order_id' => $orderId, 'type' => 'income'])) {
return;
}
$uid = (int)$order['uid'];
$amount = $this->validAmount($order);
if (bccomp($amount, '0', 2) <= 0) {
return;
}
$summary = $this->lockUserAmount($uid);
$before = (string)$summary['pending_amount'];
$afterIncome = bcadd($before, $amount, 2);
$config = app()->make(SyjPromoteConfigServices::class)->getConfig();
$base = $config['base_amount'];
$taskCount = (int)floor((float)bcdiv($afterIncome, $base, 4));
$remaining = bcsub($afterIncome, bcmul((string)$taskCount, $base, 2), 2);
$this->amountLogDao->save([
'uid' => $uid,
'order_id' => $orderId,
'order_no' => (string)$order['order_id'],
'amount' => $amount,
'before_amount' => $before,
'after_amount' => $afterIncome,
'type' => 'income',
'mark' => '有效消费计入待推广金额',
]);
$this->userAmountDao->update((int)$summary['id'], ['pending_amount' => $remaining]);
for ($i = 1; $i <= $taskCount; $i++) {
$task = $this->createTask($uid, $order, $i, $config);
$this->amountLogDao->save([
'uid' => $uid,
'order_id' => $orderId,
'order_no' => (string)$order['order_id'],
'amount' => '-' . $base,
'before_amount' => $afterIncome,
'after_amount' => $remaining,
'type' => 'consume_task_' . $i,
'link_id' => (int)$task['id'],
'mark' => '生成推四免一推广任务',
]);
}
});
}
public function handleRecommendedOrder(array $order, string $sourceType): void
{
if (!$this->isQueueOrder($order) || empty($order['spread_uid'])) {
return;
}
$timing = (string)sys_config('brokerage_timing', 'on_confirm');
if (($timing === 'on_pay' && $sourceType !== 'order_pay') || ($timing === 'on_confirm' && $sourceType !== 'order_confirm')) {
return;
}
Db::transaction(function () use ($order, $sourceType, $timing) {
$orderId = (int)$order['id'];
if ($this->recordDao->be(['order_id' => $orderId])) {
return;
}
$task = $this->taskDao->getEarliestActiveTask((int)$order['spread_uid']);
if (!$task) {
return;
}
$step = (int)$task['progress_count'] + 1;
$rates = json_decode((string)$task['reward_rates'], true);
$rate = (float)($rates[$step - 1] ?? 0);
$this->recordDao->save([
'task_id' => (int)$task['id'],
'uid' => (int)$task['uid'],
'order_uid' => (int)$order['uid'],
'order_id' => $orderId,
'order_no' => (string)$order['order_id'],
'source_type' => $sourceType,
'trigger_timing' => $timing,
'step_no' => $step,
'reward_rate' => $rate,
'status' => 1,
]);
$this->taskDao->update((int)$task['id'], ['progress_count' => $step]);
$task['progress_count'] = $step;
if ($step >= (int)$task['target_count']) {
app()->make(SyjPromoteSettlementServices::class)->complete($task);
}
});
}
public function handleRefund(array $order, array $refundData = []): void
{
Db::transaction(function () use ($order, $refundData) {
$orderId = (int)$order['id'];
$sourceTasks = $this->taskDao->getColumn(['source_order_id' => $orderId], 'id,status', 'id');
if ($sourceTasks) {
foreach ($sourceTasks as $taskId => $status) {
if ((int)$status === 0) {
$this->taskDao->update((int)$taskId, ['status' => 4, 'exception_reason' => '来源订单退款']);
}
}
}
$record = $this->recordDao->getOne(['order_id' => $orderId, 'status' => 1]);
if ($record) {
$task = $this->taskDao->get((int)$record['task_id']);
$this->recordDao->update((int)$record['id'], ['status' => 2]);
if ($task && (int)$task['status'] === 0) {
$this->taskDao->update((int)$task['id'], ['progress_count' => max(0, (int)$task['progress_count'] - 1)]);
} elseif ($task) {
$this->taskDao->update((int)$task['id'], ['status' => 4, 'exception_reason' => '推荐订单退款,需人工复核']);
}
}
});
}
public function overview(int $uid): array
{
$summary = $this->userAmountDao->getOne(['uid' => $uid]);
$pending = $summary ? (string)$summary['pending_amount'] : '0.00';
$base = (string)sys_config('syj_task_base_amount', '4333');
return [
'pending_amount' => $this->money($pending),
'base_amount' => $this->money($base),
'active_task_count' => $this->taskDao->count(['uid' => $uid, 'status' => 0]),
'completed_task_count' => $this->taskDao->count(['uid' => $uid, 'status' => 1]),
'cashout_task_count' => $this->taskDao->count(['uid' => $uid, 'status' => 2]),
'available_cashout_amount' => $this->availableCashoutAmount($uid),
];
}
public function userTasks(int $uid, array $where, int $page, int $limit): array
{
return $this->taskDao->getUserList($uid, $where, $page, $limit);
}
public function adminTasks(array $where, int $page, int $limit): array
{
return $this->taskDao->getAdminList($where, $page, $limit);
}
public function getTask(int $taskId): ?array
{
$row = $this->taskDao->get($taskId);
return $row ? $row->toArray() : null;
}
public function userTaskDetail(int $uid, int $taskId): array
{
$task = $this->getTask($taskId);
if (!$task || (int)$task['uid'] !== $uid) {
throw new ValidateException('推广任务不存在');
}
return $this->decorateTask($task);
}
public function adminTaskDetail(int $taskId): array
{
$task = $this->getTask($taskId);
if (!$task) {
throw new ValidateException('推广任务不存在');
}
return $this->decorateTask($task);
}
public function records(int $taskId, int $uid = 0): array
{
return $this->recordDao->getTaskRecords($taskId, $uid);
}
public function amountLogs(int $uid, int $page, int $limit): array
{
return $this->amountLogDao->getUserList($uid, $page, $limit);
}
public function refreshRewardStatus(int $taskId): void
{
$trigger = $this->triggerDao->getOne(['task_id' => $taskId]);
if (!$trigger) {
return;
}
$failed = in_array(2, [(int)$trigger['points_status'], (int)$trigger['level_task_status'], (int)$trigger['brokerage_status']], true);
$success = (int)$trigger['points_status'] === 1 && (int)$trigger['level_task_status'] === 1 && (int)$trigger['brokerage_status'] === 1;
$this->taskDao->update($taskId, ['reward_trigger_status' => $success ? 1 : ($failed ? 2 : 3)]);
}
private function createTask(int $uid, array $order, int $splitIndex, array $config): array
{
$taskNo = 'SYJ' . date('YmdHis') . $uid . str_pad((string)$splitIndex, 2, '0', STR_PAD_LEFT) . mt_rand(100, 999);
$row = $this->taskDao->save([
'uid' => $uid,
'task_no' => $taskNo,
'source_order_id' => (int)$order['id'],
'source_order_no' => (string)$order['order_id'],
'source_split_index' => $splitIndex,
'base_amount' => $config['base_amount'],
'reward_rates' => json_encode($config['reward_rates'], JSON_UNESCAPED_UNICODE),
'fee_rate' => $config['early_cashout_fee_rate'],
'status' => 0,
'progress_count' => 0,
'target_count' => $config['target_count'],
'reward_trigger_status' => 3,
'reward_trigger_no' => $taskNo,
])->toArray();
try {
app()->make(SyjPromoteRewardTriggerServices::class)->triggerForTask($row);
$this->refreshRewardStatus((int)$row['id']);
} catch (\Throwable $e) {
Log::error('[SYJ] 任务奖励触发异常 task=' . $row['id'] . ' ' . $e->getMessage());
$this->taskDao->update((int)$row['id'], ['reward_trigger_status' => 2, 'exception_reason' => $e->getMessage()]);
}
return $row;
}
private function lockUserAmount(int $uid): array
{
$row = $this->userAmountDao->lockByUid($uid);
if (!$row) {
$this->userAmountDao->save(['uid' => $uid, 'pending_amount' => '0.00']);
$row = $this->userAmountDao->lockByUid($uid);
}
return $row;
}
private function isQueueOrder(array $order): bool
{
return !empty($order['is_queue_goods']) && empty($order['is_del']) && empty($order['is_system_del']) && (int)($order['paid'] ?? 1) === 1;
}
private function validAmount(array $order): string
{
$pay = (string)($order['pay_price'] ?? '0');
$refund = (string)($order['refund_price'] ?? '0');
$amount = bcsub($pay, $refund, 2);
return bccomp($amount, '0', 2) > 0 ? $amount : '0.00';
}
private function decorateTask(array $task): array
{
$task['records'] = $this->recordDao->getTaskRecords((int)$task['id']);
$task['settlement'] = ($this->settlementDao->getOne(['task_id' => (int)$task['id']]) ?: null)?->toArray();
$task['reward_trigger'] = ($this->triggerDao->getOne(['task_id' => (int)$task['id']]) ?: null)?->toArray();
return $task;
}
private function availableCashoutAmount(int $uid): string
{
$tasks = $this->taskDao->getCashoutAvailableTasks($uid);
$total = '0.00';
foreach ($tasks as $task) {
$rates = json_decode((string)$task['reward_rates'], true) ?: [10, 20, 30, 40];
$rate = (float)($rates[((int)$task['progress_count']) - 1] ?? 0);
$gross = bcmul((string)$task['base_amount'], bcdiv((string)$rate, '100', 4), 2);
$fee = bcmul($gross, bcdiv((string)$task['fee_rate'], '100', 4), 2);
$total = bcadd($total, bcsub($gross, $fee, 2), 2);
}
return $total;
}
private function money(float|string $amount): string
{
return number_format((float)$amount, 2, '.', '');
}
}

View File

@@ -15,10 +15,7 @@ class LocalCopyrightService
{
public function getCopyright(): array
{
$config = SystemConfigService::more([
['copyright', ''],
['copyright_img', ''],
]);
$config = SystemConfigService::more(['copyright', 'copyright_img']);
return [
'copyrightContext' => $config['copyright'] ?? '',

View File

@@ -324,6 +324,25 @@ class SystemConfigServices extends BaseServices implements ServeConfigInterface
}
}
/**
* 更新单个系统配置值
* @param string $configName
* @param mixed $value
* @return bool
*/
public function setConfig(string $configName, $value): bool
{
$config = $this->dao->getOne(['menu_name' => $configName]);
if (!$config) {
return false;
}
$config['value'] = $value;
$this->valiDateValue($config);
$this->dao->update($configName, ['value' => json_encode($value)], 'menu_name');
\crmeb\services\SystemConfigService::clear();
return true;
}
/**
* 获取配置并分页
* @param array $where
@@ -1610,7 +1629,8 @@ class SystemConfigServices extends BaseServices implements ServeConfigInterface
$data = $this->getConfigAllField([
'integral_ratio_status', 'integral_ratio', 'integral_max_type', 'integral_max_num', 'integral_max_rate', 'order_give_integral', 'integral_effective_status',
'integral_effective_time', 'next_clear_month_time', 'next_clear_quarter_time', 'next_clear_year_time', 'integral_frozen'
'integral_effective_time', 'next_clear_month_time', 'next_clear_quarter_time', 'next_clear_year_time', 'integral_frozen',
'channel_code_integral_status', 'channel_code_agent_uid', 'channel_code_give_integral'
]);
/** @var UserIntegralServices $userIntergralServices */
@@ -1631,6 +1651,10 @@ class SystemConfigServices extends BaseServices implements ServeConfigInterface
])->options($this->getOptions($data['integral_max_type']['parameter']))
])->info($data['integral_ratio_status']['desc']),
Build::inputNum('order_give_integral', $data['order_give_integral']['info'], $data['order_give_integral']['value'])->info($data['order_give_integral']['desc'])->min(0),
Build::switch('channel_code_integral_status', $data['channel_code_integral_status']['info'], (int)$data['channel_code_integral_status']['value'])
->falseValue('关闭', 0)->trueValue('开启', 1)->info($data['channel_code_integral_status']['desc']),
Build::inputNum('channel_code_agent_uid', $data['channel_code_agent_uid']['info'], $data['channel_code_agent_uid']['value'])->info($data['channel_code_agent_uid']['desc'])->min(0),
Build::inputNum('channel_code_give_integral', $data['channel_code_give_integral']['info'], $data['channel_code_give_integral']['value'])->info($data['channel_code_give_integral']['desc'])->min(0),
Build::radio('integral_effective_status', $data['integral_effective_status']['info'], $data['integral_effective_status']['value'])
->info($data['integral_effective_status']['desc'])->control(1, [
Build::radio('integral_effective_time', $data['integral_effective_time']['info'], $data['integral_effective_time']['value'])

View File

@@ -20,7 +20,10 @@ class ClearServices extends BaseServices
*/
protected function delDirAndFile($dirName)
{
$list = glob($dirName . '*');
if (!is_dir($dirName)) {
return;
}
$list = glob($dirName . '*') ?: [];
foreach ($list as $file) {
if (is_dir($file))
$this->delDirAndFile($file . DS);

View File

@@ -7,9 +7,9 @@ declare (strict_types=1);
namespace app\services\user;
use app\jobs\notice\SmsJob;
use app\services\BaseServices;
use app\dao\user\UserDao;
use app\services\message\sms\SmsSendServices;
use app\services\message\sms\SmsRecordServices;
use app\services\wechat\WechatUserServices;
use crmeb\exceptions\ApiException;
@@ -165,7 +165,9 @@ class LoginServices extends BaseServices
$code = rand(100000, 999999);
$data['code'] = $code;
$data['time'] = $time;
$res = SmsJob::dispatch([$phone, $data, 'VERIFICATION_CODE_TIME']);
/** @var SmsSendServices $smsServices */
$smsServices = app()->make(SmsSendServices::class);
$res = $smsServices->send(true, $phone, $data, 'VERIFICATION_CODE_TIME');
if (!$res)
throw new ValidateException('短信平台验证码发送失败');
return $code;

View File

@@ -135,6 +135,14 @@ class UserBillServices extends BaseServices
'status' => 1,
'pm' => 1
],
'channel_code_give_integral' => [
'title' => '渠道码赠送积分',
'category' => 'integral',
'type' => 'channel_code_add',
'mark' => '扫码渠道码赠送{%num%}积分',
'status' => 1,
'pm' => 1
],
'level_give_integral' => [
'title' => '会员卡激活赠送积分',
'category' => 'integral',

View File

@@ -95,6 +95,13 @@ class UserBrokerageServices extends BaseServices
'status' => 1,
'pm' => 0
],
'syj_promote_settlement' => [
'title' => '芍药居推广任务结算',
'type' => 'syj_promote_settlement',
'mark' => '{%mark%}',
'status' => 1,
'pm' => 1
],
'get_staff_brokerage' => [
'title' => '获得员工推广订单佣金',
'type' => 'staff_brokerage',

View File

@@ -376,7 +376,9 @@ class UserServices extends BaseServices
// 用户注册成功事件
$userInfo = array_merge($res->toArray(), [
'unionid' => $user['unionid'] ?? ''
'unionid' => $user['unionid'] ?? '',
'spread_qrcode_id' => (int)($user['spread_qrcode_id'] ?? 0),
'spread_qrcode_type' => $user['spread_qrcode_type'] ?? ''
]);
event('user.register', [$userInfo, true, $spreadUid]);

View File

@@ -122,7 +122,7 @@ class RoutineServices extends BaseServices
$wechatInfo['spread_code'] = $spread_code;
$wechatInfo['session_key'] = $userInfoConfig['session_key'] ?? '';
$wechatInfo['phone'] = $phone;
$createData = [$openid, $wechatInfo, $spread_uid, 'routine', 'routine'];
[$openid, $wechatInfo, $spread_uid, $login_type, $userType] = $createData = $this->routineOauth($wechatInfo);
}
/** @var WechatUserServices $wechatUserServices */
$wechatUserServices = app()->make(WechatUserServices::class);
@@ -268,18 +268,44 @@ class RoutineServices extends BaseServices
$routineInfo['user_type'] = 'routine';//用户类型
$routineInfo['phone'] = $routine['phone'] ?? $routine['purePhoneNumber'] ?? '';
$spread_uid = (int)($routine['spread_uid'] ?? 0);//绑定关系uid
if (!$spread_uid && isset($routine['spread_code']) && $routine['spread_code']) {
//获取是否有扫码进小程序
/** @var QrcodeServices $qrcode */
$qrcode = app()->make(QrcodeServices::class);
$info = $qrcode->get((int)$routine['spread_code']);
if ($info) {
$spread_uid = $info['third_id'];
}
$routineInfo = $this->appendSpreadQrcodeInfo($routineInfo, $routine, $spread_uid);
if (!$spread_uid && isset($routineInfo['spread_uid'])) {
$spread_uid = (int)$routineInfo['spread_uid'];
}
return [$routine['openid'] ?? '', $routineInfo, $spread_uid, $routine['login_type'] ?? 'routine', 'routine'];
}
/**
* 尽量根据扫码参数补齐二维码信息,避免前端同时传 spread_uid / spread_code 时丢失渠道码来源。
*/
protected function appendSpreadQrcodeInfo(array $routineInfo, array $routine, int $spread_uid = 0): array
{
$spreadCode = (int)($routine['spread_code'] ?? 0);
if ($spreadCode <= 0) {
if ($spread_uid > 0) {
$routineInfo['spread_uid'] = $spread_uid;
}
return $routineInfo;
}
/** @var QrcodeServices $qrcode */
$qrcode = app()->make(QrcodeServices::class);
$info = $qrcode->get($spreadCode);
if (!$info) {
if ($spread_uid > 0) {
$routineInfo['spread_uid'] = $spread_uid;
}
return $routineInfo;
}
$qrcodeSpreadUid = (int)($info['third_id'] ?? 0);
$routineInfo['spread_qrcode_id'] = (int)($info['id'] ?? 0);
$routineInfo['spread_qrcode_type'] = $info['third_type'] ?? '';
$routineInfo['spread_uid'] = $qrcodeSpreadUid ?: $spread_uid;
return $routineInfo;
}
/**
* 获取返回信息
* @param $user

View File

@@ -0,0 +1,68 @@
-- 芍药居syj-shop分销等级 + 等级任务 baseline
-- 数据来源docs/project-shaoyaoju/prd-require.md 7.2 节
-- 用法mysql ... syj-shop < pro_v3.5.1/database/syj_agent_level_seed.sql
-- 重新初始化:清空两张表并按 PRD 写入 4 级 + 3 个升级任务(普通会员为基础级,无任务)
--
-- 关联代码:
-- app/services/agent/AgentLevelTaskServices.php $TaskType
-- type=7 伞下报单业绩 / type=8 最低直推人数
--
-- ⚠️ 说明(与 PRD 的差异):
-- 1. eb_agent_level 仅有 direct_reward_points / umbrella_reward_points 两列,
-- 业务总监原文"业务经理差补 200 + 业务主管差补 500"无法用两列完整表达;
-- 本 seed 把 200最近一级差补写入 umbrella_reward_points
-- "业务主管差补 500" 需要业务层另行实现(建议放到 system_config 或代码常量)。
-- 2. 业务主管原文"团队每新增 1 单 500 积分"
-- 团队=直推+伞下,但只有一列存储,本 seed 写入 direct_reward_points=500。
-- 3. 一级/二级佣金比例 (one_brokerage / two_brokerage) 全部置 0
-- SYJ 主线奖励走"推四免一"任务结算10/20/30/40 阶梯,扣 7%),不走百分比直接分佣。
--
-- ⚠️ 副作用:
-- - eb_agent_level_task_record 中可能存在引用旧 task_id 的用户记录,
-- re-init 后这些记录会变成孤儿数据。下方提供了可选的清理语句(默认注释掉)。
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- 分销等级 eb_agent_level
-- ----------------------------
TRUNCATE TABLE `eb_agent_level`;
INSERT INTO `eb_agent_level`
(`id`, `name`, `image`, `color`, `one_brokerage`, `two_brokerage`, `grade`, `status`, `is_del`, `add_time`, `direct_reward_points`, `umbrella_reward_points`)
VALUES
(1, '普通会员', '/uploads/system/agent_level_1.png', '#999999', 0, 0, 1, 1, 0, UNIX_TIMESTAMP(), 0, 0),
(2, '业务主管', '/uploads/system/agent_level_2.png', '#5D7DAC', 0, 0, 2, 1, 0, UNIX_TIMESTAMP(), 500, 0),
(3, '业务经理', '/uploads/system/agent_level_3.png', '#5856D6', 0, 0, 3, 1, 0, UNIX_TIMESTAMP(), 800, 300),
(4, '业务总监', '/uploads/system/agent_level_4.png', '#1DB0FC', 0, 0, 4, 1, 0, UNIX_TIMESTAMP(), 1000, 200);
-- ----------------------------
-- 分销等级任务 eb_agent_level_task
-- 普通会员 (level_id=1) 为基础级,注册并完成首单即拥有,无升级任务
-- 升业务主管 (level_id=2) :直推 3 位有效会员
-- 升业务经理 (level_id=3) :团队累计 30 单
-- 升业务总监 (level_id=4) :团队累计 100 单
-- ----------------------------
TRUNCATE TABLE `eb_agent_level_task`;
INSERT INTO `eb_agent_level_task`
(`id`, `level_id`, `name`, `type`, `number`, `desc`, `is_must`, `sort`, `status`, `is_del`, `add_time`)
VALUES
(1, 2, '直推有效会员', 8, 3, '直推 3 位有效会员', 0, 0, 1, 0, UNIX_TIMESTAMP()),
(2, 3, '团队累计单数', 7, 30, '团队累计 30 单', 0, 0, 1, 0, UNIX_TIMESTAMP()),
(3, 4, '团队累计单数', 7, 100, '团队累计 100 单', 0, 0, 1, 0, UNIX_TIMESTAMP());
-- ----------------------------
-- 可选:清理旧 task_id 留下的用户记录(默认注释;执行将丢失用户进度)
-- ----------------------------
-- TRUNCATE TABLE `eb_agent_level_task_record`;
SET FOREIGN_KEY_CHECKS = 1;
-- ----------------------------
-- 校验
-- ----------------------------
-- SELECT id, name, grade, direct_reward_points AS direct_pts, umbrella_reward_points AS umbrella_pts, status
-- FROM eb_agent_level WHERE is_del=0 ORDER BY grade;
-- SELECT t.id, t.level_id, l.name AS level_name, t.name, t.type, t.number, t.desc
-- FROM eb_agent_level_task t LEFT JOIN eb_agent_level l ON t.level_id=l.id
-- WHERE t.is_del=0 ORDER BY t.level_id, t.id;

View File

@@ -0,0 +1,145 @@
-- 芍药居推四免一任务迁移
-- 执行前请确认当前库前缀为 eb_并已备份生产数据。
CREATE TABLE IF NOT EXISTS `eb_syj_promote_user_amount` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`uid` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '用户ID',
`pending_amount` decimal(10,2) NOT NULL DEFAULT '0.00' COMMENT '当前待推广金额',
`add_time` int(10) unsigned NOT NULL DEFAULT '0',
`update_time` int(10) unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='芍药居用户待推广金额汇总';
CREATE TABLE IF NOT EXISTS `eb_syj_promote_task` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`uid` int(10) unsigned NOT NULL DEFAULT '0',
`task_no` varchar(64) NOT NULL DEFAULT '',
`source_order_id` int(10) unsigned NOT NULL DEFAULT '0',
`source_order_no` varchar(64) NOT NULL DEFAULT '',
`source_split_index` int(10) unsigned NOT NULL DEFAULT '1',
`base_amount` decimal(10,2) NOT NULL DEFAULT '4333.00',
`reward_rates` varchar(255) NOT NULL DEFAULT '',
`fee_rate` decimal(5,2) NOT NULL DEFAULT '7.00',
`status` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '0进行中 1已完成 2提前兑现 3已关闭 4异常 5审核中',
`progress_count` tinyint(3) unsigned NOT NULL DEFAULT '0',
`target_count` tinyint(3) unsigned NOT NULL DEFAULT '4',
`reward_trigger_status` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '0未触发 1成功 2失败 3处理中',
`reward_trigger_no` varchar(64) NOT NULL DEFAULT '',
`exception_reason` varchar(500) NOT NULL DEFAULT '',
`finish_time` int(10) unsigned NOT NULL DEFAULT '0',
`cashout_time` int(10) unsigned NOT NULL DEFAULT '0',
`add_time` int(10) unsigned NOT NULL DEFAULT '0',
`update_time` int(10) unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_task_no` (`task_no`),
UNIQUE KEY `uniq_source_split` (`source_order_id`,`source_split_index`),
UNIQUE KEY `uniq_reward_trigger_no` (`reward_trigger_no`),
KEY `idx_uid_status` (`uid`,`status`),
KEY `idx_source_order` (`source_order_id`),
KEY `idx_add_time` (`add_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='芍药居推四免一推广任务';
CREATE TABLE IF NOT EXISTS `eb_syj_promote_amount_log` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`uid` int(10) unsigned NOT NULL DEFAULT '0',
`order_id` int(10) unsigned NOT NULL DEFAULT '0',
`order_no` varchar(64) NOT NULL DEFAULT '',
`amount` decimal(10,2) NOT NULL DEFAULT '0.00',
`before_amount` decimal(10,2) NOT NULL DEFAULT '0.00',
`after_amount` decimal(10,2) NOT NULL DEFAULT '0.00',
`type` varchar(32) NOT NULL DEFAULT '',
`link_id` int(10) unsigned NOT NULL DEFAULT '0',
`mark` varchar(255) NOT NULL DEFAULT '',
`add_time` int(10) unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_order_type` (`order_id`,`type`),
KEY `idx_uid_type` (`uid`,`type`),
KEY `idx_add_time` (`add_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='芍药居待推广金额流水';
CREATE TABLE IF NOT EXISTS `eb_syj_promote_task_record` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`task_id` int(10) unsigned NOT NULL DEFAULT '0',
`uid` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '推荐人',
`order_uid` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '下单用户',
`order_id` int(10) unsigned NOT NULL DEFAULT '0',
`order_no` varchar(64) NOT NULL DEFAULT '',
`source_type` varchar(32) NOT NULL DEFAULT '',
`trigger_timing` varchar(32) NOT NULL DEFAULT '',
`step_no` tinyint(3) unsigned NOT NULL DEFAULT '0',
`reward_rate` decimal(5,2) NOT NULL DEFAULT '0.00',
`status` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '1有效 2退款失效',
`add_time` int(10) unsigned NOT NULL DEFAULT '0',
`update_time` int(10) unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_order` (`order_id`),
KEY `idx_task_status` (`task_id`,`status`),
KEY `idx_uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='芍药居推广任务推荐订单进度';
CREATE TABLE IF NOT EXISTS `eb_syj_promote_settlement` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`task_id` int(10) unsigned NOT NULL DEFAULT '0',
`uid` int(10) unsigned NOT NULL DEFAULT '0',
`settle_type` varchar(32) NOT NULL DEFAULT '',
`gross_amount` decimal(10,2) NOT NULL DEFAULT '0.00',
`fee_rate` decimal(5,2) NOT NULL DEFAULT '0.00',
`fee_amount` decimal(10,2) NOT NULL DEFAULT '0.00',
`net_amount` decimal(10,2) NOT NULL DEFAULT '0.00',
`bill_id` int(10) unsigned NOT NULL DEFAULT '0',
`brokerage_id` int(10) unsigned NOT NULL DEFAULT '0',
`audit_status` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '0待审核 1通过 2拒绝',
`audit_uid` int(10) unsigned NOT NULL DEFAULT '0',
`audit_remark` varchar(255) NOT NULL DEFAULT '',
`audit_time` int(10) unsigned NOT NULL DEFAULT '0',
`add_time` int(10) unsigned NOT NULL DEFAULT '0',
`update_time` int(10) unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_task_type` (`task_id`,`settle_type`),
KEY `idx_uid` (`uid`),
KEY `idx_audit` (`audit_status`,`add_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='芍药居推广任务结算';
CREATE TABLE IF NOT EXISTS `eb_syj_promote_reward_trigger` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`task_id` int(10) unsigned NOT NULL DEFAULT '0',
`uid` int(10) unsigned NOT NULL DEFAULT '0',
`trigger_no` varchar(64) NOT NULL DEFAULT '',
`trigger_amount` decimal(10,2) NOT NULL DEFAULT '4333.00',
`brokerage_status` tinyint(3) unsigned NOT NULL DEFAULT '0',
`points_status` tinyint(3) unsigned NOT NULL DEFAULT '0',
`level_task_status` tinyint(3) unsigned NOT NULL DEFAULT '0',
`brokerage_link_id` varchar(255) NOT NULL DEFAULT '',
`points_link_id` varchar(255) NOT NULL DEFAULT '',
`level_task_link_id` varchar(255) NOT NULL DEFAULT '',
`retry_count` int(10) unsigned NOT NULL DEFAULT '0',
`error_msg` varchar(500) NOT NULL DEFAULT '',
`add_time` int(10) unsigned NOT NULL DEFAULT '0',
`update_time` int(10) unsigned NOT NULL DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uniq_task` (`task_id`),
UNIQUE KEY `uniq_trigger_no` (`trigger_no`),
KEY `idx_status` (`points_status`,`level_task_status`,`brokerage_status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='芍药居推广任务等价奖励触发';
DELETE FROM `eb_system_config` WHERE `menu_name` IN (
'syj_task_base_amount',
'syj_task_target_count',
'syj_task_reward_rates',
'syj_early_cashout_fee_rate',
'syj_task_generate_timing',
'syj_task_order_dedupe',
'syj_reward_trigger_enable'
);
INSERT INTO `eb_system_config` (`menu_name`, `type`, `input_type`, `config_tab_id`, `parameter`, `upload_type`, `required`, `width`, `high`, `value`, `info`, `desc`, `sort`, `status`) VALUES
('syj_task_base_amount', 'text', 'input', 0, '', 0, '', 0, 0, '4333', '芍药居任务基准金额', '每满多少有效消费生成1个推广任务', 0, 1),
('syj_task_target_count', 'text', 'input', 0, '', 0, '', 0, 0, '4', '芍药居任务目标单数', '推四免一目标推荐单数', 0, 1),
('syj_task_reward_rates', 'text', 'input', 0, '', 0, '', 0, 0, '[10,20,30,40]', '芍药居任务奖励比例', 'JSON数组按进度档位配置', 0, 1),
('syj_early_cashout_fee_rate', 'text', 'input', 0, '', 0, '', 0, 0, '7', '芍药居提前兑现扣费比例', '1-3单提前兑现扣费百分比', 0, 1),
('syj_task_generate_timing', 'text', 'input', 0, '', 0, '', 0, 0, 'on_confirm', '芍药居任务生成节点', '首版默认确认收货', 0, 1),
('syj_task_order_dedupe', 'text', 'input', 0, '', 0, '', 0, 0, 'order', '芍药居推荐订单去重方式', '首版按订单去重', 0, 1),
('syj_reward_trigger_enable', 'text', 'input', 0, '', 0, '', 0, 0, '1', '芍药居奖励触发开关', '是否启用等价奖励触发', 0, 1);
UPDATE `eb_system_config` SET `value` = '0' WHERE `menu_name` = 'hjf_queue_pool_enable';

241
pro_v3.5.1/deploy/release-syj.sh Executable file
View File

@@ -0,0 +1,241 @@
#!/usr/bin/env bash
# syj 商城变体一键发布脚本
# 用法:
# bash deploy/release-syj.sh # 正常发布
# bash deploy/release-syj.sh --dry-run # 干跑(不动远端)
# bash deploy/release-syj.sh --rollback <ts-sha> # 手动回滚到指定备份
# bash deploy/release-syj.sh --skip-build # 跳过 admin 构建(复用 dist/
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CONF_FILE="$SCRIPT_DIR/syj.conf"
EXCLUDE_FILE="$SCRIPT_DIR/rsync-exclude.txt"
LAST_RELEASE_FILE="$SCRIPT_DIR/.last-release"
[ -f "$CONF_FILE" ] || { echo "ERROR: $CONF_FILE not found"; exit 1; }
[ -f "$EXCLUDE_FILE" ] || { echo "ERROR: $EXCLUDE_FILE not found"; exit 1; }
# shellcheck disable=SC1090
source "$CONF_FILE"
DRY_RUN=0
SKIP_BUILD=0
ROLLBACK_TAG=""
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--skip-build) SKIP_BUILD=1; shift ;;
--rollback) ROLLBACK_TAG="${2:-}"; shift 2 ;;
-h|--help) sed -n '2,8p' "$0"; exit 0 ;;
*) echo "Unknown arg: $1"; exit 2 ;;
esac
done
_ts() { date +%H:%M:%S; }
log() { printf "\033[1;36m[%s]\033[0m %s\n" "$(_ts)" "$*"; }
warn() { printf "\033[1;33m[%s] WARN\033[0m %s\n" "$(_ts)" "$*"; }
err() { printf "\033[1;31m[%s] ERR\033[0m %s\n" "$(_ts)" "$*" >&2; }
# 认证:优先 SSH_PASS_FILEsshpass -f其次 SSH_PASSWORD env再退回公钥
SSH_BASE=(ssh -p "$SSH_PORT" -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR)
if [ -n "${SSH_PASS_FILE:-}" ] && [ -f "${SSH_PASS_FILE/#~/$HOME}" ]; then
SSH_PASS_FILE="${SSH_PASS_FILE/#~/$HOME}"
[ "$(stat -f '%A' "$SSH_PASS_FILE" 2>/dev/null || stat -c '%a' "$SSH_PASS_FILE")" = "600" ] || \
warn "SSH_PASS_FILE permission is not 600 (consider: chmod 600 $SSH_PASS_FILE)"
command -v sshpass >/dev/null || { err "sshpass not installed (brew install hudochenkov/sshpass/sshpass)"; exit 1; }
SSH_AUTH=(sshpass -f "$SSH_PASS_FILE")
elif [ -n "${SSH_PASSWORD:-}" ]; then
command -v sshpass >/dev/null || { err "sshpass not installed"; exit 1; }
SSH_AUTH=(sshpass -e) # reads SSHPASS env
export SSHPASS="$SSH_PASSWORD"
else
SSH_AUTH=()
fi
ssh_run() { "${SSH_AUTH[@]}" "${SSH_BASE[@]}" "${SSH_USER}@${SSH_HOST}" "$@"; }
ssh_pipe() { "${SSH_AUTH[@]}" "${SSH_BASE[@]}" "${SSH_USER}@${SSH_HOST}" bash -se; }
# rsync 走相同认证
if [ -n "${SSH_AUTH+x}" ] && [ "${#SSH_AUTH[@]}" -gt 0 ]; then
RSYNC_RSH="${SSH_AUTH[*]} ssh -p $SSH_PORT -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR"
else
RSYNC_RSH="ssh -p $SSH_PORT"
fi
check_health() {
local url code i
for url in "${HEALTH_URLS[@]}"; do
for ((i=1; i<=HEALTH_RETRY; i++)); do
code=$(curl -ksS -o /dev/null -w "%{http_code}" --max-time 10 "$url" || echo 000)
if [ "$code" = "200" ]; then
log "$url -> 200"
break
fi
if [ "$i" -eq "$HEALTH_RETRY" ]; then
err "$url -> $code (after $HEALTH_RETRY tries)"
return 1
fi
sleep "$HEALTH_INTERVAL"
done
done
return 0
}
remote_clear_and_reload() {
REMOTE_ROOT="$REMOTE_ROOT" ENV_SOURCE_FILE="$ENV_SOURCE_FILE" \
ssh_pipe <<'EOF'
set -euo pipefail
cd "$REMOTE_ROOT"
if [ ! -f "$ENV_SOURCE_FILE" ]; then
echo "ERR: $ENV_SOURCE_FILE missing on remote" >&2
exit 1
fi
cp -p "$ENV_SOURCE_FILE" .env
php think clear
PIDFILE=runtime/swoole/swoole.pid
if [ -f "$PIDFILE" ]; then
PID=$(cat "$PIDFILE")
if kill -0 "$PID" 2>/dev/null; then
kill -USR1 "$PID"
echo "Swoole reload via pidfile (master=$PID)"
else
echo "WARN: stale pidfile, falling back to ps"
PID=$(ps -eo pid,ppid,command | awk '/php think swoole/ && $2==1 {print $1; exit}')
[ -n "$PID" ] && kill -USR1 "$PID" && echo "Swoole reload via ps (master=$PID)"
fi
else
PID=$(ps -eo pid,ppid,command | awk '/php think swoole/ && $2==1 {print $1; exit}')
if [ -n "$PID" ]; then
kill -USR1 "$PID"
echo "Swoole reload via ps (master=$PID)"
else
echo "WARN: Swoole master not found; nothing reloaded"
fi
fi
EOF
}
prune_old_backups() {
REMOTE_BAK="$REMOTE_BAK" KEEP_BACKUPS="$KEEP_BACKUPS" ssh_pipe <<'EOF'
set -euo pipefail
mkdir -p "$REMOTE_BAK"
cd "$REMOTE_BAK"
ls -1t 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | while read -r d; do
[ -n "$d" ] && rm -rf -- "$d" && echo "Pruned $REMOTE_BAK/$d"
done
EOF
}
# ---- ROLLBACK MODE ----
if [ -n "$ROLLBACK_TAG" ]; then
log "ROLLBACK to $ROLLBACK_TAG"
REMOTE_ROOT="$REMOTE_ROOT" REMOTE_BAK="$REMOTE_BAK" TAG="$ROLLBACK_TAG" \
ssh_pipe <<'EOF'
set -euo pipefail
SRC="$REMOTE_BAK/$TAG"
[ -d "$SRC" ] || { echo "Backup $SRC not found"; exit 1; }
rsync -a --exclude='.env' "$SRC/" "$REMOTE_ROOT/"
echo "Rolled back files from $SRC"
EOF
remote_clear_and_reload
sleep "$HEALTH_INTERVAL"
check_health || { err "Healthcheck failed after rollback"; exit 1; }
log "Rollback OK"
exit 0
fi
# ---- PRE-FLIGHT ----
log "Pre-flight checks"
cd "$PROJECT_ROOT"
CURRENT_BRANCH=$(git -C "$PROJECT_ROOT/.." rev-parse --abbrev-ref HEAD 2>/dev/null || echo "?")
if [ "$CURRENT_BRANCH" != "$EXPECTED_BRANCH" ]; then
warn "Current branch is '$CURRENT_BRANCH', expected '$EXPECTED_BRANCH'"
if [ "$DRY_RUN" -ne 1 ]; then
read -r -p "Continue anyway? [y/N] " ans
[[ "$ans" =~ ^[Yy]$ ]] || { err "Aborted"; exit 1; }
fi
fi
[ -f "$PROJECT_ROOT/$ENV_SOURCE_FILE" ] || { err "$ENV_SOURCE_FILE missing locally"; exit 1; }
grep -q "syj.fsgx.cn" "$PROJECT_ROOT/view/admin/.env.production" || warn "view/admin/.env.production not pointing to syj.fsgx.cn"
ssh_run "test -d '$REMOTE_ROOT'" || { err "Remote root $REMOTE_ROOT does not exist"; exit 1; }
# ---- BUILD ADMIN ----
if [ "$SKIP_BUILD" -eq 1 ]; then
log "Skip admin build (--skip-build)"
else
log "Build admin"
(
cd "$PROJECT_ROOT/view/admin"
if [ ! -d node_modules ]; then
npm ci --silent
fi
npm run build
)
fi
[ -d "$PROJECT_ROOT/view/admin/dist" ] || { err "view/admin/dist missing; build failed?"; exit 1; }
# admin 实际运行目录 = pro_v3.5.1/public/admin/(远端 nginx 指向同名路径)
# 把 build 产物同步到 public/admin/,再让项目根 rsync 整体推上去
log "Sync admin dist -> public/admin/"
mkdir -p "$PROJECT_ROOT/public/admin"
rsync -a --delete \
--exclude='UEditor/' --exclude='favicon.ico' \
"$PROJECT_ROOT/view/admin/dist/" "$PROJECT_ROOT/public/admin/"
# ---- VERSION ----
TS=$(date +%Y%m%d-%H%M%S)
SHA=$(git -C "$PROJECT_ROOT/.." rev-parse --short HEAD 2>/dev/null || echo "nogit")
TAG="${TS}-${SHA}"
log "Release tag: $TAG"
# ---- RSYNC ----
RSYNC_OPTS=(-az --delete --human-readable
--exclude-from="$EXCLUDE_FILE"
--backup --backup-dir="$REMOTE_BAK/$TAG"
-e "$RSYNC_RSH")
[ "$DRY_RUN" -eq 1 ] && RSYNC_OPTS+=(--dry-run --itemize-changes)
log "rsync -> ${SSH_USER}@${SSH_HOST}:$REMOTE_ROOT (backup: $REMOTE_BAK/$TAG)"
ssh_run "mkdir -p '$REMOTE_BAK/$TAG'"
rsync "${RSYNC_OPTS[@]}" "$PROJECT_ROOT/" "${SSH_USER}@${SSH_HOST}:$REMOTE_ROOT/"
if [ "$DRY_RUN" -eq 1 ]; then
log "Dry-run done. No remote changes applied."
exit 0
fi
# ---- REMOTE: env switch + clear + reload ----
log "Remote: switch .env, php think clear, Swoole reload"
remote_clear_and_reload
# ---- HEALTHCHECK ----
sleep "$HEALTH_INTERVAL"
log "Healthcheck"
if check_health; then
echo "$TAG" > "$LAST_RELEASE_FILE"
log "Released $TAG"
prune_old_backups || warn "Prune step failed (non-fatal)"
exit 0
fi
# ---- AUTO ROLLBACK ----
err "Healthcheck failed. Auto-rolling back from $REMOTE_BAK/$TAG"
REMOTE_ROOT="$REMOTE_ROOT" REMOTE_BAK="$REMOTE_BAK" TAG="$TAG" \
ssh_pipe <<'EOF'
set -euo pipefail
rsync -a --exclude='.env' "$REMOTE_BAK/$TAG/" "$REMOTE_ROOT/"
EOF
remote_clear_and_reload
sleep "$HEALTH_INTERVAL"
if check_health; then
err "Rolled back to previous state. Release $TAG aborted."
exit 1
else
err "Rollback healthcheck STILL failing. Manual intervention required."
err "Backup at: ${SSH_USER}@${SSH_HOST}:$REMOTE_BAK/$TAG"
exit 2
fi

View File

@@ -0,0 +1,16 @@
.git/
.idea/
.vscode/
.DS_Store
runtime/
backup/
tests/
node_modules/
public/uploads/
.env
.env-fsgx
.env-huangjinfen
view/uniapp/
view/uniapp_v2/
view/admin/
deploy/.last-release

View File

@@ -0,0 +1,33 @@
# syj 商城变体发布参数(被 release-syj.sh source
# 修改后立即生效,不需要重新构建
SSH_USER=root
SSH_HOST=8.140.50.89
SSH_PORT=22
REMOTE_ROOT=/www/wwwroot/syj.fsgx.cn
REMOTE_BAK=/www/wwwroot/syj.fsgx.cn-bak
# 密码认证(可选):把 SSH 密码放在仓库外的 600 文件里,例如 ~/.ssh/syj-deploy.pass
# 优先级SSH_PASS_FILE > $SSH_PASSWORD env > 公钥认证
# 文件方式(推荐):
# echo 'YOUR_PASSWORD' > ~/.ssh/syj-deploy.pass && chmod 600 ~/.ssh/syj-deploy.pass
SSH_PASS_FILE=~/.ssh/syj-deploy.pass
# 健康检查 URL任一非 200 即触发回滚
HEALTH_URLS=(
"https://syj.fsgx.cn/api/version"
"https://syj.fsgx.cn/admin/"
)
# 备份保留数量(远端 REMOTE_BAK 下保留最近 N 份)
KEEP_BACKUPS=10
# 期望分支;不一致时脚本会要求二次确认
EXPECTED_BRANCH=syj-bypass-auth
# 远端 .env 取自该文件(位于 pro_v3.5.1/ 根)
ENV_SOURCE_FILE=.env-syj
# 健康检查重试
HEALTH_RETRY=5
HEALTH_INTERVAL=3

View File

@@ -2864,6 +2864,26 @@ Route::group('adminapi', function () {
\app\http\middleware\admin\AdminLogMiddleware::class
]);
/**
* 芍药居推四免一推广任务
*/
Route::group('syj/promote', function () {
Route::get('task/list', 'v1.syj.PromoteController/taskList')->option(['real_name' => '芍药居推广任务列表']);
Route::get('task/:id', 'v1.syj.PromoteController/taskDetail')->option(['real_name' => '芍药居推广任务详情']);
Route::get('task/:id/records', 'v1.syj.PromoteController/taskRecords')->option(['real_name' => '芍药居推广任务进度']);
Route::get('cashout/list', 'v1.syj.PromoteController/cashoutList')->option(['real_name' => '芍药居提前兑现审核列表']);
Route::post('cashout/:id/audit', 'v1.syj.PromoteController/auditCashout')->option(['real_name' => '芍药居提前兑现审核']);
Route::get('settlement/list', 'v1.syj.PromoteController/settlementList')->option(['real_name' => '芍药居结算流水']);
Route::get('config', 'v1.syj.PromoteController/getConfig')->option(['real_name' => '芍药居任务配置']);
Route::post('config', 'v1.syj.PromoteController/saveConfig')->option(['real_name' => '保存芍药居任务配置']);
Route::post('retry-trigger/:taskId', 'v1.syj.PromoteController/retryTrigger')->option(['real_name' => '重试芍药居奖励触发']);
})->middleware([
\app\http\middleware\AllowOriginMiddleware::class,
\app\http\middleware\admin\AdminAuthTokenMiddleware::class,
\app\http\middleware\admin\AdminCkeckRoleMiddleware::class,
\app\http\middleware\admin\AdminLogMiddleware::class
]);
/**
* miss 路由
*/

View File

@@ -275,6 +275,7 @@ Route::group('api', function () {
Route::post('sign/integral', 'v1.user.UserSign/sign_integral')->middleware(BlockerMiddleware::class)->name('signIntegral');//签到
Route::get('sign/remind/:status', 'v1.user.UserSign/sign_remind')->name('signRemind');//用户设置签到提醒
Route::get('sign/calendar', 'v1.user.UserSign/sign_calendar')->name('signCalendar');//日历数据
Route::get('sign/required_article', 'v1.user.UserSign/required_article')->name('signRequiredArticle');//签到前置阅读文章("签到广告"分类随机一条)
//优惠券类
Route::post('coupon/receive', 'v1.activity.StoreCoupons/receive')->middleware(BlockerMiddleware::class)->name('couponReceive'); //领取优惠券
Route::post('coupon/receive/batch', 'v1.activity.StoreCoupons/receive_batch')->name('couponReceiveBatch'); //批量领取优惠券
@@ -438,6 +439,15 @@ Route::group('api', function () {
Route::get('hjf/brokerage/progress', 'v1.hjf.HjfBrokerage/progress')->name('hjfBrokerageProgress');//推荐佣金周期进度
Route::get('hjf/assets/overview', 'v1.hjf.HjfAssets/overview')->name('hjfAssetsOverview');//资产总览
// 芍药居推四免一推广任务
Route::get('syj/promote/overview', 'v1.syj.PromoteController/overview')->name('syjPromoteOverview');
Route::get('syj/promote/task/list', 'v1.syj.PromoteController/taskList')->name('syjPromoteTaskList');
Route::get('syj/promote/task/:id', 'v1.syj.PromoteController/taskDetail')->name('syjPromoteTaskDetail');
Route::get('syj/promote/task/:id/records', 'v1.syj.PromoteController/taskRecords')->name('syjPromoteTaskRecords');
Route::post('syj/promote/task/:id/cashout', 'v1.syj.PromoteController/cashout')->name('syjPromoteCashout');
Route::get('syj/promote/amount/log', 'v1.syj.PromoteController/amountLog')->name('syjPromoteAmountLog');
Route::get('syj/promote/settlement/list', 'v1.syj.PromoteController/settlementList')->name('syjPromoteSettlementList');
})->middleware(StationOpenMiddleware::class)->middleware(AuthTokenMiddleware::class, true);
/**

View File

@@ -28,7 +28,7 @@ Route::group('/', function () {
$pathInfoArr = explode('/', $pathInfo);
$admin = $pathInfoArr[0] ?? '';
if ($admin === 'admin') {
return __view(app()->getRootPath() . 'public' . DS . 'admin' . DS . 'index.html');
return Response::create(file_get_contents(app()->getRootPath() . 'public' . DS . 'admin' . DS . 'index.html'), 'html');
} else {
return Response::create()->code(404);
}
@@ -55,7 +55,7 @@ Route::group('/', function () {
$pathInfoArr = explode('/', $pathInfo);
$admin = $pathInfoArr[0] ?? '';
if ('kefu' === $admin) {
return __view(app()->getRootPath() . 'public' . DS . 'admin' . DS . 'index.html');
return Response::create(file_get_contents(app()->getRootPath() . 'public' . DS . 'admin' . DS . 'index.html'), 'html');
} else {
return Response::create()->code(404);
}
@@ -91,7 +91,7 @@ Route::group('/', function () {
$pathInfoArr = explode('/', $pathInfo);
$admin = $pathInfoArr[0] ?? '';
if ('supplier' === $admin) {
return __view(app()->getRootPath() . 'public' . DS . 'admin' . DS . 'index.html');
return Response::create(file_get_contents(app()->getRootPath() . 'public' . DS . 'admin' . DS . 'index.html'), 'html');
} else {
return Response::create()->code(404);
}

View File

@@ -0,0 +1,37 @@
import request from '@/plugins/request';
export function taskListApi(params) {
return request({ url: 'syj/promote/task/list', method: 'get', params });
}
export function taskDetailApi(id) {
return request({ url: `syj/promote/task/${id}`, method: 'get' });
}
export function taskRecordsApi(id) {
return request({ url: `syj/promote/task/${id}/records`, method: 'get' });
}
export function cashoutListApi(params) {
return request({ url: 'syj/promote/cashout/list', method: 'get', params });
}
export function auditCashoutApi(id, data) {
return request({ url: `syj/promote/cashout/${id}/audit`, method: 'post', data });
}
export function settlementListApi(params) {
return request({ url: 'syj/promote/settlement/list', method: 'get', params });
}
export function configGetApi() {
return request({ url: 'syj/promote/config', method: 'get' });
}
export function configSaveApi(data) {
return request({ url: 'syj/promote/config', method: 'post', data });
}
export function retryTriggerApi(taskId) {
return request({ url: `syj/promote/retry-trigger/${taskId}`, method: 'post' });
}

View File

@@ -12,19 +12,19 @@ export default {
{
title: "官网",
key: "官网",
href: "https://www.crmeb.com",
href: "https://syj.fsgx.cn",
blankTarget: true,
},
{
title: "社区",
key: "社区",
href: "http://www.crmeb.com/ask",
href: "https://syj.fsgx.cn/ask",
blankTarget: true,
},
{
title: "文档",
key: "文档",
href: "http://doc.crmeb.com",
href: "https://syj.fsgx.cn/doc",
blankTarget: true,
},
],

View File

@@ -545,7 +545,7 @@ export default {
like_num: 120,
type_image: require("@/assets/images/yonghu.png"),
type_name: "阿秋",
desc: "观看视频crmeb更多好礼等你来抢,每天都有哟~ 更多好礼请联…",
desc: "观看视频更多好礼等你来抢,每天都有哟~ 更多好礼请联…",
product_num: 3,
},
];

View File

@@ -156,7 +156,7 @@
<Timeline>
<TimelineItem>
<div class="dot" slot="dot">1</div>
<div class="title">登录您的crmeb系统后台</div>
<div class="title">登录系统后台</div>
<div class="content">
<div class="item">
<div class="text">
@@ -177,7 +177,7 @@
<div class="content">
<div class="item">
<Alert show-icon>
<div>目前小程序审核带有充值功能会被要求提供相关资料预付卡在线充值业务CRMEB小程序提交前
<div>目前小程序审核带有充值功能会被要求提供相关资料预付卡在线充值业务小程序提交前
请在后台关闭充值功能等待审核通过后再打开</div>
</Alert>
<div class="text">

View File

@@ -251,7 +251,7 @@
<Timeline>
<TimelineItem>
<div class="dot" slot="dot">1</div>
<div class="title">登录您的CRMEB系统后台</div>
<div class="title">登录系统后台</div>
<div class="content">
<div class="item">
<div class="text">
@@ -333,7 +333,7 @@ export default {
'2.将第 2 步商城后台保存提交的配置信息(再次强调上一步需要提交哦~),在微信公众平台上填写。',
],
alert: [
'1.URL填写内容为http://你的域名/api/Wechat/serve例如http://<a>www.crmeb.com</a>/api/Wechat/serve',
'1.URL填写内容为http://你的域名/api/Wechat/serve例如http://<a>syj.fsgx.cn</a>/api/Wechat/serve',
'2.AppID、AppSecret、Token、消息加密方式、EncodingAESKey两边的服务器配置必须要<a href="javascript:;">完全一致</a>哦~',
],
image: ['wechat-3-3-2.png'],

View File

@@ -293,7 +293,7 @@
<div class="text">
<div>位置刚创建的应用界面 网页授权及JS-SDK</div>
<div>
操作1把自己的域名填写在下图两个输入框中格式例如www.crmeb.com
操作1把自己的域名填写在下图两个输入框中格式例如syj.fsgx.cn
</div>
</div>
<div class="image">
@@ -319,7 +319,7 @@
<div class="text">
<div>位置刚创建的应用界面 企业微信授权登录</div>
<div>
操作点击设置授权回调域填写入自己的域名(例如www.crmeb.com)然后点击保存
操作点击设置授权回调域填写入自己的域名(例如syj.fsgx.cn)然后点击保存
</div>
</div>
<div class="image">

View File

@@ -69,6 +69,9 @@
<template slot-scope="{ row }" slot="agentLevel">
<div>{{ row.agentLevel ? row.agentLevel.name : "--" }}</div>
</template>
<template slot-scope="{ row }" slot="xcxCode">
<a @click="showXcxCode(row)">查看</a>
</template>
<template slot-scope="{ row, index }" slot="right">
<a @click="promoters(row, 'man', 2)">推广人</a>
<Divider type="vertical" />
@@ -268,11 +271,6 @@ export default {
slot: "nickname",
minWidth: 150,
},
{
title: "推广用户数量",
key: "spread_count",
minWidth: 125,
},
{
title: "订单数量",
key: "order_count",
@@ -308,6 +306,16 @@ export default {
key: "new_money",
minWidth: 105,
},
{
title: "小程序推广码",
slot: "xcxCode",
minWidth: 120,
},
{
title: "推广用户数量",
key: "spread_count",
minWidth: 125,
},
{
title: "上级推广人",
key: "spread_name",
@@ -542,9 +550,20 @@ export default {
spreadQR(row) {
this.modals = true;
this.rows = row;
this.code_src = "";
this.code_xcx = "";
this.code_h5 = "";
// this.getWeChat(row);
// this.getXcx(row);
},
showXcxCode(row) {
this.modals = true;
this.rows = row;
this.code_src = "";
this.code_xcx = "";
this.code_h5 = "";
this.getXcx();
},
// 公众号推广二维码
getWeChat() {
this.spinShow = true;

View File

@@ -338,7 +338,7 @@
colorsname: '',
formValidate: {
logo_url: '',
brand_name: 'CRMEB', // 商户名称
brand_name: '芍药居', // 商户名称
selet: 0, //卡券封面切换 0--颜色/1--图片
attrs: [{pic:''}],
title:'', //会员卡标题

View File

@@ -169,8 +169,8 @@
<div class="notice">
<div v-if="notice" class="rich" v-html="notice"></div>
<div class="copy">
<a href="http://www.crmeb.com/" target="_blank"
>CRMEB提供技术支持</a
<a href="https://syj.fsgx.cn/" target="_blank"
>芍药居提供技术支持</a
>
</div>
</div>

View File

@@ -56,7 +56,7 @@
<!-- </Modal>-->
</div>
<div class="foot-box" v-if="copyrightContext">{{copyrightContext}}</div>
<div class="foot-box" v-else>Copyright © 2014-2024 <a class="infoUrl" href="https://www.crmeb.com" target="_blank">{{version}}</a></div>
<div class="foot-box" v-else>Copyright © 2014-2024 <a class="infoUrl" href="https://syj.fsgx.cn" target="_blank">{{version}}</a></div>
</div>
</template>
<script>

View File

@@ -21,17 +21,17 @@
</template>
<template v-else>
<p v-if="currentTab == 3">阿里云oss开通方法<a target="_blank"
href="https://doc.crmeb.com/pro/crmebprov2/1213">点击查看</a></p>
href="https://syj.fsgx.cn/doc/storage/aliyun-oss">点击查看</a></p>
<p v-if="currentTab == 4">腾讯云oss开通方法<a target="_blank"
href="https://doc.crmeb.com/pro/crmebprov2/1214">点击查看</a></p>
href="https://syj.fsgx.cn/doc/storage/tencent-cos">点击查看</a></p>
<p v-if="currentTab == 2">七牛云开通方法:<a target="_blank"
href="https://doc.crmeb.com/pro/crmebprov2/1215">点击查看</a></p>
href="https://syj.fsgx.cn/doc/storage/qiniu">点击查看</a></p>
<p v-if="currentTab == 5">京东云cos开通方法<a target="_blank"
href="https://doc.crmeb.com/single/v5/8522">点击查看</a></p>
href="https://syj.fsgx.cn/doc/storage/jdcloud-cos">点击查看</a></p>
<p v-if="currentTab == 6">华为云cos开通方法<a target="_blank"
href="https://doc.crmeb.com/single/v5/8523">点击查看</a></p>
href="https://syj.fsgx.cn/doc/storage/huawei-obs">点击查看</a></p>
<p v-if="currentTab == 7">天翼云cos开通方法<a target="_blank"
href="https://doc.crmeb.com/single/v5/8524">点击查看</a></p>
href="https://syj.fsgx.cn/doc/storage/ctyun">点击查看</a></p>
<p>第一步 添加存储空间空间名称不能重复</p>
<p>第二步 开启使用状态</p>
<template v-if="currentTab == 2">

View File

@@ -53,7 +53,7 @@
</div>
<div class="pull-right" v-else>
Copyright ©2014-2024
<a class="infoUrl" href="https://www.crmeb.com" target="_blank">{{
<a class="infoUrl" href="https://syj.fsgx.cn" target="_blank">{{
version
}}</a>
</div>

View File

@@ -0,0 +1,68 @@
<template>
<div>
<Card :bordered="false" dis-hover class="ivu-mt" :padding="0">
<div class="new_card_pd">
<Form inline @submit.native.prevent>
<FormItem label="关键词:"><Input v-model="query.keyword" placeholder="任务号/用户" class="input-add" clearable /></FormItem>
<FormItem label="状态:">
<Select v-model="query.audit_status" class="input-add" clearable>
<Option :value="0">待审核</Option>
<Option :value="1">已通过</Option>
<Option :value="2">已拒绝</Option>
</Select>
</FormItem>
<FormItem><Button type="primary" @click="getList">查询</Button></FormItem>
</Form>
</div>
</Card>
<Card :bordered="false" dis-hover class="ivu-mt">
<Table :columns="columns" :data="list" :loading="loading">
<template slot-scope="{ row }" slot="amount">¥{{ row.net_amount }} / 应结 ¥{{ row.gross_amount }}</template>
<template slot-scope="{ row }" slot="status">{{ ['待审核','已通过','已拒绝'][row.audit_status] }}</template>
<template slot-scope="{ row }" slot="action">
<Button v-if="row.audit_status == 0" size="small" type="primary" @click="audit(row, 1)">通过</Button>
<Button v-if="row.audit_status == 0" size="small" class="ivu-ml-8" @click="audit(row, 2)">拒绝</Button>
</template>
</Table>
</Card>
</div>
</template>
<script>
import { cashoutListApi, auditCashoutApi } from '@/api/syjPromote.js';
export default {
name: 'SyjCashout',
data() {
return {
loading: false,
list: [],
query: { keyword: '', audit_status: 0, page: 1, limit: 20 },
columns: [
{ title: '任务号', key: 'task_no', minWidth: 180 },
{ title: '用户', key: 'nickname', minWidth: 140 },
{ title: '到账/应结', slot: 'amount', minWidth: 160 },
{ title: '扣费', key: 'fee_amount', width: 100 },
{ title: '状态', slot: 'status', width: 100 },
{ title: '操作', slot: 'action', width: 160 }
]
};
},
created() { this.getList(); },
methods: {
getList() {
this.loading = true;
cashoutListApi(this.query).then(res => {
const data = res.data || res;
this.list = data.list || [];
}).finally(() => { this.loading = false; });
},
audit(row, status) {
auditCashoutApi(row.id, { status, remark: status === 1 ? '审核通过' : '审核拒绝' }).then(() => {
this.$Message.success('审核成功');
this.getList();
});
}
}
};
</script>

View File

@@ -0,0 +1,63 @@
<template>
<Card :bordered="false" dis-hover class="ivu-mt">
<p slot="title">芍药居任务配置</p>
<Spin v-if="loading" fix />
<Form v-else :model="form" :label-width="180" @submit.native.prevent>
<FormItem label="任务基准金额"><InputNumber v-model="form.base_amount" :min="1" style="width:180px" /></FormItem>
<FormItem label="目标单数"><InputNumber v-model="form.target_count" :min="1" :precision="0" style="width:180px" /></FormItem>
<FormItem label="奖励比例"><Input v-model="ratesText" placeholder="10,20,30,40" style="width:260px" /></FormItem>
<FormItem label="提前兑现扣费比例"><InputNumber v-model="form.early_cashout_fee_rate" :min="0" :max="100" style="width:180px" /></FormItem>
<FormItem label="任务生成节点">
<RadioGroup v-model="form.task_generate_timing">
<Radio label="on_confirm">确认收货</Radio>
<Radio label="on_pay">支付成功</Radio>
</RadioGroup>
</FormItem>
<FormItem label="奖励触发"><i-switch v-model="form.reward_trigger_enable" :true-value="1" :false-value="0" /></FormItem>
<FormItem><Button type="primary" :loading="saving" @click="save">保存</Button></FormItem>
</Form>
</Card>
</template>
<script>
import { configGetApi, configSaveApi } from '@/api/syjPromote.js';
export default {
name: 'SyjConfig',
data() {
return {
loading: false,
saving: false,
ratesText: '10,20,30,40',
form: {
base_amount: 4333,
target_count: 4,
reward_rates: [10, 20, 30, 40],
early_cashout_fee_rate: 7,
task_generate_timing: 'on_confirm',
task_order_dedupe: 'order',
reward_trigger_enable: 1
}
};
},
created() { this.load(); },
methods: {
load() {
this.loading = true;
configGetApi().then(res => {
this.form = Object.assign(this.form, res.data || res);
this.ratesText = (this.form.reward_rates || []).join(',');
}).finally(() => { this.loading = false; });
},
save() {
this.saving = true;
const data = Object.assign({}, this.form, {
reward_rates: this.ratesText.split(',').map(item => Number(item.trim())).filter(item => !Number.isNaN(item))
});
configSaveApi(data).then(() => {
this.$Message.success('保存成功');
}).finally(() => { this.saving = false; });
}
}
};
</script>

View File

@@ -0,0 +1,109 @@
<template>
<div>
<Card :bordered="false" dis-hover class="ivu-mt" :padding="0">
<div class="new_card_pd">
<Form inline @submit.native.prevent>
<FormItem label="关键词:">
<Input v-model="query.keyword" placeholder="任务号/订单号/用户" class="input-add" clearable />
</FormItem>
<FormItem label="状态:">
<Select v-model="query.status" class="input-add" clearable>
<Option :value="0">进行中</Option>
<Option :value="1">已完成</Option>
<Option :value="2">提前兑现</Option>
<Option :value="4">异常</Option>
<Option :value="5">审核中</Option>
</Select>
</FormItem>
<FormItem>
<Button type="primary" class="mr14" @click="getList">查询</Button>
<Button @click="reset">重置</Button>
</FormItem>
</Form>
</div>
</Card>
<Card :bordered="false" dis-hover class="ivu-mt">
<Table :columns="columns" :data="list" :loading="loading">
<template slot-scope="{ row }" slot="status">{{ statusText(row.status) }}</template>
<template slot-scope="{ row }" slot="progress">{{ row.progress_count }}/{{ row.target_count }}</template>
<template slot-scope="{ row }" slot="amount">¥{{ Number(row.base_amount).toFixed(2) }}</template>
<template slot-scope="{ row }" slot="action">
<Button size="small" type="primary" @click="showDetail(row)">详情</Button>
<Button v-if="row.reward_trigger_status == 2" size="small" class="ivu-ml-8" @click="retry(row)">重试奖励</Button>
</template>
</Table>
<Page class="mt20" :total="total" :current="query.page" :page-size="query.limit" show-total @on-change="p => { query.page = p; getList(); }" />
</Card>
<Modal v-model="detailVisible" width="720" title="推广任务详情">
<pre class="detail-json">{{ detail }}</pre>
</Modal>
</div>
</template>
<script>
import { taskListApi, taskDetailApi, retryTriggerApi } from '@/api/syjPromote.js';
export default {
name: 'SyjPromoteTask',
data() {
return {
loading: false,
detailVisible: false,
detail: '',
total: 0,
list: [],
query: { keyword: '', status: '', page: 1, limit: 20 },
columns: [
{ title: '任务号', key: 'task_no', minWidth: 180 },
{ title: '用户', key: 'nickname', minWidth: 140 },
{ title: '来源订单', key: 'source_order_no', minWidth: 160 },
{ title: '金额', slot: 'amount', width: 110 },
{ title: '进度', slot: 'progress', width: 90 },
{ title: '状态', slot: 'status', width: 100 },
{ title: '创建时间', key: 'add_time', width: 140 },
{ title: '操作', slot: 'action', width: 180, fixed: 'right' }
]
};
},
created() {
this.getList();
},
methods: {
getList() {
this.loading = true;
taskListApi(this.query).then(res => {
const data = res.data || res;
this.list = data.list || [];
this.total = data.count || 0;
}).finally(() => { this.loading = false; });
},
reset() {
this.query = { keyword: '', status: '', page: 1, limit: 20 };
this.getList();
},
showDetail(row) {
taskDetailApi(row.id).then(res => {
this.detail = JSON.stringify(res.data || res, null, 2);
this.detailVisible = true;
});
},
retry(row) {
retryTriggerApi(row.id).then(() => {
this.$Message.success('重试完成');
this.getList();
});
},
statusText(status) {
return ['进行中', '已完成', '提前兑现', '已关闭', '异常', '审核中'][Number(status)] || '未知';
}
}
};
</script>
<style scoped>
.detail-json {
max-height: 520px;
overflow: auto;
white-space: pre-wrap;
}
</style>

View File

@@ -6,7 +6,7 @@
<Icon type="ios-information-circle-outline" class="iconIos blue" />
<div class="text">
<div>系统许可</div>
<div class="code">当前系统使用自有版权配置不展示 CRMEB 原厂商业授权状态</div>
<div class="code">当前系统使用自有版权配置不展示原厂商业授权状态</div>
<div class="code">
版本{{ licenseInfo.edition || "custom" }} · 来源{{ licenseInfo.license_source || "self-owned" }}
</div>

View File

@@ -94,7 +94,7 @@
},
mounted () {
this.editor = CodeMirror.fromTextArea(this.$refs.mycode, {
value: 'http://www.crmeb.com', // 文本域默认显示的文本
value: 'https://syj.fsgx.cn', // 文本域默认显示的文本
mode: 'text/javascript',
theme: 'ambiance', // CSS样式选择
indentUnit: 4, // 缩进单位默认2

View File

@@ -0,0 +1,34 @@
import BasicLayout from '@/layouts/basic-layout';
const pre = 'syj_';
export default {
path: '/admin/syj',
name: 'syj',
header: 'syj',
meta: {
auth: ['admin-syj'],
title: '芍药居'
},
component: BasicLayout,
children: [
{
path: 'promote/task',
name: `${pre}promoteTask`,
meta: { auth: ['syj-promote-task'], title: '推广任务' },
component: () => import('@/pages/syj/promoteTask/index')
},
{
path: 'promote/cashout',
name: `${pre}cashout`,
meta: { auth: ['syj-promote-cashout'], title: '提前兑现审核' },
component: () => import('@/pages/syj/cashout/index')
},
{
path: 'promote/config',
name: `${pre}config`,
meta: { auth: ['syj-promote-config'], title: '任务配置' },
component: () => import('@/pages/syj/config/index')
}
]
};

View File

@@ -26,6 +26,7 @@ import work from "./modules/work";
import content from "./modules/content";
import inventory from "./modules/inventory";
import hjfQueue from "./modules/hjfCustom.js";
import syj from "./modules/syj.js";
import { isSupplierPath } from "@/utils/pathUtils";
/**
@@ -210,7 +211,8 @@ const frameIn = [
work,
content,
inventory,
hjfQueue
hjfQueue,
syj
];
/**

View File

@@ -0,0 +1,25 @@
import request from '@/utils/request.js';
export function getSyjOverview() {
return request.get('syj/promote/overview');
}
export function getSyjTaskList(params) {
return request.get('syj/promote/task/list', params);
}
export function getSyjTaskDetail(id) {
return request.get(`syj/promote/task/${id}`);
}
export function applySyjCashout(id) {
return request.post(`syj/promote/task/${id}/cashout`);
}
export function getSyjAmountLog(params) {
return request.get('syj/promote/amount/log', params);
}
export function getSyjSettlementList(params) {
return request.get('syj/promote/settlement/list', params);
}

View File

@@ -133,6 +133,18 @@
"titleNView": false
}
}
},
{
"path": "pages/syj/promote_task/index",
"style": {
"navigationBarTitleText": "我的推广任务"
}
},
{
"path": "pages/syj/promote_task/detail",
"style": {
"navigationBarTitleText": "任务详情"
}
}
],
// "plugins": {

View File

@@ -0,0 +1,80 @@
<template>
<view class="page">
<view class="card">
<view class="title">{{ detail.task_no }}</view>
<view class="status">{{ statusText(detail.status) }}</view>
<view class="amount">¥{{ detail.base_amount || '0.00' }}</view>
<view class="progress">
<view class="bar"><view :style="{width: progressWidth}"></view></view>
<text>{{ detail.progress_count || 0 }}/{{ detail.target_count || 4 }}</text>
</view>
</view>
<view class="card">
<view class="section-title">推荐进度</view>
<view class="record" v-for="item in records" :key="item.id">
<text> {{ item.step_no }} </text>
<text>{{ item.order_no }}</text>
</view>
<view v-if="!records.length" class="empty">暂无推荐订单</view>
</view>
<button v-if="canCashout" class="cashout" @tap="cashout">申请提前兑现</button>
</view>
</template>
<script>
import { getSyjTaskDetail, applySyjCashout } from '@/api/syjPromote.js';
export default {
data() {
return { id: 0, detail: {} };
},
computed: {
records() {
return this.detail.records || [];
},
canCashout() {
const progress = Number(this.detail.progress_count || 0);
return Number(this.detail.status) === 0 && progress >= 1 && progress <= 3;
},
progressWidth() {
const total = Number(this.detail.target_count || 4);
return `${Math.min(100, Number(this.detail.progress_count || 0) / total * 100)}%`;
}
},
onLoad(options) {
this.id = Number(options.id || 0);
this.load();
},
methods: {
load() {
getSyjTaskDetail(this.id).then(res => {
this.detail = res.data || res;
});
},
cashout() {
applySyjCashout(this.id).then(() => {
uni.showToast({ title: '已提交审核' });
this.load();
});
},
statusText(status) {
return ['进行中', '已完成', '提前兑现', '已关闭', '异常', '审核中'][Number(status)] || '未知';
}
}
};
</script>
<style scoped>
.page{min-height:100vh;background:#f6f7f9;padding:24rpx;}
.card{background:#fff;border-radius:16rpx;padding:30rpx;margin-bottom:20rpx;}
.title{font-size:30rpx;color:#222;font-weight:600;}
.status{font-size:26rpx;color:#0f8f6f;margin-top:12rpx;}
.amount{font-size:52rpx;color:#111;font-weight:600;margin:28rpx 0;}
.progress{display:flex;align-items:center;gap:20rpx;}
.bar{flex:1;height:12rpx;background:#edf0f2;border-radius:12rpx;overflow:hidden;}
.bar view{height:100%;background:#0f8f6f;}
.section-title{font-size:30rpx;font-weight:600;margin-bottom:20rpx;}
.record{display:flex;justify-content:space-between;font-size:26rpx;color:#555;padding:20rpx 0;border-bottom:1px solid #f0f0f0;}
.empty{text-align:center;color:#999;font-size:26rpx;padding:40rpx 0;}
.cashout{height:88rpx;line-height:88rpx;border-radius:44rpx;background:#0f8f6f;color:#fff;font-size:30rpx;}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<view class="page">
<view class="summary">
<view class="summary-title">我的推广任务</view>
<view class="summary-amount">¥{{ overview.pending_amount || '0.00' }}</view>
<view class="summary-sub">待推广金额 / ¥{{ overview.base_amount || '4333.00' }} 生成 1 个任务</view>
<view class="summary-grid">
<view><text>{{ overview.active_task_count || 0 }}</text><text>进行中</text></view>
<view><text>{{ overview.completed_task_count || 0 }}</text><text>已完成</text></view>
<view><text>{{ overview.cashout_task_count || 0 }}</text><text>已兑现</text></view>
</view>
</view>
<view class="tabs">
<view v-for="tab in tabs" :key="tab.value" :class="{active: status === tab.value}" @tap="switchTab(tab.value)">{{ tab.label }}</view>
</view>
<view class="task" v-for="item in list" :key="item.id" @tap="goDetail(item.id)">
<view class="task-head">
<text>{{ item.task_no }}</text>
<text>{{ statusText(item.status) }}</text>
</view>
<view class="progress">
<view class="bar"><view :style="{width: progressWidth(item)}"></view></view>
<text>{{ item.progress_count }}/{{ item.target_count }}</text>
</view>
<view class="task-meta">来源订单{{ item.source_order_no }}</view>
<view class="task-meta">任务金额¥{{ item.base_amount }}</view>
</view>
<view v-if="!list.length && !loading" class="empty">暂无推广任务</view>
</view>
</template>
<script>
import { getSyjOverview, getSyjTaskList } from '@/api/syjPromote.js';
export default {
data() {
return {
overview: {},
list: [],
loading: false,
status: '',
page: 1,
tabs: [
{ label: '全部', value: '' },
{ label: '进行中', value: 0 },
{ label: '已完成', value: 1 },
{ label: '已兑现', value: 2 },
{ label: '异常', value: 4 }
]
};
},
onShow() {
this.load();
},
methods: {
load() {
this.loading = true;
Promise.all([
getSyjOverview(),
getSyjTaskList({ status: this.status, page: this.page, limit: 20 })
]).then(([overview, tasks]) => {
this.overview = overview.data || overview;
const data = tasks.data || tasks;
this.list = data.list || [];
}).finally(() => {
this.loading = false;
});
},
switchTab(value) {
this.status = value;
this.page = 1;
this.load();
},
goDetail(id) {
uni.navigateTo({ url: `/pages/syj/promote_task/detail?id=${id}` });
},
progressWidth(item) {
const total = Number(item.target_count || 4);
return `${Math.min(100, Number(item.progress_count || 0) / total * 100)}%`;
},
statusText(status) {
return ['进行中', '已完成', '提前兑现', '已关闭', '异常', '审核中'][Number(status)] || '未知';
}
}
};
</script>
<style scoped>
.page{min-height:100vh;background:#f6f7f9;padding:24rpx;}
.summary{background:#fff;border-radius:16rpx;padding:32rpx;}
.summary-title{font-size:30rpx;color:#333;}
.summary-amount{font-size:56rpx;font-weight:600;color:#0f8f6f;margin-top:20rpx;}
.summary-sub{font-size:24rpx;color:#777;margin-top:8rpx;}
.summary-grid{display:flex;margin-top:28rpx;border-top:1px solid #eee;padding-top:24rpx;}
.summary-grid view{flex:1;display:flex;flex-direction:column;gap:8rpx;font-size:24rpx;color:#777;}
.summary-grid text:first-child{font-size:34rpx;color:#222;font-weight:600;}
.tabs{display:flex;gap:16rpx;margin:24rpx 0;overflow-x:auto;}
.tabs view{white-space:nowrap;background:#fff;border-radius:999rpx;padding:14rpx 24rpx;font-size:26rpx;color:#555;}
.tabs .active{background:#0f8f6f;color:#fff;}
.task{background:#fff;border-radius:16rpx;padding:28rpx;margin-bottom:20rpx;}
.task-head{display:flex;justify-content:space-between;font-size:28rpx;color:#222;}
.task-head text:last-child{color:#0f8f6f;}
.progress{display:flex;align-items:center;gap:20rpx;margin:24rpx 0;}
.bar{flex:1;height:12rpx;background:#edf0f2;border-radius:12rpx;overflow:hidden;}
.bar view{height:100%;background:#0f8f6f;}
.task-meta{font-size:24rpx;color:#777;line-height:38rpx;}
.empty{text-align:center;color:#999;font-size:28rpx;padding:80rpx 0;}
</style>

View File

@@ -44,6 +44,9 @@
this.$Cache.set('spread', queryData.query.spid);
this.globalData.spid = queryData.query.spid;
this.globalData.pid = queryData.query.spid;
if (queryData.query.id) {
this.globalData.code = queryData.query.id;
}
silenceBindingSpread(this.globalData);
}
// #ifdef MP
@@ -53,6 +56,9 @@
this.$Cache.set('spread', param.spid);
this.globalData.spid = param.spid;
}
if (param.id) {
this.globalData.code = param.id;
}
/** 直播间分享**/
// const sceneList = [1007, 1008, 1014, 1044, 1045, 1046, 1047, 1048, 1049, 1073, 1154, 1155];
// if (sceneList.includes(queryData.query.scene)) {

View File

@@ -157,6 +157,14 @@ export function getSignCalendar(data) {
return request.get('sign/calendar', data)
}
/**
* 签到前置阅读:随机返回"签到广告"分类下一条文章
* 后端无该分类或分类下没有可用文章时 data 为 null前端可跳过门槛直接签到
*/
export function getSignRequiredArticle() {
return request.get('sign/required_article')
}
/**
* 活动状态
*

View File

@@ -91,13 +91,13 @@
<style lang="scss">
/* #ifdef H5 || APP-PLUS */
.activity-popup /deep/uni-swiper .uni-swiper-dot{
.activity-popup ::v-deep uni-swiper .uni-swiper-dot{
width: 14rpx;
height: 14rpx;
}
/* #endif */
/* #ifdef MP-WEIXIN */
.activity-popup /deep/.wx-swiper-dot~.wx-swiper-dot {
.activity-popup ::v-deep .wx-swiper-dot~.wx-swiper-dot {
width: 10rpx;
height: 10rpx;
}

View File

@@ -128,7 +128,7 @@
</script>
<style lang="scss">
/deep/ checkbox .wx-checkbox-input.wx-checkbox-input-checked {
::v-deep checkbox .wx-checkbox-input.wx-checkbox-input-checked {
border: 1px solid $primary-admin !important;
background-color: $primary-admin !important;
color: #fff !important;

View File

@@ -28,6 +28,23 @@ import {
} from './../config/cache';
import Routine from '@/libs/routine';
function normalizeRoutineAuthTypes(config = {}) {
let types = config.routine_auth_type;
if (Array.isArray(types)) return types.map(item => Number(item));
if (typeof types === 'string') return types.split(',').map(item => Number(item));
return [];
}
function isRoutineWechatAuth(config = {}) {
const authTypes = normalizeRoutineAuthTypes(config);
return Number(config.wechat_auth_switch) === 1 || authTypes.includes(1);
}
function isRoutinePhoneAuth(config = {}) {
const authTypes = normalizeRoutineAuthTypes(config);
return Number(config.phone_auth_switch) === 1 || authTypes.includes(2);
}
function prePage() {
let pages = getCurrentPages();
let prePage = pages[pages.length - 1];
@@ -56,7 +73,7 @@ function _toLogin(push, pathLogin) {
if (!pathLogin)
pathLogin = '/page/users/login/index'
Cache.set('login_back_url', path);
const BASIC_CONFIG = uni.getStorageSync('BASIC_CONFIG');
const BASIC_CONFIG = uni.getStorageSync('BASIC_CONFIG') || {};
// #ifdef H5
if (isWeixin() && BASIC_CONFIG.wechat_status) {
uni.navigateTo({
@@ -72,7 +89,7 @@ function _toLogin(push, pathLogin) {
// #ifdef MP
let url
if (!BASIC_CONFIG.wechat_auth_switch) {
if (!isRoutineWechatAuth(BASIC_CONFIG) && isRoutinePhoneAuth(BASIC_CONFIG)) {
url = '/pages/users/binding_phone/index?pageType=0'
} else {
url = '/pages/users/wechat_login/index'
@@ -121,4 +138,4 @@ export function checkLogin() {
return true;
}
}
}

View File

@@ -1,7 +1,7 @@
{
"name" : "阿胶黄精粉",
"appid" : "__UNI__6691FE3",
"description" : "阿胶黄精粉商城",
"name" : "芍药居草本",
"appid" : "__UNI__518D050",
"description" : "芍药居草本商城",
"versionName" : "3.5.1",
"versionCode" : 351,
"transformPx" : false,
@@ -201,7 +201,7 @@
"optimization" : {
"subPackages" : true
},
"appid" : "wx998d9e0a925a1a13"
"appid" : "wxaafcb57c19b75f4d"
},
"mp-alipay" : {
"usingComponents" : true

View File

@@ -1192,7 +1192,7 @@
background-size: 100% 100%;
width: 100%;
height: 116rpx;
/deep/.time{
::v-deep .time{
padding-top: 14px;
.styleAll{
background-color: #E93323;

View File

@@ -1096,7 +1096,7 @@
color: #fc4141;
}
.group-con .wrapper .title .name /deep/.time .styleAll {
.group-con .wrapper .title .name ::v-deep .time .styleAll {
text-align: center;
border-radius: 8rpx;
font-size: 24rpx;

View File

@@ -1560,14 +1560,14 @@
</script>
<style lang="scss">
/deep/uni-video {
::v-deep uni-video {
width: 100% !important;
}
/deep/video {
::v-deep video {
width: 100% !important;
}
/deep/ .styleAll{
::v-deep .styleAll{
display: inline-block;
min-width: 34rpx;
height: 36rpx;
@@ -1578,7 +1578,7 @@
font-family:'Regular';
padding: 0 2rpx;
}
.seckill-card /deep/ .timeTxt{
.seckill-card ::v-deep .timeTxt{
padding: 0 6rpx;
}
.z-99{

View File

@@ -373,7 +373,7 @@
right: 30rpx;
}
.sel-last {
/deep/.uni-scroll-view{
::v-deep .uni-scroll-view{
margin-right: -10rpx;
}

View File

@@ -282,7 +282,7 @@
</script>
<style lang="scss">
/deep/ .navbar{
::v-deep .navbar{
z-index: 90;
}
.bg-top{

View File

@@ -251,7 +251,7 @@
font-size: 18rpx;
color: #e93323;
}
/deep/ .empty-box{
::v-deep .empty-box{
width: 100%;
height: 280px;
}

View File

@@ -330,7 +330,7 @@
min-height: 0;
box-sizing: border-box;
/deep/.shoppingCart {
::v-deep .shoppingCart {
position: absolute;
top: 0;
right: 0;

View File

@@ -162,15 +162,15 @@
</script>
<style lang="scss" scoped>
/deep/ checkbox .wx-checkbox-input.wx-checkbox-input-checked {
::v-deep checkbox .wx-checkbox-input.wx-checkbox-input-checked {
border: 1px solid $primary-admin;
background-color: $primary-admin;
color: #fff;
}
/deep/checkbox{
::v-deep checkbox{
margin-right: 13rpx;
}
/deep/uni-checkbox .uni-checkbox-input{
::v-deep uni-checkbox .uni-checkbox-input{
border-radius: 4rpx;
width: 28rpx;
height: 28rpx;

View File

@@ -413,14 +413,14 @@
</script>
<style lang="scss" scoped>
/deep/checkbox .wx-checkbox-input.wx-checkbox-input-checked {
::v-deep checkbox .wx-checkbox-input.wx-checkbox-input-checked {
border: 1px solid $primary-admin !important;
background-color: $primary-admin !important;
}
/deep/uni-checkbox .uni-checkbox-input{
::v-deep uni-checkbox .uni-checkbox-input{
margin-top: -4rpx;
}
/deep/checkbox:not([disabled]) .uni-checkbox-input:hover {
::v-deep checkbox:not([disabled]) .uni-checkbox-input:hover {
border-color: #d1d1d1 !important;
}
.empty-box{
@@ -522,11 +522,11 @@
.list{
padding-bottom: 20rpx;
padding: 0 20rpx 20rpx 20rpx;
/deep/uni-checkbox .uni-checkbox-input{
::v-deep uni-checkbox .uni-checkbox-input{
background-color: #f5f5f5;
margin: 0 20rpx 20rpx 0;
}
/deep/wx-checkbox .wx-checkbox-input{
::v-deep wx-checkbox .wx-checkbox-input{
background-color: #f5f5f5;
margin: 0 20rpx 20rpx 0;
}

View File

@@ -221,11 +221,11 @@ export default {
</script>
<style lang="scss" scoped>
/deep/checkbox .uni-checkbox-input.uni-checkbox-input-checked {
::v-deep checkbox .uni-checkbox-input.uni-checkbox-input-checked {
border: 1px solid $primary-admin !important;
background-color: $primary-admin !important;
}
/deep/checkbox .wx-checkbox-input.wx-checkbox-input-checked {
::v-deep checkbox .wx-checkbox-input.wx-checkbox-input-checked {
border: 1px solid $primary-admin !important;
background-color: $primary-admin !important;
}

View File

@@ -695,7 +695,7 @@
}
.height-add {
height: calc(120rpx+ constant(safe-area-inset-bottom)); ///兼容 IOS<11.2/
height: calc(120rpx + constant(safe-area-inset-bottom)); ///兼容 IOS<11.2/
height: calc(120rpx + env(safe-area-inset-bottom)); ///兼容 IOS>11.2/
}

View File

@@ -582,7 +582,7 @@
<style lang="scss" scoped>
.pos-order-list {
/deep/.navbar {
::v-deep .navbar {
.content {
background: #F5F5F5 !important;
}
@@ -685,7 +685,7 @@
border-radius: 24rpx;
background-color: #fff;
/deep/.time {
::v-deep .time {
.title {
color: #FF7E00;
}

View File

@@ -175,7 +175,7 @@ export default {
font-family: Regular;
color: #FF7E00;
}
/deep/.uni-input-input{
::v-deep .uni-input-input{
padding-right: 10rpx;
}
.placeholder{

View File

@@ -165,7 +165,7 @@ export default {
font-family: Regular;
color: #FF7E00;
}
/deep/.uni-input-input{
::v-deep .uni-input-input{
padding-right: 10rpx;
}
.placeholder{

View File

@@ -431,14 +431,14 @@
</script>
<style lang="scss" scoped>
/deep/checkbox .wx-checkbox-input.wx-checkbox-input-checked {
::v-deep checkbox .wx-checkbox-input.wx-checkbox-input-checked {
border: 1px solid $primary-admin !important;
background-color: $primary-admin !important;
}
/deep/checkbox:not([disabled]) .uni-checkbox-input:hover {
::v-deep checkbox:not([disabled]) .uni-checkbox-input:hover {
border-color: #d1d1d1 !important;
}
/deep/.empty-page{
::v-deep .empty-page{
margin-top: 20rpx;
}
.accountTitle{
@@ -507,10 +507,10 @@
.list{
padding-bottom: 20rpx;
margin-top: 32rpx;
/deep/uni-checkbox .uni-checkbox-input{
::v-deep uni-checkbox .uni-checkbox-input{
margin: 0 20rpx 20rpx 0;
}
/deep/wx-checkbox .wx-checkbox-input{
::v-deep wx-checkbox .wx-checkbox-input{
margin: 0 20rpx 20rpx 0;
}
.item {
@@ -643,7 +643,7 @@
width: 100%;
left: 0;
/deep/uni-checkbox .uni-checkbox-input{
::v-deep uni-checkbox .uni-checkbox-input{
margin-bottom: 6rpx;
}

Some files were not shown because too many files have changed in this diff Show More