feat(fsgx): HJF queue merge, brokerage timing, cycle commission, points release

- Add HJF jobs, services, DAOs, models, admin/API controllers, release command
- Respect brokerage_timing (on_pay vs confirm); dispatch HjfOrderPayJob for queue goods
- Queue-only cycle commission and position index fix in StoreOrderCreateServices
- UserBill income types: frozen_points_brokerage, frozen_points_release
- Timer: fsgx_release_frozen_points -> PointsReleaseServices
- Agent tasks: no_assess filtering for direct/umbrella counts
- Migrations: queue_pool, points_release_log, fsgx_v1 checklist updates
- Admin/uniapp: crontab preset, membership level, user list, finance routes, docs

Made-with: Cursor
This commit is contained in:
apple
2026-03-24 11:59:09 +08:00
parent 434aa8c69d
commit 76ccb24679
59 changed files with 2902 additions and 237 deletions

View File

@@ -40,6 +40,133 @@ class AgentLevelServices extends BaseServices
#[Inject]
protected AgentLevelDao $dao;
/**
* HJF 官方会员等级名称(与数据库插入数据一致)
* 用于区分 CRMEB 默认「等级一/等级二…」与 HJF 创客/云店…
*/
public const HJF_OFFICIAL_LEVEL_NAMES = ['创客', '云店', '服务商', '分公司'];
/**
* 一次查询并返回用户列表展示所需等级索引(供 UserServices::index() 调用)
*
* @return array{byId: array<int,array>, byGradeAny: array<int,array>, byGradeOfficial: array<int,array>}
*/
public function loadHjfUserListLevelMaps(): array
{
$rows = $this->dao->getList(['is_del' => 0]);
return $this->buildHjfUserListLevelMaps($rows);
}
/**
* 从等级行列表构建用户列表展示用索引
*
* @param array $hjfLevelRows
* @return array{byId: array<int,array>, byGradeAny: array<int,array>, byGradeOfficial: array<int,array>}
*/
public function buildHjfUserListLevelMaps(array $hjfLevelRows): array
{
$byId = [];
$byGradeAny = [];
$byGradeOfficial = [];
$official = self::HJF_OFFICIAL_LEVEL_NAMES;
foreach ($hjfLevelRows as $hjfRow) {
$lid = (int)($hjfRow['id'] ?? 0);
if ($lid > 0) {
$byId[$lid] = $hjfRow;
}
$g = (int)($hjfRow['grade'] ?? 0);
if ($g > 0 && !isset($byGradeAny[$g])) {
$byGradeAny[$g] = $hjfRow;
}
$nm = (string)($hjfRow['name'] ?? '');
if ($g > 0 && in_array($nm, $official, true) && !isset($byGradeOfficial[$g])) {
$byGradeOfficial[$g] = $hjfRow;
}
}
return ['byId' => $byId, 'byGradeAny' => $byGradeAny, 'byGradeOfficial' => $byGradeOfficial];
}
/**
* 用户列表场景:解析应展示的等级行
* - agent_level 指向 CRMEB 默认行时,按 grade 改用 HJF 官方行
* - 旧 id 已软删或误把 grade 写入 agent_level 时,按 byGradeAny 回退
*
* @param int $agentLevelId 用户的 agent_level 字段值
* @param array $maps loadHjfUserListLevelMaps() 返回的索引
* @return array|null
*/
public function pickHjfLevelRowForUserListDisplay(int $agentLevelId, array $maps): ?array
{
if ($agentLevelId <= 0) {
return null;
}
$byId = $maps['byId'] ?? [];
$byGradeAny = $maps['byGradeAny'] ?? [];
$byGradeOfficial = $maps['byGradeOfficial'] ?? [];
$official = self::HJF_OFFICIAL_LEVEL_NAMES;
$row = $byId[$agentLevelId] ?? null;
if ($row === null) {
return $byGradeAny[$agentLevelId] ?? null;
}
$nm = (string)($row['name'] ?? '');
$g = (int)($row['grade'] ?? 0);
if ($g > 0 && !in_array($nm, $official, true) && isset($byGradeOfficial[$g])) {
return $byGradeOfficial[$g];
}
return $row;
}
/**
* 根据 grade 获取 agent_level ID用于筛选条件转换
*
* @param int $grade 等级数字 (1=创客, 2=云店, 3=服务商, 4=分公司)
* @return int agent_level ID找不到返回 0
*/
public function getLevelIdByGrade(int $grade): int
{
if ($grade <= 0) {
return 0;
}
return (int)$this->dao->value(['grade' => $grade, 'is_del' => 0, 'status' => 1], 'id') ?: 0;
}
/**
* 根据 agent_level ID 获取等级 gradeHJF 会员等级数字 0-4
*/
public function getGradeByLevelId(int $agentLevelId): int
{
if ($agentLevelId <= 0) {
return 0;
}
$levelInfo = $this->getLevelInfo($agentLevelId);
return (int)($levelInfo['grade'] ?? 0);
}
/**
* 根据 agent_level ID 获取直推奖励积分
*/
public function getDirectRewardPoints(int $agentLevelId): int
{
if ($agentLevelId <= 0) {
return 0;
}
$levelInfo = $this->getLevelInfo($agentLevelId);
return (int)($levelInfo['direct_reward_points'] ?? 0);
}
/**
* 根据 agent_level ID 获取伞下奖励积分
*/
public function getUmbrellaRewardPoints(int $agentLevelId): int
{
if ($agentLevelId <= 0) {
return 0;
}
$levelInfo = $this->getLevelInfo($agentLevelId);
return (int)($levelInfo['umbrella_reward_points'] ?? 0);
}
/**
* 获取某一个等级信息
* @param int $id

View File

@@ -20,6 +20,7 @@ use crmeb\services\FormBuilder as Form;
use FormBuilder\Factory\Iview;
use think\annotation\Inject;
use think\exception\ValidateException;
use think\facade\Db;
use think\facade\Route as Url;
@@ -38,6 +39,11 @@ class AgentLevelTaskServices extends BaseServices
* max_number 最大设定数值 0为不限定
* min_number 最小设定数值
* unit 单位
*
* type 6-8: HJF 会员等级升级任务类型(改造新增)
* 6 = 直推报单单数(直推下级购买报单商品的订单数)
* 7 = 伞下报单业绩(含业绩分离逻辑)
* 8 = 最低直推人数
* */
protected array $TaskType = [
[
@@ -90,6 +96,36 @@ class AgentLevelTaskServices extends BaseServices
'unit' => '单',
'image' => '/uploads/system/agent_spread_order.png'
],
[
'type' => 6,
'method' => 'directQueueOrderCount',
'name' => '直推报单满{$num}',
'real_name' => '直推报单单数',
'max_number' => 0,
'min_number' => 1,
'unit' => '单',
'image' => '/uploads/system/agent_spread_order.png'
],
[
'type' => 7,
'method' => 'umbrellaQueueOrderCount',
'name' => '伞下报单满{$num}',
'real_name' => '伞下报单业绩',
'max_number' => 0,
'min_number' => 1,
'unit' => '单',
'image' => '/uploads/system/agent_spread_order.png'
],
[
'type' => 8,
'method' => 'directSpreadCount',
'name' => '至少{$num}个直推',
'real_name' => '最低直推人数',
'max_number' => 0,
'min_number' => 1,
'unit' => '人',
'image' => '/uploads/system/agent_spread.png'
],
];
/**
@@ -356,6 +392,20 @@ class AgentLevelTaskServices extends BaseServices
$userNumber = $storeOrderServices->count($where);
}
break;
case 6:
// 直推下级购买报单商品的订单数
$userNumber = $this->getDirectQueueOrderCount($uid);
break;
case 7:
// 伞下报单业绩(含业绩分离)
$userNumber = $this->getUmbrellaQueueOrderCount($uid);
break;
case 8:
// 最低直推人数
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
$userNumber = $userServices->count(['spread_uid' => $uid]);
break;
default:
return false;
}
@@ -372,6 +422,85 @@ class AgentLevelTaskServices extends BaseServices
return [$msg, $userNumber, $isComplete];
}
/**
* 统计直推下级的报单订单数type=6 任务)
*
* @param int $uid 用户 ID
* @return int
*/
public function getDirectQueueOrderCount(int $uid): int
{
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
// no_assess=1 的用户不计入升级任务,仅统计正常考核下级
$directUids = $userServices->getColumn(['spread_uid' => $uid, 'no_assess' => 0], 'uid');
if (empty($directUids)) {
return 0;
}
return (int)Db::name('store_order')
->whereIn('uid', $directUids)
->where('is_queue_goods', 1)
->where('paid', 1)
->where('is_del', 0)
->count();
}
/**
* 统计伞下报单业绩type=7 任务,含业绩分离逻辑)
*
* 业绩分离若某直推下级已升级为云店或更高grade≥2
* 则该下级及其团队的订单不计入本用户的伞下业绩。
*
* @param int $uid 用户 ID
* @param int $maxDepth 递归最大深度
* @return int
*/
public function getUmbrellaQueueOrderCount(int $uid, int $maxDepth = 8): int
{
return $this->recursiveUmbrellaCount($uid, $maxDepth);
}
/**
* 递归统计伞下业绩DFS云店及以上等级的下级团队业绩被分离
*/
private function recursiveUmbrellaCount(int $uid, int $remainDepth): int
{
if ($remainDepth <= 0) {
return 0;
}
$directChildren = Db::name('user')
->where('spread_uid', $uid)
->where('no_assess', 0) // 不考核用户不计入伞下业绩
->field('uid,agent_level')
->select()
->toArray();
if (empty($directChildren)) {
return 0;
}
/** @var AgentLevelServices $levelServices */
$levelServices = app()->make(AgentLevelServices::class);
$total = 0;
foreach ($directChildren as $child) {
$childGrade = 0;
if (!empty($child['agent_level'])) {
$childLevelInfo = $levelServices->getLevelInfo((int)$child['agent_level']);
$childGrade = (int)($childLevelInfo['grade'] ?? 0);
}
// 云店及以上业绩分离,不计入本级伞下
if ($childGrade >= 2) {
continue;
}
$total += (int)Db::name('store_order')
->where('uid', $child['uid'])
->where('is_queue_goods', 1)
->where('paid', 1)
->where('is_del', 0)
->count();
$total += $this->recursiveUmbrellaCount((int)$child['uid'], $remainDepth - 1);
}
return $total;
}
/**
* 检测等级任务
* @param int $id

View File

@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace app\services\hjf;
use app\dao\user\UserBillDao;
use app\dao\user\UserDao;
use app\services\BaseServices;
use think\annotation\Inject;
/**
* HJF 资产服务
*
* 提供用户资产总览和现金流水查询功能。
* 复用 CRMEB 原有 eb_user余额/积分)和 eb_user_bill流水字段。
*
* Class HjfAssetsServices
* @package app\services\hjf
*/
class HjfAssetsServices extends BaseServices
{
#[Inject]
protected UserDao $userDao;
#[Inject]
protected UserBillDao $billDao;
/**
* 获取用户资产总览
*
* 返回:
* - now_money 现金余额
* - frozen_points 待释放积分
* - available_points 已释放积分(可消费)
* - total_points 总积分frozen + available
*
* @param int $uid
* @return array
*/
public function getOverview(int $uid): array
{
$user = $this->userDao->get($uid, 'uid,now_money,frozen_points,available_points');
if (!$user) {
return [
'now_money' => '0.00',
'frozen_points' => 0,
'available_points' => 0,
'total_points' => 0,
];
}
$frozen = (int)($user['frozen_points'] ?? 0);
$available = (int)($user['available_points'] ?? 0);
return [
'now_money' => number_format((float)($user['now_money'] ?? 0), 2, '.', ''),
'frozen_points' => $frozen,
'available_points' => $available,
'total_points' => $frozen + $available,
];
}
/**
* 获取现金流水(分页)
*
* 复用 eb_user_bill 表,筛选 category='now_money' 的记录。
*
* @param int $uid
* @param string $type 流水类型筛选('' = 全部,'queue_refund' 公排退款,'withdraw' 提现等)
* @param int $page
* @param int $limit
* @return array
*/
public function getCashDetail(int $uid, string $type, int $page, int $limit): array
{
$where = [
'uid' => $uid,
'category' => 'now_money',
];
if ($type !== '') {
$where['type'] = $type;
}
$count = $this->billDao->count($where);
$list = $this->billDao->getBalanceRecord($where, $page, $limit);
return compact('list', 'count');
}
}

