diff --git a/.cursor/rules/backend-admin.mdc b/.cursor/rules/backend-admin.mdc new file mode 100644 index 0000000..24f60b9 --- /dev/null +++ b/.cursor/rules/backend-admin.mdc @@ -0,0 +1,61 @@ +--- +description: FastAdmin 管理后台规范:CRUD 控制器、视图、语言包、前端表格 JS +globs: backend/application/admin/**,backend/public/assets/js/backend/** +alwaysApply: false +--- + +# FastAdmin 管理后台规范 + +## 新增模块 + +1. 先建表(`fa_` 前缀,`createtime`/`updatetime` 为 int,枚举字段 COMMENT 写成 `状态:0=关闭,1=招标中`) +2. 用命令生成骨架,再在生成结果上修改:`php think crud -t demands -u 1`,菜单 `php think menu -c demands` +3. 一个模块对应四处文件,命名保持一致: + - 控制器 `admin/controller/[子目录/]Demands.php` + - 视图 `admin/view/[子目录/]demands/{index,add,edit}.html` + - 语言包 `admin/lang/zh-cn/[子目录/]demands.php`(字段名、`Status 1` 等枚举文案) + - 表格 JS `public/assets/js/backend/[子目录/]demands.js` + +## 控制器 + +- 继承 `app\common\controller\Backend`;在 `_initialize()` 中实例化 `$this->model`(使用 `app\common\model` 下的模型)并 `assign` 各 `xxxList` +- 默认 `index/add/edit/del/multi` 由 trait 提供,只有需要定制时才覆盖;覆盖时以 `admin/library/traits/Backend.php` 中原方法为蓝本 +- 关联查询覆盖 `index()`:`$this->relationSearch = true`,`->with([...])`,并用 `visible()` 限定关联字段 +- 保存时用 `$this->model->allowField(true)->save($params)`,入参先经 `preExcludeFields()` +- 自定义操作(审核、重新投标、取消等): + +```php +public function audit($ids) +{ + $row = $this->model->get($ids); + if (!$row) { + $this->error(__('No Results were found')); + } + if ($this->request->isAjax()) { + try { + DemandService::auditPass($ids); + } catch (\Exception $e) { + $this->error($e->getMessage()); + } + $this->success('审核成功'); + } + $this->view->assign('row', $row->toArray()); + return $this->view->fetch(); +} +``` + +- 业务流程(资金、状态流转、通知)必须调用 `common/service`,与 API 共用同一套逻辑,不在后台控制器里重写 +- `$this->success()` 放在 try 外,或在 catch 中对 `HttpResponseException` 原样抛出 +- 新增自定义方法后需在权限菜单中添加对应节点,否则非超管无权限 + +## 表格 JS + +- 使用 `define([... 'table', 'form'], ...)` + `Table.api.init({ extend: {...} })`,列文案一律 `__('Field')` +- 枚举列 `searchList` 与模型 `getXxxList()` 保持一致,配合 `Table.api.formatter.status/normal` +- 自定义按钮放在 `operate` 列的 `buttons` 中,弹窗用 `btn-dialog`,Ajax 用 `btn-ajax` 并带 `confirm` +- 表单页调用 `Controller.api.bindevent()` → `Form.api.bindevent($("form[role=form]"))` + +## 视图 + +- 表单字段名使用 `row[field]`;图片/文件使用 FastAdmin 的 `faupload` 组件 +- 不在模板内写业务判断,下拉选项使用控制器 assign 的 `xxxList` diff --git a/.cursor/rules/backend-php.mdc b/.cursor/rules/backend-php.mdc new file mode 100644 index 0000000..ee15778 --- /dev/null +++ b/.cursor/rules/backend-php.mdc @@ -0,0 +1,109 @@ +--- +description: 后端 PHP 规范:API 控制器、Service 层、模型、事务、资金与安全(ThinkPHP 5 / FastAdmin) +globs: backend/application/**/*.php +alwaysApply: false +--- + +# 后端 PHP 规范 + +## 语言版本与风格 + +- 兼容 PHP 7.4:可用箭头函数 `fn`、类型属性;**禁止** `match`、命名参数、构造器属性提升、`?->`、联合类型、`enum`、`readonly` +- PSR-12 风格,4 空格缩进;类名 PascalCase,方法/变量 camelCase,数据库字段与请求字段 snake_case +- 命名空间与目录一致:`app\api\controller`、`app\common\service`、`app\common\model\{子目录}` + +## 分层职责 + +| 层 | 位置 | 职责 | +| --- | --- | --- | +| API 控制器 | `api/controller/` | 取参、校验、登录判断、组装返回;不写业务流程 | +| Service | `common/service/XxxService.php` | 业务逻辑、状态流转、事务、状态常量 | +| Model | `common/model/` | 表映射、关联、获取器/修改器、`*_text` 追加字段 | +| Validate | `common/validate/` | 可复用的验证规则 | +| 定时/队列 | `common/command/`、`common/job/` | 调用 Service,不内联业务 | + +## API 控制器模板 + +`success()/error()` 通过抛出 `HttpResponseException` 输出,catch 里必须原样抛出: + +```php +class Demand extends Api +{ + protected $noNeedLogin = []; // 免登录方法,支付回调等必须显式列出 + protected $noNeedRight = ['*']; + + /** + * 参与竞标 + * @ApiMethod (POST) + * @param int $demand_id 需求ID + */ + public function joinBidding() + { + try { + $params = $this->request->post(); + $validate = new Validate(['demand_id' => 'require|number'], ['demand_id.require' => '需求ID不能为空']); + if (!$validate->check($params)) { + $this->error($validate->getError()); + } + $res = DemandBiddingService::join($params, $this->auth->id); + $this->success('报名成功', $res); + } catch (\Exception $e) { + if ($e instanceof HttpResponseException) { + throw $e; + } + $this->error($e->getMessage()); + } + } +} +``` + +- 数组参数用 `$this->request->post('list/a', [])`;数值参数显式 `intval()` +- 需要登录的接口不要放进 `$noNeedLogin`,也不要用 `$this->auth->id ?? -1` 绕过登录 +- 新增接口后在 `frontend/miniapp/config/api.js` 登记 + +## Service 模板 + +```php +class DemandService +{ + use ServiceTrait; // 提供 instance()/getOne()/getList(),并静态代理模型方法 + + /** @var Demands */ + public static $Model = Demands::class; + + const STATUS_BIDDING = 1; //招标中 + const STATUS_MAP = [self::STATUS_BIDDING => '招标中']; +} +``` + +- 方法默认 `public static`,带中文 docblock(`@param`/`@return`/`@throws`) +- 查询优先 `self::getOne($where, $with)`、`self::getList($where, $pageSize, $order, '', $with)` +- 状态值用 Service 常量或 `CommonService::NO/ONGOING/SUCCESS/FAILED`、`STATUS_ON/OFF`,**禁止**新代码出现魔法数字 `status == 2` +- 失败直接抛 `\Exception('中文原因')`(或 `exception()` 助手),不返回 `false`/错误数组 +- 涉及"操作他人数据"的方法必须接收 `$userId` 并校验归属(如 `$demand->user_id == $userId`),不能只凭前端传的 ID + +## 事务与并发 + +- 前置校验放在事务外,写操作放在事务内;catch 中 `Db::rollback()` 后 `throw $e` +- 余额、状态流转、库存/激活码分配:事务内用 `->lock(true)` 读取再判断,防止并发重复操作 +- 订阅消息、企业微信、OSS、微信转账等外部调用放在 `Db::commit()` **之后**,失败只记日志不回滚业务 +- 支付/转账回调必须幂等:先查订单当前状态,已处理直接返回成功 + +## 资金与账户 + +- 余额/冻结/积分/信誉分只通过 `AccountLogService::addLog()` + `AccountService::updateData()` 变更,二者在同一事务内 +- 金额计算用 bcmath(`bcadd/bcsub/bcmul`,精度 2),不用浮点直接加减比较 +- 结算价、订单金额从数据库商品/订单读取,不使用请求中的 `price` + +## 模型 + +- `$name` 不带前缀;`$autoWriteTimestamp = 'integer'`,`$createTime = 'createtime'`,`$updateTime = 'updatetime'` +- JSON/数组字段用 `$type = ['images' => 'array']` +- 枚举字段配套 `getXxxList()`(文案走 `__()` 语言包)+ `getXxxTextAttr()`,并加入 `$append` +- 关联方法 camelCase,并用 `->field()` 限定字段,避免泄露手机号、openid 等敏感字段 + +## 日志与配置 + +- `Log::info(__CLASS__ . ' 描述:' . json_encode($data, JSON_UNESCAPED_UNICODE))`;禁止 `var_dump`/`echo`/`dump` 残留 +- 站点配置读取 `config('site.xxx')`,环境配置读取 `Env::get()`;不在代码里硬编码密钥与域名 +- 原生 SQL 必须参数绑定;`like` 关键词使用 `['like', "%{$keywords}%"]` 数组条件,不拼接字符串 diff --git a/.cursor/rules/frontend-miniapp.mdc b/.cursor/rules/frontend-miniapp.mdc new file mode 100644 index 0000000..47a1a33 --- /dev/null +++ b/.cursor/rules/frontend-miniapp.mdc @@ -0,0 +1,84 @@ +--- +description: 微信小程序前端规范:页面结构、接口服务层、TDesign 组件、样式与交互 +globs: frontend/miniapp/**/*.{js,wxml,wxss,json,wxs} +alwaysApply: false +--- + +# 微信小程序前端规范 + +## 代码风格 + +- 遵循 `.prettierrc.yml` 与 `.eslintrc.js`:2 空格、单引号、分号、行宽 120、尾逗号、LF +- 用 `const/let`,禁止 `var`;用模板字符串,比较用 `===`;ES Module `import/export` +- 不提交调试日志:`console.log` 需删除,保留 `console.error/warn` 用于异常 +- 提交信息遵循 commitlint(`feat: 新增需求交付页`、`fix: ...`) + +## 目录与命名 + +- 页面:`pages/{模块}/{页面}/index.{js,json,wxml,wxss}`,目录名 kebab-case,新页面必须在 `app.json` 的 `pages` 中注册 +- 页面私有组件放 `pages/{模块}/components/`,跨页面复用放 `components/` +- 接口函数放 `services/{模块}/fetchXxx.js`,一个模块一个文件 + +## 接口调用 + +1. 在 `config/api.js` 登记地址:`DemandDeliver: ApiRootUrl + 'demand/deliver', //需求发货` +2. 在 services 中封装,通过全局 `wx.$util.request(url, data, method)`: + +```js +/** 需求发货 */ +export function deliverDemand(params) { + return wx.$util.request(wx.$api.DemandDeliver, params, 'POST'); +} +``` + +3. 页面只 import services 函数,**禁止**在页面里直接 `wx.request` 或拼接接口地址 +4. 结果按 `res.code === 1` 判断成功,失败展示 `res.msg`: + +```js +async onSubmit() { + wx.showLoading({ title: '提交中', mask: true }); + try { + const res = await deliverDemand({ demand_id: this.data.id }); + if (res.code !== 1) { + wx.showToast({ title: res.msg || '操作失败', icon: 'none' }); + return; + } + wx.showToast({ title: '发货成功', icon: 'success' }); + } catch (err) { + console.error('deliverDemand failed:', err); + wx.showToast({ title: '网络异常,请重试', icon: 'none' }); + } finally { + wx.hideLoading(); + } +} +``` + +- 新代码使用 `async/await`,不再写 `.then(success, fail)` 嵌套 +- 提交类按钮加防重复点击(`submitting` 标志或 `t-button` 的 `loading`) + +## 页面逻辑 + +- `data` 中每个字段写注释说明含义;列表页固定使用 `page`、`hasMore`、`isEmpty` 三个字段 +- 分页:`onReachBottom` 判断 `hasMore` 加载下一页;`onPullDownRefresh` 刷新后调用 `wx.stopPullDownRefresh()` +- `setData` 只传变化的字段,单条更新用数据路径写法(如键名 `demandList[0].button`),不整体替换大列表 +- 跨页面刷新使用已有机制:`wx.$util.markDemandRefresh(id)`(`globalData.refreshDemandId`)或 `backAndRefresh()`,不新增其他全局通信方式 +- 按钮动作按后端返回的 `item.button.key` 分支,常量与后端 `DemandService::BUTTON_*` 保持一致 +- 删除模板生成的空生命周期函数(空的 `onReady/onHide/onUnload/onShareAppMessage`) + +## WXML / 组件 + +- UI 优先使用 TDesign 组件,在页面 `index.json` 的 `usingComponents` 中按需引入(`tdesign-miniprogram/xxx/xxx`);图片优先 `/components/webp-image/index` +- `wx:for` 必须写 `wx:key`(优先唯一 `id`);嵌套循环使用不同的 `wx:for-item/wx:for-index` 名 +- 事件参数通过 `data-*` 传递,在 js 中用 `e.currentTarget.dataset` 解构读取 +- 不在 WXML 中写复杂表达式,复杂格式化放 `.wxs` 或在 js 中预处理 + +## WXSS + +- 尺寸使用 `rpx`;类名小写中划线,可用 BEM(`search-bar__input`、`refresh-btn--loading`) +- 主题色沿用 TabBar 选中色 `#FF5F15` 与 TDesign CSS 变量(`--td-brand-color` 等),不随意新增色值 +- 避免行内 `style`,仅动态值(如 `max-height`)可用 + +## 安全与隐私 + +- 不在前端保存任何密钥;`token`、`userInfo` 仅存于 Storage +- 手机号展示使用 `phoneEncryption()` 脱敏;用户输入在提交前做必要的非空/长度校验,但以后端校验为准 diff --git a/.cursor/rules/project-overview.mdc b/.cursor/rules/project-overview.mdc new file mode 100644 index 0000000..c821a47 --- /dev/null +++ b/.cursor/rules/project-overview.mdc @@ -0,0 +1,35 @@ +--- +description: 代码帮项目总览、目录边界与前后端接口契约(全局生效) +alwaysApply: true +--- + +# 代码帮(code-bang)项目总览 + +软件商城 + 定制开发撮合平台。后端 `backend/`:FastAdmin(ThinkPHP 5.x,PHP >= 7.4,MySQL,表前缀 `fa_`);前端 `frontend/miniapp/`:微信原生小程序 + TDesign(基于零售模板二次开发)。 + +## 目录边界 + +- 业务代码只写在:`backend/application/{api,admin,common}`、`backend/public/assets/js/backend`、`frontend/miniapp/{pages,components,services,config,utils,custom-tab-bar}` +- 禁止修改:`backend/vendor`、`backend/thinkphp`、`backend/addons/*`(第三方插件)、`frontend/miniapp/node_modules`、`miniprogram_npm` +- FastAdmin 框架基类(`common/controller/*`、`admin/library/traits/Backend.php`、`common/library/*`)非必要不改,需要定制时在子类覆盖 +- 零售模板遗留的 mock(`model/`、`config.useMock`)不再扩展,新功能直接对接真实接口 + +## 接口契约(前后端必须一致) + +- 地址:`{ApiRoot}/api/{controller}/{action}`,前端统一登记在 `frontend/miniapp/config/api.js` +- 鉴权:请求头 `token`(来自 `wx.getStorageSync('token')`),由 `auth/wxLogin` 签发 +- 响应:`{ code, msg, time, data }`,`code === 1` 成功,`code === 0` 失败,`msg` 为可直接展示给用户的中文提示 +- 分页:后端 `paginate()` 返回 `{ total, per_page, current_page, last_page, data }`,前端用 `current_page < last_page` 判断是否还有更多;请求参数 `page`、`pageSize` +- 字段命名:新接口统一 `snake_case`(与数据库一致);仅对接 TDesign 模板页面(商品/订单/购物车)时才用 `convert_keys_to_camelcase()` 转驼峰 +- 状态驱动的按钮由后端下发 `button: { key, name, theme }`,前端只按 `key` 判断动作,不按中文文案判断 +- 金额:数据库 `decimal(10,2)`,单位为元;价格、应付金额一律以服务端查库计算为准,不信任前端传入的价格 + +## 业务术语 + +需求 demand(招标 → 签约 → 工作 → 验收 → 收货 → 完成)、投标 bidding、合同 contract、开发者 developer、账户 account(余额/冻结/积分/信誉分)、激活码 soft code、设备 soft device(每码最多绑定 2 台)、订阅消息 subscribe message。 + +## 通用约定 + +- 注释、提示文案、提交说明使用中文;标识符使用英文 +- 不提交密钥:AppSecret、商户密钥、OSS AccessKey 等只放 `backend/.env` 或后台配置(`config('site.xxx')`) +- 改动接口字段时,同步检查前端 `services/` 与对应页面的使用处 diff --git a/backend/application/admin/lang/zh-cn/account/logs.php b/backend/application/admin/lang/zh-cn/account/logs.php index fd4faac..8cfc019 100644 --- a/backend/application/admin/lang/zh-cn/account/logs.php +++ b/backend/application/admin/lang/zh-cn/account/logs.php @@ -18,6 +18,7 @@ return [ 'Type 5' => '提现', 'Type 6' => '冻结', 'Type 7' => '解冻', + 'Type 8' => '推广佣金', 'Type 9' => '后台操作', 'Credit_type' => '信用分类型', 'Credit_type 1' => '任务完成奖励', @@ -26,7 +27,7 @@ return [ 'Credit_type 4' => '放弃签约扣除', 'Credit_type 5' => '系统退回', 'Credit_type 9' => '后台操作', - 'Percent' => '分成百分比', + 'Percent' => '分成比例', 'Order_amount' => '订单总金额', 'Refer_id' => '引用数据ID', 'Inc_or_exp' => '增减类型', diff --git a/backend/application/admin/lang/zh-cn/orders.php b/backend/application/admin/lang/zh-cn/orders.php index 070817e..58ba9c5 100644 --- a/backend/application/admin/lang/zh-cn/orders.php +++ b/backend/application/admin/lang/zh-cn/orders.php @@ -35,6 +35,14 @@ return [ 'Rights_type' => '维权类型', 'Auto_cancel_time' => '自动取消时间', 'Order_status_remark' => '订单状态备注', + 'Promoter_id' => '推广人ID', + 'Commission_ratio' => '佣金比例', + 'Commission_amount' => '推广佣金', + 'Commission_status' => '佣金状态', + 'Commission_status 0' => '待结算', + 'Commission_status 1' => '已结算', + 'Commission_status 2' => '不分佣', + 'Commission_time' => '佣金结算时间', 'Createtime' => '创建时间', 'Updatetime' => '更新时间' ]; diff --git a/backend/application/admin/lang/zh-cn/user/user.php b/backend/application/admin/lang/zh-cn/user/user.php index 13f096d..1ab72ea 100644 --- a/backend/application/admin/lang/zh-cn/user/user.php +++ b/backend/application/admin/lang/zh-cn/user/user.php @@ -29,5 +29,6 @@ return [ 'Updatetime' => '更新时间', 'Token' => 'Token', 'Status' => '状态', + 'Promoter_id' => '推广人ID', 'Leave password blank if dont want to change' => '不修改密码请留空', ]; diff --git a/backend/application/admin/view/account/logs/add.html b/backend/application/admin/view/account/logs/add.html index 9770910..ab24f69 100644 --- a/backend/application/admin/view/account/logs/add.html +++ b/backend/application/admin/view/account/logs/add.html @@ -51,7 +51,7 @@
- +
diff --git a/backend/application/admin/view/account/logs/edit.html b/backend/application/admin/view/account/logs/edit.html index 54dee90..7ee8196 100644 --- a/backend/application/admin/view/account/logs/edit.html +++ b/backend/application/admin/view/account/logs/edit.html @@ -51,7 +51,7 @@
- +
diff --git a/backend/application/api/controller/Auth.php b/backend/application/api/controller/Auth.php index ede5eb4..090bfa5 100644 --- a/backend/application/api/controller/Auth.php +++ b/backend/application/api/controller/Auth.php @@ -4,6 +4,7 @@ namespace app\api\controller; use app\common\controller\Api; use app\common\model\User; +use app\common\service\PromotionService; use app\common\service\UserService; use think\exception\HttpResponseException; use think\Validate; @@ -65,6 +66,8 @@ class Auth extends Api $this->error('参数错误'); } $user = UserService::wxLogin($code); + //通过分享链接/小程序码进入时携带推广人ID,首次进入即绑定 + PromotionService::bindPromoter(intval($user->id), intval($this->request->post('promoter_id', 0))); $this->auth->direct($user->id); $userInfo = $this->auth->getUserinfo(); $this->success('登录成功', ['userinfo' => $this->auth->getUserinfo(), 'token' => $userInfo['token']]); diff --git a/backend/application/api/controller/Promotion.php b/backend/application/api/controller/Promotion.php new file mode 100644 index 0000000..c861923 --- /dev/null +++ b/backend/application/api/controller/Promotion.php @@ -0,0 +1,93 @@ +request->post('promoter_id', 0)); + $res = PromotionService::bindPromoter(intval($this->auth->id), $promoterId); + $this->success('', ['bound' => $res]); + } catch (\Exception $e) { + if ($e instanceof HttpResponseException) { + throw $e; + } + $this->error($e->getMessage()); + } + } + + /** + * 推广中心概览 + * + * @ApiMethod (GET) + */ + public function info() + { + try { + $this->success('', PromotionService::getOverview(intval($this->auth->id))); + } catch (\Exception $e) { + if ($e instanceof HttpResponseException) { + throw $e; + } + $this->error($e->getMessage()); + } + } + + /** + * 推广订单列表 + * + * @ApiMethod (GET) + * @param int $page 页码 + * @param int $pageSize 每页条数 + */ + public function orders() + { + try { + $pageSize = min(max(intval($this->request->get('pageSize', 10)), 1), 50); + $this->success('', PromotionService::getOrderList(intval($this->auth->id), $pageSize)); + } catch (\Exception $e) { + if ($e instanceof HttpResponseException) { + throw $e; + } + $this->error($e->getMessage()); + } + } + + /** + * 推广小程序码 + * + * @ApiMethod (GET) + * @param string $envVersion 小程序版本 release|trial|develop + */ + public function qrcode() + { + try { + $envVersion = (string)$this->request->get('envVersion', 'release'); + $qrcode = PromotionService::getQrcode(intval($this->auth->id), $envVersion); + $this->success('', ['qrcode' => $qrcode]); + } catch (\Exception $e) { + if ($e instanceof HttpResponseException) { + throw $e; + } + $this->error($e->getMessage()); + } + } +} diff --git a/backend/application/common/command/DemandDeal.php b/backend/application/common/command/DemandDeal.php index d0a4bad..e39e004 100644 --- a/backend/application/common/command/DemandDeal.php +++ b/backend/application/common/command/DemandDeal.php @@ -7,6 +7,7 @@ use app\common\service\AccountService; use app\common\service\EasyWeChatService; use app\common\service\OrderService; use app\common\service\OrderShipService; +use app\common\service\PromotionService; use app\common\service\SubscribeMessageService; use think\console\Command; use think\console\Input; @@ -27,6 +28,8 @@ class DemandDeal extends Command $output->writeln("bidding:deal:"); $this->confirmReceiptTimeoutDeal($input, $output); $this->demandProfitUnfreezeDeal($input, $output); + //兜底:结算交易完成但未结算的推广佣金(如后台直接改为已完成的订单) + PromotionService::settlePendingOrders(); } /** @@ -64,6 +67,8 @@ class DemandDeal extends Command if (!$upRes) { exception('更新订单状态失败'); } + //交易完成:结算推广佣金 + PromotionService::settleOrders(['parent_order_no' => $item->parent_order_no]); } }); } catch (\Exception $e) { diff --git a/backend/application/common/model/Orders.php b/backend/application/common/model/Orders.php index f38bbed..0c8d4d4 100644 --- a/backend/application/common/model/Orders.php +++ b/backend/application/common/model/Orders.php @@ -24,7 +24,8 @@ class Orders extends Model protected $append = [ 'order_type_text', 'order_status_text', - 'auto_cancel_time_text' + 'auto_cancel_time_text', + 'commission_status_text' ]; @@ -39,6 +40,18 @@ class Orders extends Model } + public function getCommissionStatusList() + { + return ['0' => __('Commission_status 0'), '1' => __('Commission_status 1'), '2' => __('Commission_status 2')]; + } + + public function getCommissionStatusTextAttr($value, $data) + { + $value = $value ? $value : (isset($data['commission_status']) ? $data['commission_status'] : ''); + $list = $this->getCommissionStatusList(); + return isset($list[$value]) ? $list[$value] : ''; + } + public function getOrderTypeTextAttr($value, $data) { $value = $value ? $value : (isset($data['order_type']) ? $data['order_type'] : ''); diff --git a/backend/application/common/model/account/Logs.php b/backend/application/common/model/account/Logs.php index c10dad7..e17dceb 100644 --- a/backend/application/common/model/account/Logs.php +++ b/backend/application/common/model/account/Logs.php @@ -35,7 +35,7 @@ class Logs extends Model public function getTypeList() { - return ['1' => __('Type 1'), '2' => __('Type 2'), '3' => __('Type 3'), '4' => __('Type 4'), '5' => __('Type 5'), '6' => __('Type 6'), '7' => __('Type 7'), '9' => __('Type 9')]; + return ['1' => __('Type 1'), '2' => __('Type 2'), '3' => __('Type 3'), '4' => __('Type 4'), '5' => __('Type 5'), '6' => __('Type 6'), '7' => __('Type 7'), '8' => __('Type 8'), '9' => __('Type 9')]; } public function getCreditTypeList() diff --git a/backend/application/common/service/AccountLogService.php b/backend/application/common/service/AccountLogService.php index 5224192..299e327 100644 --- a/backend/application/common/service/AccountLogService.php +++ b/backend/application/common/service/AccountLogService.php @@ -13,7 +13,7 @@ class AccountLogService /** @var AccountLogs */ public static $Model = AccountLogs::class; - //日志类型 1充值 2消费 3收益 4退款 5提现 6冻结 7解冻 9后台操作 + //日志类型 1充值 2消费 3收益 4退款 5提现 6冻结 7解冻 8推广佣金 9后台操作 const TYPE_RECHARGE = 1; const TYPE_CONSUME = 2; const TYPE_PROFIT = 3; @@ -21,6 +21,7 @@ class AccountLogService const TYPE_WITHDRAW = 5; const TYPE_FREEZE = 6; const TYPE_UNFREEZE = 7; + const TYPE_COMMISSION = 8; const TYPE_ADMIN_OPERATE = 9; const TYPE_MAP = [ @@ -31,6 +32,7 @@ class AccountLogService self::TYPE_WITHDRAW => '提现', self::TYPE_FREEZE => '冻结', self::TYPE_UNFREEZE => '解冻', + self::TYPE_COMMISSION => '推广佣金', self::TYPE_ADMIN_OPERATE => '后台操作', ]; @@ -66,7 +68,7 @@ class AccountLogService * @param $user_id * @param $amount * @param $log - * @param $type int 日志类型:1充值 2消费 3收益 4退款 5提现 6冻结 7解冻 9后台操作 + * @param $type int 日志类型:1充值 2消费 3收益 4退款 5提现 6冻结 7解冻 8推广佣金 9后台操作 * @param $credit_type int 信誉分日志类型:1=任务完成奖励,2=任务失败扣除,3=拒绝接单扣除,4=放弃签约扣除,5=系统退回,9=后台操作 * @param $inc_or_exp 1=收入 2支出 * @param int $refer_id @@ -75,7 +77,7 @@ class AccountLogService * @return mixed * @throws \Exception */ - public static function addLog($account_type, $user_id, $amount, $log, int $type, int $credit_type, $inc_or_exp, int $refer_id = 0, int $total_amount = 0, $percent = 0) + public static function addLog($account_type, $user_id, $amount, $log, int $type, int $credit_type, $inc_or_exp, int $refer_id = 0, $total_amount = 0, $percent = 0) { $data = [ 'account_type' => $account_type, diff --git a/backend/application/common/service/DemandService.php b/backend/application/common/service/DemandService.php index db244db..52c6dfb 100644 --- a/backend/application/common/service/DemandService.php +++ b/backend/application/common/service/DemandService.php @@ -562,6 +562,8 @@ class DemandService if (!$upRes) { exception('更新订单状态失败'); } + //交易完成:结算推广佣金 + PromotionService::settleOrders(['parent_order_no' => $demand->parent_order_no]); Db::commit(); return $demand; } catch (\Exception $e) { diff --git a/backend/application/common/service/OrderService.php b/backend/application/common/service/OrderService.php index f8f2407..5bdeb23 100644 --- a/backend/application/common/service/OrderService.php +++ b/backend/application/common/service/OrderService.php @@ -145,6 +145,7 @@ class OrderService 'goods_amount' => $total_amount, 'discount_amount' => 0, 'remark' => $storeInfo['remark'] ?? '', + 'promoter_id' => PromotionService::getPromoterId(intval($userId)), ]; $orderData['parent_order_no'] = $orderData['order_no']; $existed = OrderService::getOne(['order_no' => $orderData['order_no']]); @@ -199,6 +200,7 @@ class OrderService $orderInfo = self::where(['parent_order_no' => $demand->parent_order_no])->find(); $ratio = 0.5; //分期比率50% if (!$orderInfo) { + $promoterId = PromotionService::getPromoterId(intval($demand->user_id)); $installments = 2;//分期数 for ($i = 1; $i <= $installments; $i++) { $total_amount = $i == 1 ? $totalCost * $ratio : $totalCost * (1 - $ratio); @@ -229,6 +231,7 @@ class OrderService 'goods_amount' => $total_amount, 'discount_amount' => 0, 'remark' => '', + 'promoter_id' => $promoterId, ]; $existed = OrderService::getOne(['order_no' => $orderData['order_no']]); if (!$existed) { @@ -415,6 +418,8 @@ class OrderService } } } + //交易完成:结算推广佣金 + PromotionService::settleOrders($order->parent_order_no ? ['parent_order_no' => $order->parent_order_no] : ['id' => $order->id]); Db::commit(); $info = OrderService::getOne(['id' => $orderId]); diff --git a/backend/application/common/service/PromotionService.php b/backend/application/common/service/PromotionService.php new file mode 100644 index 0000000..3622626 --- /dev/null +++ b/backend/application/common/service/PromotionService.php @@ -0,0 +1,352 @@ + '待结算', + self::COMMISSION_STATUS_SETTLED => '已结算', + self::COMMISSION_STATUS_SKIPPED => '不分佣', + ]; + + //小程序码 scene 中推广人参数名(scene 最长 32 字符) + const QRCODE_SCENE_KEY = 'pid'; + //小程序码落地页 + const QRCODE_PAGE = 'pages/home/home'; + const QRCODE_CACHE_PREFIX = 'promotion_qrcode:'; + const QRCODE_CACHE_TTL = 604800; //7天 + + const ENV_VERSIONS = ['release', 'trial', 'develop']; + + //推广订单列表展示的订单状态(已支付及之后) + const VISIBLE_ORDER_STATUS = [ + OrderService::ORDER_STATUS_WAIT_SHIP, + OrderService::ORDER_STATUS_SHIPPED, + OrderService::ORDER_STATUS_FINISHED, + ]; + + /** + * 是否开启推广分佣 + * @return bool + */ + public static function isEnabled(): bool + { + return intval(config('site.enable_promotion_commission') ?? 0) === 1; + } + + /** + * 按订单类型获取佣金比例(0~1),为 0 表示该类订单不分佣 + * - 软件订单:site.soft_promotion_commission_rate + * - 需求订单(首付款/尾款):site.promotion_commission_ratio + * @param int $orderType 订单类型 OrderService::ORDER_TYPE_* + * @return string + */ + public static function getRatio(int $orderType): string + { + $key = $orderType === OrderService::ORDER_TYPE_SOFTWARE ? 'site.soft_promotion_commission_rate' : 'site.promotion_commission_ratio'; + + return self::normalizeRatio(config($key)); + } + + /** + * 规范化比例配置,后台填写大于 1 时按百分比处理,如 10 表示 10% + * @param mixed $ratio + * @return string + */ + protected static function normalizeRatio($ratio): string + { + if (!is_numeric($ratio) || $ratio <= 0) { + return '0'; + } + $ratio = (string)$ratio; + if (bccomp($ratio, '1', 4) > 0) { + $ratio = bcdiv($ratio, '100', 4); + } + + return bccomp($ratio, '1', 4) > 0 ? '1' : bcadd($ratio, '0', 4); + } + + /** + * 绑定推广关系(永久绑定,已绑定的用户不再变更),失败只记日志不抛异常,避免影响登录 + * @param int $userId 当前用户ID + * @param int $promoterId 推广人用户ID + * @return bool 是否本次绑定成功 + */ + public static function bindPromoter(int $userId, int $promoterId): bool + { + if ($userId <= 0 || $promoterId <= 0 || $userId === $promoterId) { + return false; + } + try { + $user = User::where('id', $userId)->field('id,promoter_id')->find(); + if (!$user || $user->promoter_id > 0) { + return false; + } + $promoter = User::where('id', $promoterId)->field('id,promoter_id,status')->find(); + if (!$promoter || ($promoter->status && $promoter->status !== 'normal')) { + return false; + } + //禁止互相绑定 + if (intval($promoter->promoter_id) === $userId) { + return false; + } + //条件更新保证并发下只绑定一次 + $res = User::where(['id' => $userId, 'promoter_id' => 0])->update([ + 'promoter_id' => $promoterId, + 'promoter_bind_time' => time(), + ]); + if ($res) { + Log::info(__CLASS__ . ' 绑定推广关系:' . json_encode(['user_id' => $userId, 'promoter_id' => $promoterId], JSON_UNESCAPED_UNICODE)); + } + + return (bool)$res; + } catch (\Exception $e) { + Log::error(__CLASS__ . ' 绑定推广关系失败:' . json_encode(['user_id' => $userId, 'promoter_id' => $promoterId, 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); + return false; + } + } + + /** + * 获取用户的推广人ID(用于下单快照) + * @param int $userId + * @return int + */ + public static function getPromoterId(int $userId): int + { + if ($userId <= 0) { + return 0; + } + + return intval(User::where('id', $userId)->value('promoter_id')); + } + + /** + * 结算满足条件的已完成订单佣金(可在外层事务中调用,内部使用保存点,单笔失败不影响外层业务) + * @param array $where 订单范围条件,如 ['parent_order_no' => 'xxx'] + * @return void + */ + public static function settleOrders(array $where) + { + $orderIds = self::where($where) + ->where('order_status', OrderService::ORDER_STATUS_FINISHED) + ->where('promoter_id', '>', 0) + ->where('commission_status', self::COMMISSION_STATUS_PENDING) + ->column('id'); + foreach ($orderIds as $orderId) { + self::settleOrder(intval($orderId)); + } + } + + /** + * 兜底:结算所有待结算的已完成推广订单(定时任务调用) + * @return void + */ + public static function settlePendingOrders() + { + self::where('order_status', OrderService::ORDER_STATUS_FINISHED) + ->where('promoter_id', '>', 0) + ->where('commission_status', self::COMMISSION_STATUS_PENDING) + ->field('id') + ->chunk(100, function ($orders) { + foreach ($orders as $order) { + self::settleOrder(intval($order->id)); + } + }); + } + + /** + * 结算单笔订单佣金 + * @param int $orderId + * @return bool 是否发放了佣金 + */ + public static function settleOrder(int $orderId): bool + { + Db::startTrans(); + try { + $order = self::where('id', $orderId)->lock(true)->find(); + if (!$order + || intval($order->commission_status) !== self::COMMISSION_STATUS_PENDING + || intval($order->order_status) !== OrderService::ORDER_STATUS_FINISHED + || intval($order->promoter_id) <= 0) { + Db::commit(); + return false; + } + $promoterId = intval($order->promoter_id); + $ratio = self::isEnabled() ? self::getRatio(intval($order->order_type)) : '0'; + $amount = bcmul((string)$order->payment_amount, $ratio, 2); + $isPaid = OrderPaymentService::where(['order_no' => $order->order_no, 'pay_status' => 'SUCCESS'])->count() > 0; + $skip = bccomp($amount, '0', 2) <= 0 || !$isPaid || $promoterId === intval($order->user_id); + + $saveData = [ + 'commission_ratio' => $ratio, + 'commission_amount' => $skip ? 0 : $amount, + 'commission_status' => $skip ? self::COMMISSION_STATUS_SKIPPED : self::COMMISSION_STATUS_SETTLED, + 'commission_time' => time(), + ]; + if (!$skip) { + self::ensureAccount($promoterId); + $title = OrderService::ORDER_TYPE_MAP[$order->order_type] ?? '订单'; + $log = AccountLogService::addLog( + AccountLogService::ACCOUNT_TYPE_AMOUNT, + $promoterId, + $amount, + '推广佣金-' . $title . $order->order_no, + AccountLogService::TYPE_COMMISSION, + 0, + AccountLogService::INC_EXP_INCREASE, + $order->id, + $order->payment_amount, + $ratio + ); + AccountService::updateData($promoterId, $amount, 0, 0, 0, $log->id); + try { + MessageService::createData( + $promoterId, + '推广佣金到账', + '您推广的订单' . $order->order_no . '已完成,佣金' . $amount . '元已存入余额', + ['page' => 'pages/promoter/index/index'] + ); + } catch (\Exception $e) { + Log::error(__CLASS__ . ' 佣金到账站内消息失败:' . json_encode(['order_id' => $orderId, 'error' => $e->getMessage()], JSON_UNESCAPED_UNICODE)); + } + } + $upRes = self::where(['id' => $orderId, 'commission_status' => self::COMMISSION_STATUS_PENDING])->update($saveData); + if (!$upRes) { + exception('更新订单佣金状态失败'); + } + Db::commit(); + Log::info(__CLASS__ . ' 推广佣金结算:' . json_encode(['order_id' => $orderId, 'promoter_id' => $promoterId] + $saveData, JSON_UNESCAPED_UNICODE)); + + return !$skip; + } catch (\Exception $e) { + Db::rollback(); + Log::error(__CLASS__ . ' 推广佣金结算失败:' . json_encode(['order_id' => $orderId, 'error' => $e->getMessage(), 'file' => $e->getFile() . ':' . $e->getLine()], JSON_UNESCAPED_UNICODE)); + return false; + } + } + + /** + * 推广人账户不存在时补建(手机号注册的用户可能没有账户) + * @param int $userId + * @return void + */ + protected static function ensureAccount(int $userId) + { + if (!Accounts::where('user_id', $userId)->count()) { + Accounts::create(['user_id' => $userId]); + } + } + + /** + * 推广中心概览 + * @param int $userId + * @return array + */ + public static function getOverview(int $userId): array + { + $base = self::where('promoter_id', $userId)->where('order_status', 'in', self::VISIBLE_ORDER_STATUS); + $settledAmount = (clone $base)->where('commission_status', self::COMMISSION_STATUS_SETTLED)->sum('commission_amount'); + $pendingCount = (clone $base)->where('commission_status', self::COMMISSION_STATUS_PENDING)->count(); + + return [ + 'enabled' => self::isEnabled(), + 'soft_ratio_percent' => floatval(bcmul(self::getRatio(OrderService::ORDER_TYPE_SOFTWARE), '100', 2)), + 'demand_ratio_percent' => floatval(bcmul(self::getRatio(OrderService::ORDER_TYPE_FIRST_PAYMENT), '100', 2)), + 'invite_count' => User::where('promoter_id', $userId)->count(), + 'order_count' => (clone $base)->count(), + 'settled_amount' => bcadd((string)$settledAmount, '0', 2), + 'pending_count' => $pendingCount, + ]; + } + + /** + * 推广订单列表 + * @param int $userId + * @param int $pageSize + * @return array + */ + public static function getOrderList(int $userId, int $pageSize = 10): array + { + $list = self::where('promoter_id', $userId) + ->where('order_status', 'in', self::VISIBLE_ORDER_STATUS) + ->field('id,user_id,order_no,order_type,order_status,payment_amount,commission_amount,commission_status,commission_time,createtime') + ->order('id desc') + ->paginate($pageSize) + ->toArray(); + $userIds = array_unique(array_column($list['data'], 'user_id')); + $users = $userIds ? User::where('id', 'in', $userIds)->column('nickname,avatar', 'id') : []; + foreach ($list['data'] as &$item) { + $buyer = $users[$item['user_id']] ?? []; + $item['buyer_nickname'] = $buyer['nickname'] ?? ''; + $item['buyer_avatar'] = $buyer['avatar'] ?? ''; + $item['order_type_text'] = OrderService::ORDER_TYPE_MAP[$item['order_type']] ?? ''; + $item['commission_status'] = intval($item['commission_status']); + $item['commission_status_text'] = self::COMMISSION_STATUS_MAP[$item['commission_status']] ?? ''; + $item['createtime_text'] = date('Y-m-d H:i', $item['createtime']); + unset($item['user_id']); + } + unset($item); + + return $list; + } + + /** + * 获取推广小程序码(scene=pid=用户ID),返回 data URL,结果缓存 7 天 + * @param int $userId + * @param string $envVersion release|trial|develop + * @return string + * @throws \Exception + */ + public static function getQrcode(int $userId, string $envVersion = 'release'): string + { + if (!in_array($envVersion, self::ENV_VERSIONS, true)) { + $envVersion = 'release'; + } + $cacheKey = self::QRCODE_CACHE_PREFIX . $userId . ':' . $envVersion; + if ($cached = Cache::get($cacheKey)) { + return $cached; + } + $response = (new EasyWeChatService())->app->getClient()->postJson('/wxa/getwxacodeunlimit', [ + 'scene' => self::QRCODE_SCENE_KEY . '=' . $userId, + 'page' => self::QRCODE_PAGE, + 'check_path' => $envVersion === 'release', + 'env_version' => $envVersion, + 'width' => 430, + ]); + //成功返回图片二进制,失败返回 JSON + if (strpos($response->getHeaderLine('content-type', false), 'image') === false) { + $error = $response->toArray(false); + Log::error(__CLASS__ . ' 生成推广小程序码失败:' . json_encode(['user_id' => $userId, 'res' => $error], JSON_UNESCAPED_UNICODE)); + throw new \Exception('生成推广码失败:' . ($error['errmsg'] ?? '未知错误')); + } + $dataUrl = $response->toDataUrl(); + Cache::set($cacheKey, $dataUrl, self::QRCODE_CACHE_TTL); + + return $dataUrl; + } +} diff --git a/backend/deploy/sql/20261014_promotion_commission.sql b/backend/deploy/sql/20261014_promotion_commission.sql new file mode 100644 index 0000000..d8d1e68 --- /dev/null +++ b/backend/deploy/sql/20261014_promotion_commission.sql @@ -0,0 +1,21 @@ +-- 推广分佣:用户推广关系 + 订单佣金字段 + 账户日志调整 +-- 执行前请先备份 fa_user / fa_orders / fa_account_logs + +ALTER TABLE `fa_user` + ADD COLUMN `promoter_id` int(10) UNSIGNED NOT NULL DEFAULT 0 COMMENT '推广人用户ID(首次通过分享进入时永久绑定)', + ADD COLUMN `promoter_bind_time` bigint(16) NULL DEFAULT NULL COMMENT '推广关系绑定时间', + ADD INDEX `idx_promoter_id` (`promoter_id`); + +ALTER TABLE `fa_orders` + ADD COLUMN `promoter_id` int(10) UNSIGNED NOT NULL DEFAULT 0 COMMENT '推广人用户ID(下单时快照)', + ADD COLUMN `commission_ratio` decimal(5, 4) NOT NULL DEFAULT 0.0000 COMMENT '结算时的佣金比例', + ADD COLUMN `commission_amount` decimal(10, 2) NOT NULL DEFAULT 0.00 COMMENT '推广佣金', + ADD COLUMN `commission_status` tinyint(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT '佣金状态:0=待结算,1=已结算,2=不分佣', + ADD COLUMN `commission_time` bigint(16) NULL DEFAULT NULL COMMENT '佣金结算时间', + ADD INDEX `idx_promoter_id` (`promoter_id`), + ADD INDEX `idx_commission_settle` (`commission_status`, `order_status`); + +-- percent 原为 double(2,2),只能存 -0.99~0.99 且仅两位小数,比例为 1 时会越界、0.125 会被四舍五入 +ALTER TABLE `fa_account_logs` + MODIFY COLUMN `type` tinyint(1) UNSIGNED NOT NULL DEFAULT 0 COMMENT '日志类型:1=充值,2=消费,3=收益,4=退款,5=提现,6=冻结,7=解冻,8=推广佣金,9=后台操作', + MODIFY COLUMN `percent` decimal(5, 4) NOT NULL DEFAULT 0.0000 COMMENT '分成比例(0~1)'; diff --git a/backend/public/assets/js/backend/account/logs.js b/backend/public/assets/js/backend/account/logs.js index dda8629..fa1687e 100644 --- a/backend/public/assets/js/backend/account/logs.js +++ b/backend/public/assets/js/backend/account/logs.js @@ -32,7 +32,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin {field: 'account_type', title: __('Account_type'), operate: 'LIKE'}, {field: 'amount', title: __('Amount'), operate:'BETWEEN'}, {field: 'log', title: __('Log'), operate: 'LIKE'}, - {field: 'type', title: __('Type'), searchList: {"1":__('Type 1'),"2":__('Type 2'),"3":__('Type 3'),"4":__('Type 4'),"5":__('Type 5'),"6":__('Type 6'),"7":__('Type 7'),"9":__('Type 9')}, formatter: Table.api.formatter.normal}, + {field: 'type', title: __('Type'), searchList: {"1":__('Type 1'),"2":__('Type 2'),"3":__('Type 3'),"4":__('Type 4'),"5":__('Type 5'),"6":__('Type 6'),"7":__('Type 7'),"8":__('Type 8'),"9":__('Type 9')}, formatter: Table.api.formatter.normal}, {field: 'credit_type', title: __('Credit_type'), searchList: {"1":__('Credit_type 1'),"2":__('Credit_type 2'),"3":__('Credit_type 3'),"4":__('Credit_type 4'),"5":__('Credit_type 5'),"9":__('Credit_type 9')}, formatter: Table.api.formatter.normal}, {field: 'percent', title: __('Percent')}, {field: 'order_amount', title: __('Order_amount'), operate:'BETWEEN'}, diff --git a/backend/public/assets/js/backend/orders.js b/backend/public/assets/js/backend/orders.js index bd80a24..97405f9 100644 --- a/backend/public/assets/js/backend/orders.js +++ b/backend/public/assets/js/backend/orders.js @@ -43,6 +43,10 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin {field: 'freight_fee', title: __('Freight_fee'), operate:'BETWEEN'}, {field: 'package_fee', title: __('Package_fee'), operate:'BETWEEN'}, {field: 'discount_amount', title: __('Discount_amount'), operate:'BETWEEN'}, + {field: 'promoter_id', title: __('Promoter_id')}, + {field: 'commission_amount', title: __('Commission_amount'), operate:'BETWEEN'}, + {field: 'commission_status', title: __('Commission_status'), searchList: {"0":__('Commission_status 0'),"1":__('Commission_status 1'),"2":__('Commission_status 2')}, formatter: Table.api.formatter.status}, + {field: 'commission_time', title: __('Commission_time'), operate:'RANGE', addclass:'datetimerange', autocomplete:false, formatter: Table.api.formatter.datetime}, {field: 'channel_type', title: __('Channel_type')}, {field: 'channel_source', title: __('Channel_source'), operate: 'LIKE'}, {field: 'channel_identity', title: __('Channel_identity'), operate: 'LIKE'}, diff --git a/backend/public/assets/js/backend/user/user.js b/backend/public/assets/js/backend/user/user.js index 6ecd518..e81aefe 100644 --- a/backend/public/assets/js/backend/user/user.js +++ b/backend/public/assets/js/backend/user/user.js @@ -28,6 +28,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin {field: 'group.name', title: __('Group')}, {field: 'username', title: __('Username'), operate: 'LIKE'}, {field: 'nickname', title: __('Nickname'), operate: 'LIKE'}, + {field: 'promoter_id', title: __('Promoter_id')}, {field: 'email', title: __('Email'), operate: 'LIKE'}, {field: 'mobile', title: __('Mobile'), operate: 'LIKE'}, {field: 'avatar', title: __('Avatar'), events: Table.api.events.image, formatter: Table.api.formatter.image, operate: false}, diff --git a/frontend/miniapp/app.js b/frontend/miniapp/app.js index 138e6d2..8da4a24 100644 --- a/frontend/miniapp/app.js +++ b/frontend/miniapp/app.js @@ -1,32 +1,56 @@ import updateManager from './common/updateManager'; +import { + QUERY_KEY as PROMOTER_KEY, + savePendingPromoter, + getPendingPromoter, + clearPendingPromoter, + bindPendingPromoter, +} from './utils/promoter'; var util = require('utils/util.js'); var api = require('config/api.js'); App({ - onLaunch: function () { + onLaunch: function (options) { wx.$api = api wx.$util = util // 展示本地存储能力 var logs = wx.getStorageSync('logs') || [] logs.unshift(Date.now()) wx.setStorageSync('logs', logs) + //通过分享链接/小程序码冷启动时缓存推广人,随登录请求一起绑定 + savePendingPromoter(options && options.query); + this.loggingIn = true; // 登录 wx.login({ success: (res) => { util.request(api.AuthLoginByWeixin, { - code: res.code - }, 'POST').then(function (res) { - console.log('App Launch') + code: res.code, + [PROMOTER_KEY]: getPendingPromoter(), + }, 'POST').then((res) => { + this.loggingIn = false; if (res.code === 1) { let userInfo = res.data.userinfo; wx.setStorageSync('token', res.data.token); wx.setStorageSync('userInfo', userInfo); + //推广关系为永久绑定,登录成功后无论是否绑定都无需再次提交 + clearPendingPromoter(); } + }).catch((err) => { + this.loggingIn = false; + console.error('wxLogin failed:', err); }); }, + fail: () => { + this.loggingIn = false; + }, }); }, - onShow: function () { + onShow: function (options) { + //热启动从分享进入:已登录时直接绑定,冷启动由 onLaunch 的登录请求处理 + savePendingPromoter(options && options.query); + if (!this.loggingIn) { + bindPendingPromoter(); + } updateManager(); //从后台切回前台时刷新一次未读红点(不使用定时轮询,避免请求堆积导致页面卡死) this.refreshUnread(); diff --git a/frontend/miniapp/app.json b/frontend/miniapp/app.json index e1518fb..0ef1ca0 100644 --- a/frontend/miniapp/app.json +++ b/frontend/miniapp/app.json @@ -37,7 +37,8 @@ "pages/demand/contract/index", "pages/demand/deliver/index", "pages/demand/agreement/index", - "pages/usercenter/balance/index" + "pages/usercenter/balance/index", + "pages/promoter/index/index" ], "tabBar": { "custom": true, diff --git a/frontend/miniapp/config/api.js b/frontend/miniapp/config/api.js index f7726c3..8b2b7e9 100644 --- a/frontend/miniapp/config/api.js +++ b/frontend/miniapp/config/api.js @@ -104,6 +104,12 @@ module.exports = { AccountApplyWithdraw: ApiRootUrl + 'account/applyWithdraw', //提现申请 AccountTransferAuthorization: ApiRootUrl + 'account/transferAuthorization', //免确认收款授权(发起/查询) + // 推广分佣 + PromotionBind: ApiRootUrl + 'promotion/bind', //绑定推广人 + PromotionInfo: ApiRootUrl + 'promotion/info', //推广中心概览 + PromotionOrders: ApiRootUrl + 'promotion/orders', //推广订单列表 + PromotionQrcode: ApiRootUrl + 'promotion/qrcode', //推广小程序码 + // 消息 MessageList: ApiRootUrl + 'message/list', //消息列表 MessageDetail: ApiRootUrl + 'message/detail', //消息详情 diff --git a/frontend/miniapp/pages/goods/details/index.js b/frontend/miniapp/pages/goods/details/index.js index 0b35825..c5dd6e0 100644 --- a/frontend/miniapp/pages/goods/details/index.js +++ b/frontend/miniapp/pages/goods/details/index.js @@ -6,6 +6,7 @@ import { } from '../../../services/good/fetchGoodsDetailsComments'; import { cdnBase } from '../../../config/index'; +import { withPromoter, promoterQuery } from '../../../utils/promoter'; const imgPrefix = `${cdnBase}/`; @@ -350,11 +351,20 @@ Page({ const customInfo = { imageUrl: this.data.details.primaryImage, title: this.data.details.title + shareSubTitle, - path: `/pages/goods/details/index?spuId=${this.data.spuId}`, + path: withPromoter(`/pages/goods/details/index?spuId=${this.data.spuId}`), }; return customInfo; }, + onShareTimeline() { + const query = promoterQuery(); + return { + title: this.data.details.title, + imageUrl: this.data.details.primaryImage, + query: `spuId=${this.data.spuId}${query ? `&${query}` : ''}`, + }; + }, + /** 获取评价统计 */ async getCommentsStatistics() { try { diff --git a/frontend/miniapp/pages/home/home.js b/frontend/miniapp/pages/home/home.js index 9a42869..50b2d6c 100644 --- a/frontend/miniapp/pages/home/home.js +++ b/frontend/miniapp/pages/home/home.js @@ -1,5 +1,6 @@ import { fetchHome, fetchConfig } from '../../services/home/home'; import { fetchPerson } from '../../services/usercenter/fetchPerson'; +import { withPromoter, promoterQuery } from '../../utils/promoter'; Page({ data: { @@ -143,4 +144,18 @@ Page({ url: `/pages/promotion-detail/index?promotion_id=${promotionID}`, }); }, + + onShareAppMessage() { + return { + title: '代码帮 - 软件商城与定制开发平台', + path: withPromoter('/pages/home/home'), + }; + }, + + onShareTimeline() { + return { + title: '代码帮 - 软件商城与定制开发平台', + query: promoterQuery(), + }; + }, }); \ No newline at end of file diff --git a/frontend/miniapp/pages/promoter/index/index.js b/frontend/miniapp/pages/promoter/index/index.js new file mode 100644 index 0000000..827dfa4 --- /dev/null +++ b/frontend/miniapp/pages/promoter/index/index.js @@ -0,0 +1,159 @@ +// pages/promoter/index/index.js +import { fetchPromoterInfo, fetchPromoterOrders, fetchPromoterQrcode } from '../../../services/promoter/fetchPromoter'; +import { withPromoter } from '../../../utils/promoter'; + +const PAGE_SIZE = 10; + +Page({ + data: { + // 推广概览:enabled 是否开启分佣、soft_ratio_percent 软件订单佣金比例(%)、 + // demand_ratio_percent 需求订单佣金比例(%)、invite_count 推广用户数、 + // order_count 推广订单数、settled_amount 累计已结算佣金(元)、pending_count 待结算订单数 + overview: {}, + list: [], // 推广订单列表 + page: 1, // 下一次请求的页码 + hasMore: true, // 是否还有更多数据 + isEmpty: false, // 列表是否为空 + loadMoreStatus: 0, // 加载状态 0=空闲 1=加载中 2=没有更多 3=失败 + showQrcode: false, // 是否展示推广码弹窗 + qrcode: '', // 推广小程序码 data URL + qrcodeLoading: false, // 推广码生成中 + saving: false, // 保存推广码中 + }, + + onLoad() { + this.loadOverview(); + this.loadList(true); + }, + + async onPullDownRefresh() { + await Promise.all([this.loadOverview(), this.loadList(true)]); + wx.stopPullDownRefresh(); + }, + + onReachBottom() { + if (this.data.hasMore && this.data.loadMoreStatus !== 1) { + this.loadList(); + } + }, + + onShareAppMessage() { + return { + title: '代码帮 - 软件商城与定制开发平台', + path: withPromoter('/pages/home/home'), + }; + }, + + async loadOverview() { + try { + const res = await fetchPromoterInfo(); + if (res.code === 1) { + this.setData({ overview: res.data }); + } + } catch (err) { + console.error('fetchPromoterInfo failed:', err); + } + }, + + async loadList(fresh = false) { + const page = fresh ? 1 : this.data.page; + this.setData({ loadMoreStatus: 1 }); + try { + const res = await fetchPromoterOrders({ page, pageSize: PAGE_SIZE }); + if (res.code !== 1) { + this.setData({ loadMoreStatus: 3 }); + return; + } + const { data = [], current_page: currentPage, last_page: lastPage } = res.data; + const list = fresh ? data : this.data.list.concat(data); + const hasMore = currentPage < lastPage; + this.setData({ + list, + page: currentPage + 1, + hasMore, + isEmpty: list.length === 0, + loadMoreStatus: hasMore ? 0 : 2, + }); + } catch (err) { + console.error('fetchPromoterOrders failed:', err); + this.setData({ loadMoreStatus: 3 }); + } + }, + + onRetryLoad() { + this.loadList(); + }, + + async onShowQrcode() { + this.setData({ showQrcode: true }); + if (this.data.qrcode || this.data.qrcodeLoading) { + return; + } + this.setData({ qrcodeLoading: true }); + try { + const res = await fetchPromoterQrcode({ envVersion: wx.$api.envVersion }); + if (res.code !== 1) { + wx.showToast({ title: res.msg || '推广码生成失败', icon: 'none' }); + return; + } + this.setData({ qrcode: res.data.qrcode }); + } catch (err) { + console.error('fetchPromoterQrcode failed:', err); + wx.showToast({ title: '网络异常,请重试', icon: 'none' }); + } finally { + this.setData({ qrcodeLoading: false }); + } + }, + + onQrcodeVisibleChange(e) { + this.setData({ showQrcode: e.detail.visible }); + }, + + onCloseQrcode() { + this.setData({ showQrcode: false }); + }, + + /** 保存推广码到相册:data URL 需先写入本地文件 */ + async onSaveQrcode() { + const { qrcode, saving } = this.data; + if (!qrcode || saving) { + return; + } + this.setData({ saving: true }); + try { + const [meta, base64] = qrcode.split(','); + const ext = meta.indexOf('png') > -1 ? 'png' : 'jpg'; + const filePath = `${wx.env.USER_DATA_PATH}/promoter_qrcode.${ext}`; + wx.getFileSystemManager().writeFileSync(filePath, base64, 'base64'); + await this.saveImage(filePath); + wx.showToast({ title: '已保存到相册', icon: 'success' }); + } catch (err) { + console.error('saveQrcode failed:', err); + if (err && err.errMsg && err.errMsg.indexOf('auth') > -1) { + this.guideAlbumSetting(); + } else if (!(err && err.errMsg && err.errMsg.indexOf('cancel') > -1)) { + wx.showToast({ title: '保存失败,请重试', icon: 'none' }); + } + } finally { + this.setData({ saving: false }); + } + }, + + saveImage(filePath) { + return new Promise((resolve, reject) => { + wx.saveImageToPhotosAlbum({ filePath, success: resolve, fail: reject }); + }); + }, + + guideAlbumSetting() { + wx.showModal({ + title: '提示', + content: '需要授权保存到相册,是否前往设置开启?', + success: (res) => { + if (res.confirm) { + wx.openSetting(); + } + }, + }); + }, +}); diff --git a/frontend/miniapp/pages/promoter/index/index.json b/frontend/miniapp/pages/promoter/index/index.json new file mode 100644 index 0000000..0cdd795 --- /dev/null +++ b/frontend/miniapp/pages/promoter/index/index.json @@ -0,0 +1,14 @@ +{ + "navigationBarTitleText": "推广中心", + "enablePullDownRefresh": true, + "backgroundTextStyle": "dark", + "usingComponents": { + "t-button": "tdesign-miniprogram/button/button", + "t-icon": "tdesign-miniprogram/icon/icon", + "t-avatar": "tdesign-miniprogram/avatar/avatar", + "t-empty": "tdesign-miniprogram/empty/empty", + "t-popup": "tdesign-miniprogram/popup/popup", + "t-loading": "tdesign-miniprogram/loading/loading", + "t-load-more": "/components/load-more/index" + } +} diff --git a/frontend/miniapp/pages/promoter/index/index.wxml b/frontend/miniapp/pages/promoter/index/index.wxml new file mode 100644 index 0000000..29041a2 --- /dev/null +++ b/frontend/miniapp/pages/promoter/index/index.wxml @@ -0,0 +1,69 @@ + + + + {{overview.settled_amount || '0.00'}} + 累计佣金(元) + + + + {{overview.invite_count || 0}} + 推广用户 + + + {{overview.order_count || 0}} + 推广订单 + + + {{overview.pending_count || 0}} + 待结算订单 + + + + 分享给好友 + 推广二维码 + + + + + + + 好友通过你的分享链接或二维码进入并下单,订单交易完成后按实付金额结算佣金,自动存入余额,可直接提现: + · 软件订单:{{overview.soft_ratio_percent > 0 ? overview.soft_ratio_percent + '%' : '暂无佣金'}} + · 定制需求订单:{{overview.demand_ratio_percent > 0 ? overview.demand_ratio_percent + '%' : '暂无佣金'}} + + 平台暂未开启推广分佣,你仍可邀请好友,开启后好友完成的订单将按规则结算佣金。 + + + + 推广订单 + + + + {{item.buyer_nickname || '用户'}} · {{item.order_type_text}} + 实付 ¥{{item.payment_amount}} · {{item.createtime_text}} + + + +{{item.commission_amount}} + {{item.commission_status_text}} + + + + + + + + + + + 我的推广码 + + + + + 好友微信扫码进入小程序即成为你的推广用户 + + 保存到相册 + 关闭 + + + diff --git a/frontend/miniapp/pages/promoter/index/index.wxss b/frontend/miniapp/pages/promoter/index/index.wxss new file mode 100644 index 0000000..f3bdc87 --- /dev/null +++ b/frontend/miniapp/pages/promoter/index/index.wxss @@ -0,0 +1,194 @@ +/* pages/promoter/index/index.wxss */ +page { + padding-bottom: 40rpx; +} + +/* 佣金主卡 */ +.promoter-card { + margin: 24rpx; + padding: 48rpx 32rpx 36rpx; + background: var(--brand-gradient); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-brand); + text-align: center; + color: var(--text-inverse); +} + +.promoter-card__amount { + display: block; + font-size: 64rpx; + font-weight: 700; + line-height: 80rpx; + font-family: 'DIN Alternate', -apple-system, sans-serif; +} + +.promoter-card__label { + display: block; + margin-top: 8rpx; + font-size: 24rpx; + color: rgba(255, 255, 255, 0.85); +} + +.promoter-card__stats { + display: flex; + margin: 40rpx 0 32rpx; +} + +.promoter-card__stat { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; +} + +.promoter-card__value { + font-size: 34rpx; + font-weight: 600; + font-family: 'DIN Alternate', -apple-system, sans-serif; +} + +.promoter-card__stat-label { + margin-top: 8rpx; + font-size: 24rpx; + color: rgba(255, 255, 255, 0.75); +} + +.promoter-card__actions { + display: flex; + justify-content: center; +} + +.promoter-card__btn { + margin: 0 12rpx; + --td-button-primary-bg-color: #ffffff; + --td-button-primary-color: var(--brand); + --td-button-primary-border-color: #ffffff; + --td-button-primary-active-bg-color: #fff5ef; + --td-button-primary-active-border-color: #fff5ef; +} + +/* 规则说明 */ +.rule-card { + display: flex; + align-items: flex-start; + margin: 0 24rpx 24rpx; + padding: 24rpx 28rpx; + background: var(--brand-light); + border-radius: var(--radius-md); +} + +.rule-card__text { + flex: 1; + margin-left: 12rpx; + font-size: 24rpx; + line-height: 38rpx; + color: var(--brand); +} + +/* 推广订单 */ +.order-card { + margin: 0 24rpx; + padding-bottom: 16rpx; + background: var(--bg-card); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.order-card__title { + padding: 28rpx 32rpx 12rpx; + font-size: 30rpx; + font-weight: 600; + color: var(--text-primary); +} + +.order-card__empty { + padding: 40rpx 0; +} + +.order-item { + display: flex; + align-items: center; + padding: 24rpx 32rpx; + border-bottom: 1px solid var(--border-level-1); +} + +.order-item__main { + flex: 1; + margin-left: 20rpx; + overflow: hidden; +} + +.order-item__name { + font-size: 28rpx; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.order-item__desc { + margin-top: 6rpx; + font-size: 22rpx; + color: var(--text-placeholder); +} + +.order-item__side { + margin-left: 16rpx; + text-align: right; +} + +.order-item__amount { + font-size: 32rpx; + font-weight: 700; + color: var(--success); + font-family: 'DIN Alternate', -apple-system, sans-serif; +} + +.order-item__status { + margin-top: 4rpx; + font-size: 22rpx; + color: var(--text-tertiary); +} + +.order-item__status--0 { + color: var(--brand); +} + +/* 推广码弹窗 */ +.qrcode-popup { + width: 600rpx; + padding: 40rpx 40rpx 24rpx; + background: var(--bg-card); + border-radius: var(--radius-lg); + text-align: center; +} + +.qrcode-popup__title { + font-size: 32rpx; + font-weight: 600; + color: var(--text-primary); +} + +.qrcode-popup__image { + display: flex; + align-items: center; + justify-content: center; + width: 400rpx; + height: 400rpx; + margin: 32rpx auto 16rpx; +} + +.qrcode-popup__img { + width: 400rpx; + height: 400rpx; +} + +.qrcode-popup__tips { + font-size: 24rpx; + color: var(--text-tertiary); +} + +.qrcode-popup__actions { + margin-top: 32rpx; +} diff --git a/frontend/miniapp/pages/usercenter/index.js b/frontend/miniapp/pages/usercenter/index.js index e198382..ae51f5a 100644 --- a/frontend/miniapp/pages/usercenter/index.js +++ b/frontend/miniapp/pages/usercenter/index.js @@ -30,6 +30,12 @@ const menuData = [ url: '', type: 'balance', }, + { + title: '推广中心', + tit: '', + url: '', + type: 'promoter', + }, ], [{ title: '帮助中心', @@ -201,6 +207,12 @@ Page({ }); break; } + case 'promoter': { + wx.navigateTo({ + url: '/pages/promoter/index/index' + }); + break; + } case 'coupon': { // wx.navigateTo({ // url: '/pages/coupon/coupon-list/index' diff --git a/frontend/miniapp/services/promoter/fetchPromoter.js b/frontend/miniapp/services/promoter/fetchPromoter.js new file mode 100644 index 0000000..e6cb7f3 --- /dev/null +++ b/frontend/miniapp/services/promoter/fetchPromoter.js @@ -0,0 +1,19 @@ +/** 绑定推广人(已绑定过的用户后端会忽略) */ +export function bindPromoter(params) { + return wx.$util.request(wx.$api.PromotionBind, params, 'POST'); +} + +/** 获取推广中心概览 */ +export function fetchPromoterInfo() { + return wx.$util.request(wx.$api.PromotionInfo, {}, 'GET'); +} + +/** 获取推广订单列表 */ +export function fetchPromoterOrders(params) { + return wx.$util.request(wx.$api.PromotionOrders, params, 'GET'); +} + +/** 获取推广小程序码(data URL) */ +export function fetchPromoterQrcode(params) { + return wx.$util.request(wx.$api.PromotionQrcode, params, 'GET'); +} diff --git a/frontend/miniapp/utils/promoter.js b/frontend/miniapp/utils/promoter.js new file mode 100644 index 0000000..302dfb8 --- /dev/null +++ b/frontend/miniapp/utils/promoter.js @@ -0,0 +1,75 @@ +import { bindPromoter } from '../services/promoter/fetchPromoter'; + +// 待绑定的推广人ID(登录前进入时先缓存,登录/绑定成功后清除) +const PENDING_KEY = 'pendingPromoterId'; +// 分享链接参数:/pages/xxx?promoter_id=1,同时作为登录/绑定接口的参数名 +export const QUERY_KEY = 'promoter_id'; +// 小程序码 scene 参数:scene=pid%3D1,与后端 PromotionService::QRCODE_SCENE_KEY 一致 +const SCENE_KEY = 'pid'; + +/** 当前登录用户ID */ +export function getCurrentUserId() { + return Number((wx.getStorageSync('userInfo') || {}).id) || 0; +} + +/** 从页面启动参数中解析推广人ID */ +export function parsePromoterId(query = {}) { + if (query[QUERY_KEY]) { + return Number(query[QUERY_KEY]) || 0; + } + if (query.scene) { + const pair = decodeURIComponent(query.scene) + .split('&') + .map((item) => item.split('=')) + .find(([key]) => key === SCENE_KEY); + return pair ? Number(pair[1]) || 0 : 0; + } + return 0; +} + +/** 启动参数中带推广人时缓存,等待登录后绑定 */ +export function savePendingPromoter(query) { + const promoterId = parsePromoterId(query || {}); + if (promoterId && promoterId !== getCurrentUserId()) { + wx.setStorageSync(PENDING_KEY, promoterId); + } +} + +export function getPendingPromoter() { + return Number(wx.getStorageSync(PENDING_KEY)) || 0; +} + +export function clearPendingPromoter() { + wx.removeStorageSync(PENDING_KEY); +} + +/** 已登录状态下绑定缓存的推广人(热启动从分享进入时使用) */ +export async function bindPendingPromoter() { + const promoterId = getPendingPromoter(); + if (!promoterId || !wx.getStorageSync('token')) { + return; + } + try { + const res = await bindPromoter({ [QUERY_KEY]: promoterId }); + if (res.code === 1) { + clearPendingPromoter(); + } + } catch (err) { + console.error('bindPromoter failed:', err); + } +} + +/** 分享参数:promoter_id=当前用户ID,未登录时为空 */ +export function promoterQuery() { + const userId = getCurrentUserId(); + return userId ? `${QUERY_KEY}=${userId}` : ''; +} + +/** 给分享路径追加推广人参数 */ +export function withPromoter(path) { + const query = promoterQuery(); + if (!query) { + return path; + } + return `${path}${path.indexOf('?') > -1 ? '&' : '?'}${query}`; +}