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}`;
+}