View File

@@ -0,0 +1,136 @@
<?php
declare(strict_types=1);
namespace app\services\hjf;
use app\services\agent\AgentLevelServices;
use app\services\agent\AgentLevelTaskServices;
use app\services\BaseServices;
use app\services\user\UserServices;
use think\annotation\Inject;
use think\facade\Db;
use think\facade\Log;
/**
* 会员等级升级服务(改造复用版)
*
* 基于 CRMEB Pro 的团队分销等级功能进行改造:
* - 使用 eb_user.agent_level (FK → eb_agent_level.id) 代替独立的 member_level
* - 升级条件通过 eb_agent_level_task 的 type 6/7/8 定义
* - 升级逻辑委托给 AgentLevelServices::checkUserLevelFinish()
*
* 本服务保留为薄封装层,提供 HJF 特有的查询方法供控制器调用。
*
* Class MemberLevelServices
* @package app\services\hjf
*/
class MemberLevelServices extends BaseServices
{
#[Inject]
protected AgentLevelServices $agentLevelServices;
#[Inject]
protected AgentLevelTaskServices $agentLevelTaskServices;
/**
* 检查并执行升级(异步触发入口)
*
* 委托给 CRMEB 的 AgentLevelServices 复用原有升级检测流程,
* 该流程已支持 type 6/7/8 的 HJF 任务类型。
*/
public function checkUpgrade(int $uid): void
{
try {
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
$userInfo = $userServices->getUserCacheInfo($uid);
if (!$userInfo) {
return;
}
$spreadUid = $userServices->getSpreadUid($uid, $userInfo);
$twoSpreadUid = 0;
if ($spreadUid > 0 && $oneUserInfo = $userServices->getUserCacheInfo($spreadUid)) {
$twoSpreadUid = $userServices->getSpreadUid($spreadUid, $oneUserInfo, false);
}
$uids = array_unique([$uid, $spreadUid, $twoSpreadUid]);
$this->agentLevelServices->checkUserLevelFinish($uid, $uids);
} catch (\Throwable $e) {
Log::error("[MemberLevel] checkUpgrade uid={$uid}: " . $e->getMessage());
}
}
/**
* 获取用户当前会员等级 grade0=普通, 1=创客, 2=云店, 3=服务商, 4=分公司)
*/
public function getUserGrade(int $uid): int
{
$agentLevel = (int)Db::name('user')->where('uid', $uid)->value('agent_level');
return $this->agentLevelServices->getGradeByLevelId($agentLevel);
}
/**
* 获取用户当前等级名称
*/
public function getUserLevelName(int $uid): string
{
$agentLevel = (int)Db::name('user')->where('uid', $uid)->value('agent_level');
if ($agentLevel <= 0) {
return '普通会员';
}
$maps = $this->agentLevelServices->loadHjfUserListLevelMaps();
$info = $this->agentLevelServices->pickHjfLevelRowForUserListDisplay($agentLevel, $maps);
return $info['name'] ?? '普通会员';
}
/**
* 获取直推用户的报单订单数
*/
public function getDirectQueueOrderCount(int $uid): int
{
return $this->agentLevelTaskServices->getDirectQueueOrderCount($uid);
}
/**
* 获取直推人数
*/
public function getDirectSpreadCount(int $uid): int
{
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
return (int)$userServices->count(['spread_uid' => $uid]);
}
/**
* 获取伞下总报单订单数(含业绩分离逻辑)
*/
public function getUmbrellaQueueOrderCount(int $uid): int
{
return $this->agentLevelTaskServices->getUmbrellaQueueOrderCount($uid);
}
/**
* 手动设置会员等级(管理后台使用)
*
* @param int $uid 用户 ID
* @param int $grade 目标等级 grade (0-4)
*/
public function setUserLevel(int $uid, int $grade): void
{
$agentLevelId = 0;
if ($grade > 0) {
$agentLevelId = $this->agentLevelServices->getLevelIdByGrade($grade);
if ($agentLevelId <= 0) {
throw new \think\exception\ValidateException("等级 grade={$grade} 在 eb_agent_level 中不存在");
}
}
/** @var UserServices $userServices */
$userServices = app()->make(UserServices::class);
$userServices->update($uid, ['agent_level' => $agentLevelId]);
Log::info("[MemberLevel] 手动设置 uid={$uid} agent_level={$agentLevelId} (grade={$grade})");
}
}

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace app\services\hjf;
use app\dao\hjf\PointsReleaseLogDao;
use app\dao\user\UserDao;
use app\services\BaseServices;
use crmeb\services\SystemConfigService;
use think\annotation\Inject;
use think\facade\Db;
use think\facade\Log;
/**
* 积分每日释放服务
*
* 由定时任务每天凌晨00:01或 Command 触发。
* 计算公式release_amount = FLOOR(frozen_points × rate / 1000)
* 其中 rate = hjf_release_rate默认 4即 4‰
*
* Class PointsReleaseServices
* @package app\services\hjf
*/
class PointsReleaseServices extends BaseServices
{
#[Inject]
protected PointsReleaseLogDao $logDao;
#[Inject]
protected UserDao $userDao;
/**
* 执行今日积分释放(批量)
*
* @return array 统计:['processed' => int, 'total_released' => int]
*/
public function executeRelease(): array
{
$rate = (int)SystemConfigService::get('hjf_release_rate', 4);
$releaseDate = date('Y-m-d');
$processed = 0;
$totalReleased = 0;
// 分批处理,每批 200 条,避免内存溢合
$page = 1;
$limit = 200;
do {
$users = $this->userDao->selectList(
['frozen_points' => ['>', 0]],
'uid,frozen_points,available_points',
$page,
$limit,
'uid',
'asc'
);
if (empty($users)) {
break;
}
foreach ($users as $user) {
$frozenBefore = (int)$user['frozen_points'];
// 使用 bcmath 确保精度
$releaseAmount = (int)bcdiv(bcmul((string)$frozenBefore, (string)$rate), '1000');
if ($releaseAmount <= 0) {
continue;
}
$frozenAfter = $frozenBefore - $releaseAmount;
try {
Db::transaction(function () use ($user, $releaseAmount, $frozenBefore, $frozenAfter, $releaseDate) {
// 更新用户积分字段
$this->userDao->update($user['uid'], [
'frozen_points' => $frozenAfter,
'available_points' => Db::raw('available_points + ' . $releaseAmount),
], 'uid');
// 写 points_release_log本次每日释放记录
$this->logDao->save([
'uid' => $user['uid'],
'points' => $releaseAmount,
'pm' => 1,
'type' => 'release',
'title' => '每日释放',
'mark' => "积分每日自动解冻,释放日期 {$releaseDate}",
'status' => 'released',
'release_date' => $releaseDate,
]);
});
$totalReleased += $releaseAmount;
$processed++;
} catch (\Throwable $e) {
Log::error("[PointsRelease] uid={$user['uid']} 释放失败: " . $e->getMessage());
}
}
$page++;
} while (count($users) === $limit);
Log::info("[PointsRelease] 完成processed={$processed} total_released={$totalReleased}");
return [
'processed' => $processed,
'total_released' => $totalReleased,
'release_date' => $releaseDate,
];
}
}

View File

@@ -0,0 +1,135 @@
<?php
declare(strict_types=1);
namespace app\services\hjf;
use app\dao\hjf\PointsReleaseLogDao;
use app\dao\user\UserDao;
use app\services\agent\AgentLevelServices;
use app\services\BaseServices;
use think\annotation\Inject;
use think\facade\Db;
use think\facade\Log;
/**
* 积分奖励服务(级差计算)—— 改造复用版
*
* 改造要点PRD 3.2.2
* - 使用 eb_user.agent_level (FK → eb_agent_level.id) 获取会员等级
* - 从 eb_agent_level 表的 direct_reward_points / umbrella_reward_points 字段读取奖励积分
* - 不再使用独立的 member_level 字段和系统配置表中的 hjf_reward_* 键
*
* Class PointsRewardServices
* @package app\services\hjf
*/
class PointsRewardServices extends BaseServices
{
#[Inject]
protected PointsReleaseLogDao $logDao;
#[Inject]
protected UserDao $userDao;
#[Inject]
protected AgentLevelServices $agentLevelServices;
/**
* 对一笔报单订单发放积分奖励
*/
public function reward(int $orderUid, string $orderId): void
{
try {
$buyer = $this->userDao->get($orderUid);
if (!$buyer || !$buyer['spread_uid']) {
return;
}
$this->propagateReward($buyer['spread_uid'], $orderUid, $orderId, 0);
} catch (\Throwable $e) {
Log::error("[PointsReward] 积分奖励失败 orderUid={$orderUid} orderId={$orderId}: " . $e->getMessage());
}
}
/**
* 向上递归发放级差积分
*
* @param int $uid 当前被奖励用户
* @param int $fromUid 触发方(下级)用户 ID
* @param string $orderId 来源订单号
* @param int $lowerReward 下级已获得的直推/伞下奖励积分(用于级差扣减)
* @param int $depth 递归深度
*/
private function propagateReward(
int $uid,
int $fromUid,
string $orderId,
int $lowerReward,
int $depth = 0
): void {
if ($depth >= 10 || $uid <= 0) {
return;
}
$user = $this->userDao->get($uid);
if (!$user) {
return;
}
$agentLevelId = (int)($user['agent_level'] ?? 0);
$grade = $this->agentLevelServices->getGradeByLevelId($agentLevelId);
if ($grade === 0) {
if ($user['spread_uid']) {
$this->propagateReward((int)$user['spread_uid'], $uid, $orderId, 0, $depth + 1);
}
return;
}
$isDirect = ($depth === 0);
$reward = $isDirect
? $this->agentLevelServices->getDirectRewardPoints($agentLevelId)
: $this->agentLevelServices->getUmbrellaRewardPoints($agentLevelId);
$actual = max(0, $reward - $lowerReward);
if ($actual > 0) {
$this->grantFrozenPoints(
$uid,
$actual,
$orderId,
$isDirect ? 'reward_direct' : 'reward_umbrella',
($isDirect ? '直推奖励' : '伞下奖励(级差)') . " - 来源订单 {$orderId}"
);
}
if ($user['spread_uid']) {
$this->propagateReward(
(int)$user['spread_uid'],
$uid,
$orderId,
$reward,
$depth + 1
);
}
}
/**
* 写入待释放积分frozen_points并记录明细
*/
private function grantFrozenPoints(int $uid, int $points, string $orderId, string $type, string $mark): void
{
Db::transaction(function () use ($uid, $points, $orderId, $type, $mark) {
$this->userDao->bcInc($uid, 'frozen_points', $points, 'uid');
$this->logDao->save([
'uid' => $uid,
'points' => $points,
'pm' => 1,
'type' => $type,
'title' => ($type === 'reward_direct') ? '直推奖励' : '伞下奖励',
'mark' => $mark,
'status' => 'frozen',
'order_id' => $orderId,
]);
});
}
}

View File

@@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace app\services\hjf;
use app\dao\hjf\QueuePoolDao;
use app\jobs\hjf\QueueRefundJob;
use app\services\BaseServices;
use app\services\user\UserServices;
use crmeb\services\CacheService;
use crmeb\services\SystemConfigService;
use think\annotation\Inject;
use think\exception\ValidateException;
use think\facade\Db;
use think\facade\Log;
/**
* 公排池服务
*
* 负责入队enqueue+ 退款触发条件判断 + 统计信息查询。
* 退款的实际执行委托给 QueueRefundJob异步以避免支付回调阻塞。
*
* Class QueuePoolServices
* @package app\services\hjf
* @mixin QueuePoolDao
*/
class QueuePoolServices extends BaseServices
{
#[Inject]
protected QueuePoolDao $dao;
/** Redis 分布式锁 Key */
const LOCK_KEY = 'hjf:queue:enqueue_lock';
/** 锁超时(秒) */
const LOCK_TTL = 10;
/**
* 报单商品订单入队
*
* 使用 Redis SET NX EX 分布式锁保证同一时刻只有一个入队+触发检测操作执行。
*
* @param int $uid 用户 ID
* @param string $orderId 原始订单号
* @param float $amount 金额(默认 3600.00
* @return array 新入队记录数组
* @throws ValidateException
*/
public function enqueue(int $uid, string $orderId, float $amount = 3600.00): array
{
$lockKey = self::LOCK_KEY;
$lockValue = uniqid('', true);
// 获取 Redis 实例
/** @var \Redis $redis */
$redis = CacheService::getRedis();
// SET NX EX 原子锁
$acquired = $redis->set($lockKey, $lockValue, ['NX', 'EX' => self::LOCK_TTL]);
if (!$acquired) {
throw new ValidateException('公排入队繁忙,请稍后重试');
}
try {
return Db::transaction(function () use ($uid, $orderId, $amount, $redis, $lockKey, $lockValue) {
$queueNo = $this->dao->nextQueueNo();
$record = $this->dao->save([
'uid' => $uid,
'order_id' => $orderId,
'amount' => $amount,
'queue_no' => $queueNo,
'status' => 0,
'refund_time' => 0,
'trigger_batch' => 0,
]);
$data = $record->toArray();
// 检查是否触发退款条件
$this->checkAndTriggerRefund();
return $data;
});
} finally {
// 释放锁Lua 原子删除,防止误删他人的锁)
$script = <<<'LUA'
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
LUA;
$redis->eval($script, [$lockKey, $lockValue], 1);
}
}
/**
* 检查是否达到退款触发条件,若是则派发异步退款 Job
*
* 触发条件:当前排队中总单数 ≥ triggerMultiple默认4
* 即每进入4单就对最早的1单触发退款。
*/
public function checkAndTriggerRefund(): void
{
$multiple = (int)SystemConfigService::get('hjf_trigger_multiple', 4);
$pending = $this->dao->countPending();
if ($pending < $multiple) {
return;
}
$earliest = $this->dao->getEarliestPending();
if (!$earliest) {
return;
}
// 批次号 = 历史已退款总数 + 1
$batchNo = $this->dao->count(['status' => 1]) + 1;
// 派发异步退款 Job
QueueRefundJob::dispatch($earliest['id'], $earliest['uid'], $earliest['amount'], $batchNo);
}
/**
* 获取用户的公排状态摘要(用于状态页)
*/
public function getUserStatus(int $uid): array
{
$multiple = (int)SystemConfigService::get('hjf_trigger_multiple', 4);
$pending = $this->dao->countPending();
$total = $this->dao->countTotal();
// 当前批次已入队单数(本批次进度)
$batchCount = $pending % $multiple;
// 用户自己的订单
$myOrders = $this->dao->getModel()
->where('uid', $uid)
->order('add_time', 'desc')
->select()
->toArray();
foreach ($myOrders as &$item) {
$item['estimated_wait'] = $item['status'] === 1
? '已退款'
: $this->estimateWait((int)$item['queue_no'], $pending, $multiple);
}
unset($item);
return [
'total_orders' => $total,
'my_orders' => $myOrders,
'progress' => [
'current_batch_count' => $batchCount,
'trigger_multiple' => $multiple,
'next_refund_queue_no' => $this->dao->getEarliestPending()['queue_no'] ?? 0,
],
];
}
/**
* 获取用户公排历史(分页,支持按状态筛选)
*/
public function getUserHistory(int $uid, int $status, int $page, int $limit): array
{
$result = $this->dao->getUserList($uid, $status, $page, $limit);
foreach ($result['list'] as &$item) {
$item['time_key'] = date('Y-m-d', (int)$item['add_time']);
}
unset($item);
return $result;
}
/**
* 简单估算等待时间(基于队列位置)
*/
private function estimateWait(int $queueNo, int $pending, int $multiple): string
{
$earliest = $this->dao->getEarliestPending();
if (!$earliest) {
return '--';
}
$positionFromFront = $queueNo - (int)$earliest['queue_no'];
if ($positionFromFront <= 0) {
return '即将退款';
}
$waitCycles = (int)ceil($positionFromFront / $multiple);
return "约等待 {$waitCycles}";
}
}

View File

@@ -954,7 +954,11 @@ class StoreOrderCreateServices extends BaseServices
} else {
$is_brokerage = $productInfo['is_brokerage'] ?? 0;
}
if ($is_brokerage == 0) {
// fsgx: is_queue_goods=1 的报单商品,即使 is_brokerage=0 也参与周期佣金计算
$isQueueGoodsProduct = (int)($productInfo['is_queue_goods'] ?? 0);
$brokerageScopeCheck = sys_config('brokerage_scope', 'all');
$queueGoodsBypassBrokerage = ($brokerageScopeCheck === 'queue_only' && $isQueueGoodsProduct === 1);
if ($is_brokerage == 0 && !$queueGoodsBypassBrokerage) {
continue;
}
//指定返佣金额
@@ -999,7 +1003,7 @@ class StoreOrderCreateServices extends BaseServices
if ($useCycleBrokerage && $spread_uid > 0) {
// 统计推荐人下级已完成的有效报单商品订单数,取模得到当前位次
// is_queue_goods 冗余存储在订单表eb_store_order由创建订单时写入
// 注意compute() 在 paid=1 之后执行,当前订单已被计入,需 -1 得到"之前完成单数"
/** @var \app\dao\order\StoreOrderDao $orderDao */
$orderDao = app()->make(\app\dao\order\StoreOrderDao::class);
$completedCount = $orderDao->count([
@@ -1008,7 +1012,7 @@ class StoreOrderCreateServices extends BaseServices
'paid' => 1,
'is_del' => 0,
]);
$position = $completedCount % $cycleCount;
$position = max(0, $completedCount - 1) % $cycleCount;
$cycleRatePercent = isset($cycleRates[$position]) ? (int)$cycleRates[$position] : (int)($cycleRates[0] ?? 0);
if ($cycleRatePercent > 0) {
$brokerageRatio = bcdiv((string)$cycleRatePercent, 100, 4);

View File

@@ -139,8 +139,9 @@ class StoreOrderTakeServices extends BaseServices
$res = $this->transaction(function () use ($order, $userInfo, $storeTitle) {
//赠送积分
$res1 = $this->gainUserIntegral($order, $userInfo, $storeTitle);
//返佣
$res2 = $this->backOrderBrokerage($order, $userInfo);
// fsgx: brokerage_timing=on_pay 时佣金已在支付时发放,此处跳过
$brokerageTiming = sys_config('brokerage_timing', 'on_confirm');
$res2 = ($brokerageTiming === 'on_pay') ? true : $this->backOrderBrokerage($order, $userInfo);
//经验
$res3 = $this->gainUserExp($order, $userInfo);
//事业部

View File

@@ -1494,9 +1494,9 @@ class SystemConfigServices extends BaseServices implements ServeConfigInterface
])->trueValue('开启', 1)->falseValue('关闭', 0)->info($data['brokerage_user_status']['desc']),
])->option('推荐佣金fsgx', [
Build::inputNum('brokerage_cycle_count', $data['brokerage_cycle_count']['info'] ?: '佣金周期人数', (int)($data['brokerage_cycle_count']['value'] ?: 3))->min(1)->info('推荐N人为一个周期循环计算各档佣金比例'),
Build::input('brokerage_cycle_rates', $data['brokerage_cycle_rates']['info'] ?: '各档佣金比例(JSON)', $data['brokerage_cycle_rates']['value'] ?: '[20,30,50]')->info('JSON数组元素为百分比整数如[20,30,50]表示第1人20%、第2人30%、第3人50%'),
Build::radio('brokerage_scope', $data['brokerage_scope']['info'] ?: '返佣范围', $data['brokerage_scope']['value'] ?: 'queue_only')->options([['label' => '所有商品', 'value' => 'all'], ['label' => '仅报单商品', 'value' => 'queue_only']])->info('queue_only=仅is_queue_goods=1的商品参与佣金计算'),
Build::radio('brokerage_timing', $data['brokerage_timing']['info'] ?: '佣金发放时机', $data['brokerage_timing']['value'] ?: 'on_pay')->options([['label' => '支付即发放', 'value' => 'on_pay'], ['label' => '确认收货后发放', 'value' => 'on_confirm']])->info('on_pay=订单支付后立即发放 on_confirm=用户确认收货后发放'),
Build::input('brokerage_cycle_rates', $data['brokerage_cycle_rates']['info'] ?: '各档佣金比例(JSON)', is_array($data['brokerage_cycle_rates']['value']) ? json_encode($data['brokerage_cycle_rates']['value']) : ($data['brokerage_cycle_rates']['value'] ?: '[20,30,50]'))->info('JSON数组元素为百分比整数如[20,30,50]表示第1人20%、第2人30%、第3人50%'),
Build::radio('brokerage_scope', $data['brokerage_scope']['info'] ?: '返佣范围', is_array($data['brokerage_scope']['value']) ? ($data['brokerage_scope']['value'][0] ?? 'queue_only') : ($data['brokerage_scope']['value'] ?: 'queue_only'))->options([['label' => '所有商品', 'value' => 'all'], ['label' => '仅报单商品', 'value' => 'queue_only']])->info('queue_only=仅is_queue_goods=1的商品参与佣金计算'),
Build::radio('brokerage_timing', $data['brokerage_timing']['info'] ?: '佣金发放时机', is_array($data['brokerage_timing']['value']) ? ($data['brokerage_timing']['value'][0] ?? 'on_pay') : ($data['brokerage_timing']['value'] ?: 'on_pay'))->options([['label' => '支付即发放', 'value' => 'on_pay'], ['label' => '确认收货后发放', 'value' => 'on_confirm']])->info('on_pay=订单支付后立即发放 on_confirm=用户确认收货后发放'),
])
]);

View File

@@ -14,6 +14,7 @@ namespace app\services\system\timer;
use app\services\BaseServices;
use crmeb\exceptions\AdminException;
use app\dao\system\timer\SystemTimerDao;
use app\listener\system\timer\SystemTimer as SystemTimerListener;
use think\annotation\Inject;
/**
@@ -231,6 +232,19 @@ class SystemTimerServices extends BaseServices
return $timer->toArray();
}
/**
* 手动立即执行指定定时任务HTTP 请求上下文,绕过 Swoole Cron 构造,用反射直接调用 implement_timer
* @param string $mark
* @return void
*/
public function runNow(string $mark): void
{
$this->update(['mark' => $mark], ['last_execution_time' => time()]);
$ref = new \ReflectionClass(SystemTimerListener::class);
$instance = $ref->newInstanceWithoutConstructor();
$instance->implement_timer($mark);
}
/**获取下次执行时间
* @param $type
* @param $cycle

View File

@@ -204,6 +204,24 @@ class UserBillServices extends BaseServices
'status' => 1,
'pm' => 1
],
// fsgx: 佣金转冻结积分
'frozen_points_brokerage' => [
'title' => '佣金奖励积分(待释放)',
'category' => 'integral',
'type' => 'frozen_points_brokerage',
'mark' => '获得待释放积分{%num%}',
'status' => 1,
'pm' => 1
],
// fsgx: 每日释放冻结积分
'frozen_points_release' => [
'title' => '积分每日释放',
'category' => 'integral',
'type' => 'frozen_points_release',
'mark' => '每日释放积分{%num%}',
'status' => 1,
'pm' => 1
],
];
/**

View File

@@ -770,6 +770,21 @@ class UserServices extends BaseServices
// 添加过滤条件
$where['is_filter_del'] = 1;
// HJF按分销等级 grade 筛选,转换为 agent_level ID 范围
if (isset($where['hjf_member_level']) && $where['hjf_member_level'] !== '') {
$grade = (int)$where['hjf_member_level'];
/** @var AgentLevelServices $agentLevelSvc */
$agentLevelSvc = app()->make(AgentLevelServices::class);
if ($grade > 0) {
$levelId = $agentLevelSvc->getLevelIdByGrade($grade);
$where['agent_level'] = $levelId > 0 ? $levelId : -1;
} else {
// grade=0 表示"无分销等级"
$where['agent_level'] = 0;
}
}
unset($where['hjf_member_level']);
/** @var UserWechatuserServices $userWechatUser */
$userWechatUser = app()->make(UserWechatuserServices::class);
$fields = 'u.*,w.country,w.province,w.city,w.sex,w.unionid,w.openid,w.user_type as w_user_type,w.groupid,w.tagid_list,w.subscribe,w.subscribe_time';
@@ -799,6 +814,11 @@ class UserServices extends BaseServices
$clientData = $workClientService->getList(['uid' => $uids], ['id', 'uid', 'name', 'external_userid', 'corp_id', 'unionid'], false);
$clientlist = $clientData['list'] ?? [];
/** HJF分销等级展示索引is_del=0按 id/grade 双索引,优先 HJF 官方等级名称) */
/** @var AgentLevelServices $agentLevelServices */
$agentLevelServices = app()->make(AgentLevelServices::class);
$hjfLevelMaps = $agentLevelServices->loadHjfUserListLevelMaps();
// 补充信息
$extendInfo = SystemConfigService::get('user_extend_info', []);
$is_extend_info = false;
@@ -878,6 +898,14 @@ class UserServices extends BaseServices
$item['svip_over_day'] = 0;
}
// 分销等级HJF 扩展member_level=grade 数值member_level_name=等级名称)
$agentLevelId = (int)($item['agent_level'] ?? 0);
$hjfLevelInfo = $agentLevelServices->pickHjfLevelRowForUserListDisplay($agentLevelId, $hjfLevelMaps);
$item['member_level'] = $hjfLevelInfo ? (int)$hjfLevelInfo['grade'] : null;
$item['member_level_name'] = $hjfLevelInfo ? ($hjfLevelInfo['name'] ?? '') : '';
$item['available_points'] = (int)($item['available_points'] ?? 0);
$item['frozen_points'] = (int)($item['frozen_points'] ?? 0);
// 标签
$item['labels'] = $userlabel[$item['uid']] ?? '';

View File

@@ -12,6 +12,7 @@ declare (strict_types=1);
namespace app\services\user;
use app\services\agent\AgentLevelServices;
use app\services\BaseServices;
use app\dao\user\UserWechatUserDao;
use think\annotation\Inject;
@@ -48,6 +49,7 @@ class UserWechatuserServices extends BaseServices
*/
public function getWhereUserList(array $where, string $field): array
{
$where = $this->normalizeHjfMemberLevelWhere($where);
[$page, $limit] = $this->getPageValue();
$order_string = '';
$order_arr = ['asc', 'desc'];
@@ -58,4 +60,40 @@ class UserWechatuserServices extends BaseServices
$count = $this->dao->getCountByWhere($where);
return [$list, $count];
}
/**
* 将会员列表筛选「HJF 等级grade」转为 eb_user.agent_level 条件,供 UserWechatUserDao 使用。
*/
protected function normalizeHjfMemberLevelWhere(array $where): array
{
if (!array_key_exists('hjf_member_level', $where)) {
return $where;
}
$raw = $where['hjf_member_level'];
if ($raw === null) {
unset($where['hjf_member_level']);
return $where;
}
if (is_string($raw)) {
$raw = trim($raw);
}
// 空串/仅空白:不按分销等级筛选(避免 (int)' '=>0 误加 agent_level=0
if ($raw === '') {
unset($where['hjf_member_level']);
return $where;
}
$grade = (int)$raw;
/** @var AgentLevelServices $agentLevel */
$agentLevel = app()->make(AgentLevelServices::class);
if ($grade === 0) {
$where['hjf_agent_level_id'] = 0;
} else {
$where['hjf_agent_level_id'] = $agentLevel->getLevelIdByGrade($grade) ?: -1;
}
unset($where['hjf_member_level']);
return $where;
}
}