feat(partner): 完善马豆实时动态汇率与精度自适应,支持交易数据清理与汇率重置
这个提交包含在:
@@ -198,7 +198,7 @@ class User extends Common
|
||||
->where('user_id', $id);
|
||||
if (!$showSkip) {
|
||||
$query->where(function ($q) {
|
||||
$q->where('amount', '>', 0)->whereOr('madow', '>', 0);
|
||||
$q->where('amount', '<>', 0)->whereOr('madow', '<>', 0);
|
||||
});
|
||||
}
|
||||
$list = $query->order('id', 'desc')->paginate($this->request->param('list_rows', 10));
|
||||
@@ -312,17 +312,182 @@ class User extends Common
|
||||
}
|
||||
|
||||
$params = $this->request->only(['id', 'type', 'amount', 'memo']);
|
||||
if (!in_array($params['type'], ['money', 'score'])) {
|
||||
error_stop('参数错误');
|
||||
$userId = intval($params['id'] ?? 0);
|
||||
$type = (string) ($params['type'] ?? 'money');
|
||||
$amount = trim((string) ($params['amount'] ?? ''));
|
||||
$memo = trim((string) ($params['memo'] ?? ''));
|
||||
|
||||
if (!$userId || !is_numeric($amount) || (float) $amount == 0) {
|
||||
$this->error('请输入合法的变更数值');
|
||||
}
|
||||
|
||||
$result = Db::transaction(function () use ($params) {
|
||||
return WalletService::change($params['id'], $params['type'], $params['amount'], 'admin_recharge', [], $params['memo']);
|
||||
});
|
||||
if ($result) {
|
||||
$this->success('充值成功');
|
||||
$allowedTypes = ['money', 'score', 'madow', 'voucher'];
|
||||
if (!in_array($type, $allowedTypes)) {
|
||||
$this->error('不支持的资产类型');
|
||||
}
|
||||
$this->error('充值失败');
|
||||
|
||||
try {
|
||||
$result = Db::transaction(function () use ($userId, $type, $amount, $memo) {
|
||||
$user = UserModel::where('id', $userId)->lock(true)->find();
|
||||
if (!$user) {
|
||||
error_stop('会员不存在');
|
||||
}
|
||||
|
||||
$oper = \addons\shopro\library\Operator::get();
|
||||
$operDesc = ($oper['type'] ?? 'admin') . ($oper['id'] ? '#' . $oper['id'] : '');
|
||||
|
||||
if ($type === 'money') {
|
||||
// 余额充值或扣除 (保留2位小数)
|
||||
$amount = round((float) $amount, 2);
|
||||
if ($amount < 0 && bcadd((string) $user->money, (string) $amount, 2) < 0) {
|
||||
error_stop('当前余额不足,无法扣除');
|
||||
}
|
||||
return WalletService::change($userId, 'money', $amount, 'admin_recharge', [], $memo ?: '后台管理调整余额');
|
||||
}
|
||||
|
||||
if ($type === 'score') {
|
||||
// 积分充值或扣除 (整数)
|
||||
$amount = intval($amount);
|
||||
if ($amount == 0) {
|
||||
error_stop('请输入有效的积分数值');
|
||||
}
|
||||
if ($amount < 0 && ($user->score + $amount) < 0) {
|
||||
error_stop('当前积分不足,无法扣除');
|
||||
}
|
||||
return WalletService::change($userId, 'score', $amount, 'admin_recharge', [], $memo ?: '后台管理调整积分');
|
||||
}
|
||||
|
||||
// 对于马豆 (madow) 与兑换券 (voucher),属于合伙人二开资产体系
|
||||
$agent = \app\admin\model\shopro\commission\Agent::where('user_id', $userId)->lock(true)->find();
|
||||
if (!$agent) {
|
||||
// 自动为用户初始化分销商记录,以便承载二开资产体系
|
||||
$agent = (new \addons\shopro\service\commission\Agent($userId))->createNewAgent();
|
||||
$agent = \app\admin\model\shopro\commission\Agent::where('user_id', $userId)->lock(true)->find();
|
||||
if (!$agent) {
|
||||
$now = time();
|
||||
\think\Db::name('shopro_commission_agent')->insert([
|
||||
'user_id' => $userId,
|
||||
'level' => 1,
|
||||
'status' => 'normal',
|
||||
'madow' => '0.00000000',
|
||||
'voucher_total' => '0.00',
|
||||
'voucher_released' => '0.00',
|
||||
'voucher_used' => '0.00',
|
||||
'createtime' => $now,
|
||||
'updatetime' => $now,
|
||||
]);
|
||||
$agent = \app\admin\model\shopro\commission\Agent::where('user_id', $userId)->lock(true)->find();
|
||||
}
|
||||
}
|
||||
|
||||
// 生成全局唯一的负数 order_id(防止与商城真实订单冲突,并避免 uk_order_user_type 唯一索引冲突)
|
||||
$randOrderId = -intval(substr(time(), -6) . mt_rand(100, 999));
|
||||
$identity = \addons\shopro\service\partner\IdentityService::build($agent);
|
||||
$role = !empty($identity['identities']) ? end($identity['identities']) : 'taster';
|
||||
|
||||
if ($type === 'madow') {
|
||||
// 马豆充值或扣除 (8位小数精度)
|
||||
$currentMadow = (string) ($agent->madow ?? '0.00000000');
|
||||
$amountMadow = function_exists('bcadd') ? bcadd((string) $amount, '0', 8) : number_format((float) $amount, 8, '.', '');
|
||||
$afterMadow = function_exists('bcadd') ? bcadd($currentMadow, $amountMadow, 8) : ($currentMadow + $amountMadow);
|
||||
|
||||
if ((float) $afterMadow < 0) {
|
||||
error_stop('当前持仓马豆不足,无法扣除');
|
||||
}
|
||||
|
||||
$rate = (new \addons\shopro\service\partner\RateService())->currentRate();
|
||||
$agent->madow = $afterMadow;
|
||||
$agent->save();
|
||||
|
||||
\addons\shopro\service\partner\PartnerRewardService::write([
|
||||
'order_id' => $randOrderId,
|
||||
'buyer_id' => $userId,
|
||||
'user_id' => $userId,
|
||||
'role' => $role,
|
||||
'reward_type' => 'admin_madow',
|
||||
'amount' => 0,
|
||||
'madow' => $amountMadow,
|
||||
'base_amount' => 0,
|
||||
'rate' => $rate,
|
||||
'settle_date' => null, // 设为 null,避免与每日均分的 uk_daily_user_type 发生索引冲突
|
||||
'ext' => [
|
||||
'formula' => ($amount > 0 ? '管理员充值马豆: +' : '管理员扣除马豆: ') . $amountMadow,
|
||||
'memo' => $memo ?: ($amount > 0 ? '管理员充值马豆' : '管理员扣除马豆'),
|
||||
'operator' => $operDesc,
|
||||
'before' => $currentMadow,
|
||||
'after' => $afterMadow,
|
||||
],
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($type === 'voucher') {
|
||||
// 兑换券充值或扣除 (2位小数精度)
|
||||
$amountVoucher = round((float) $amount, 2);
|
||||
$currentReleased = (float) ($agent->voucher_released ?? 0);
|
||||
$currentUsed = (float) ($agent->voucher_used ?? 0);
|
||||
$currentTotal = (float) ($agent->voucher_total ?? 0);
|
||||
$available = max(0, round($currentReleased - $currentUsed, 2));
|
||||
|
||||
if ($amountVoucher < 0 && bccomp((string) $available, (string) abs($amountVoucher), 2) < 0) {
|
||||
error_stop('当前可用兑换券不足,无法扣除');
|
||||
}
|
||||
|
||||
if ($amountVoucher > 0) {
|
||||
// 充值增加可用兑换券:同时增加 released 与 total
|
||||
$newReleased = round($currentReleased + $amountVoucher, 2);
|
||||
$newTotal = round($currentTotal + $amountVoucher, 2);
|
||||
} else {
|
||||
// 扣除可用兑换券:减少 released 与 total
|
||||
$deduct = abs($amountVoucher);
|
||||
$newReleased = max(0, round($currentReleased - $deduct, 2));
|
||||
$newTotal = max(0, round($currentTotal - $deduct, 2));
|
||||
}
|
||||
|
||||
$agent->voucher_released = $newReleased;
|
||||
$agent->voucher_total = $newTotal;
|
||||
$agent->save();
|
||||
|
||||
\addons\shopro\service\partner\PartnerRewardService::write([
|
||||
'order_id' => $randOrderId,
|
||||
'buyer_id' => $userId,
|
||||
'user_id' => $userId,
|
||||
'role' => $role,
|
||||
'reward_type' => 'admin_voucher',
|
||||
'amount' => $amountVoucher,
|
||||
'madow' => 0,
|
||||
'base_amount' => abs($amountVoucher),
|
||||
'rate' => 0,
|
||||
'settle_date' => null,
|
||||
'ext' => [
|
||||
'formula' => ($amountVoucher > 0 ? '管理员充值兑换券: +' : '管理员扣除兑换券: ') . $amountVoucher,
|
||||
'memo' => $memo ?: ($amountVoucher > 0 ? '管理员充值兑换券' : '管理员扣除兑换券'),
|
||||
'operator' => $operDesc,
|
||||
'before_available' => number_format($available, 2, '.', ''),
|
||||
'after_available' => number_format(max(0, $newReleased - $currentUsed), 2, '.', ''),
|
||||
],
|
||||
'status' => 1,
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
} catch (\think\exception\HttpResponseException $e) {
|
||||
$data = $e->getResponse()->getData();
|
||||
$msg = $data ? ($data['msg'] ?? '') : $e->getMessage();
|
||||
$this->error($msg ?: '操作失败');
|
||||
} catch (\Exception $e) {
|
||||
$this->error($e->getMessage() ?: '系统异常');
|
||||
}
|
||||
|
||||
if ($result) {
|
||||
$this->success('操作成功');
|
||||
}
|
||||
$this->error('操作失败');
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ class User extends Validate
|
||||
'username.alphaDash' => '用户名只能包含字母,数字,_和-',
|
||||
'username.length' => '用户名长度必须在 5-12 位',
|
||||
'username.unique' => '用户名已被占用',
|
||||
'username.regx' => '用户名需以字母开头',
|
||||
'username.regex' => '用户名需以字母开头',
|
||||
|
||||
'nickname.require' => '昵称必须填写',
|
||||
'nickname.chsDash' => '昵称只能包含汉字,字母,数字,_和-',
|
||||
@@ -45,13 +45,13 @@ class User extends Validate
|
||||
|
||||
'password.require' => '请填写密码',
|
||||
'password.length' => '密码长度必须在 6-16 位',
|
||||
'password.regx' => '密码必须包含字母和数字',
|
||||
'password.regex' => '密码必须包含字母和数字',
|
||||
|
||||
'oldPassword.require' => '请填写旧密码',
|
||||
|
||||
'newPassword.require' => '请填写新密码',
|
||||
'newPassword.length' => '密码长度必须在 6-16 位',
|
||||
'newPassword.regx' => '密码必须包含字母和数字',
|
||||
'newPassword.regex' => '密码必须包含字母和数字',
|
||||
|
||||
'avatar.require' => '头像必须上传',
|
||||
|
||||
|
||||
@@ -177,7 +177,19 @@
|
||||
收益中心分销商档案
|
||||
</el-button>
|
||||
{if $auth->check('shopro/user/user/recharge')}
|
||||
<el-button type="warning" @click="onRecharge('money')">账户充值</el-button>
|
||||
<el-dropdown trigger="click" @command="onRecharge">
|
||||
<el-button type="warning">
|
||||
账户充值 <el-icon class="el-icon--right"><arrow-down /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item command="money">充值余额 (元)</el-dropdown-item>
|
||||
<el-dropdown-item command="madow">充值马豆 (颗)</el-dropdown-item>
|
||||
<el-dropdown-item command="score">充值积分 (分)</el-dropdown-item>
|
||||
<el-dropdown-item command="voucher">充值兑换券 (元)</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
{/if}
|
||||
<el-button class="sa-button-refresh ml-2" icon="RefreshRight" @click="getDetail"></el-button>
|
||||
</div>
|
||||
@@ -425,7 +437,12 @@
|
||||
<!-- 资产 2: 分红马豆 -->
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="asset-card-box">
|
||||
<div class="asset-card-label">分红马豆 (GMV 1:1 分红)</div>
|
||||
<div class="asset-card-label sa-flex sa-row-between" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>分红马豆</span>
|
||||
{if $auth->check('shopro/user/user/recharge')}
|
||||
<el-button type="success" link size="small" @click="onRecharge('madow')">充值/扣除</el-button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="asset-card-num" style="color: #67c23a;">
|
||||
{{ state.detail.madow || 0 }} <span style="font-size: 13px;">颗</span>
|
||||
</div>
|
||||
@@ -441,7 +458,12 @@
|
||||
<!-- 资产 3: 商城兑换券 -->
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="asset-card-box">
|
||||
<div class="asset-card-label">商城可用兑换券</div>
|
||||
<div class="asset-card-label sa-flex sa-row-between" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>商城可用兑换券</span>
|
||||
{if $auth->check('shopro/user/user/recharge')}
|
||||
<el-button type="warning" link size="small" @click="onRecharge('voucher')">充值/扣除</el-button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="asset-card-num" style="color: #e6a23c;">
|
||||
¥{{ state.detail.voucher_available || '0.00' }}
|
||||
</div>
|
||||
@@ -457,7 +479,15 @@
|
||||
<!-- 资产 4: 基础钱包与消费 -->
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<div class="asset-card-box">
|
||||
<div class="asset-card-label">商城余额 / 积分</div>
|
||||
<div class="asset-card-label sa-flex sa-row-between" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>商城余额 / 积分</span>
|
||||
{if $auth->check('shopro/user/user/recharge')}
|
||||
<div>
|
||||
<el-button type="primary" link size="small" @click="onRecharge('money')">充余额</el-button>
|
||||
<el-button type="primary" link size="small" @click="onRecharge('score')">充积分</el-button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="asset-card-num" style="color: #303133;">
|
||||
¥{{ state.detail.money || '0.00' }}
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,104 @@
|
||||
{include file="/shopro/common/script" /}
|
||||
|
||||
<div id="recharge" class="user-recharge" v-cloak>
|
||||
<el-container class="panel-block">
|
||||
<el-main>
|
||||
<el-form ref="formRef" :model="form.model" :rules="form.rules" label-width="100px">
|
||||
<el-form-item :label="`充值${state.type == 'money' ? '金额' : '积分'}`" prop="amount">
|
||||
<el-input v-model="form.model.amount" type="number" placeholder="负数则为扣除" />
|
||||
<div id="recharge" class="user-recharge" v-cloak style="padding: 16px;">
|
||||
<el-container class="panel-block" v-loading="state.loading">
|
||||
<el-main style="padding: 0 8px 16px 8px;">
|
||||
<!-- 会员简要信息与四大资产全景卡片 -->
|
||||
<div v-if="state.user" style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 12px 16px; margin-bottom: 20px;">
|
||||
<div class="sa-flex sa-row-between mb-3" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="sa-flex" style="display: flex; align-items: center;">
|
||||
<sa-image :url="state.user.avatar" size="42" radius="21"></sa-image>
|
||||
<div style="margin-left: 10px;">
|
||||
<div style="font-weight: 600; font-size: 14px; color: #1e293b;">{{ state.user.nickname || '未命名会员' }}</div>
|
||||
<div style="font-size: 12px; color: #64748b;">会员ID: #{{ state.user.id }} <span v-if="state.user.mobile">({{ state.user.mobile }})</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<el-tag v-if="state.user.identity_text" type="success" size="small">{{ state.user.identity_text }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 四大资产当前存量栅格展示 -->
|
||||
<el-row :gutter="8">
|
||||
<el-col :span="6">
|
||||
<div style="background: #ffffff; padding: 8px 10px; border-radius: 6px; border: 1px solid #edf2f7; text-align: center;">
|
||||
<div style="font-size: 11px; color: #64748b;">商城余额</div>
|
||||
<div style="font-size: 13px; font-weight: 600; color: #2563eb; margin-top: 2px;">¥{{ state.user.money || '0.00' }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div style="background: #ffffff; padding: 8px 10px; border-radius: 6px; border: 1px solid #edf2f7; text-align: center;">
|
||||
<div style="font-size: 11px; color: #64748b;">分红马豆</div>
|
||||
<div style="font-size: 13px; font-weight: 600; color: #16a34a; margin-top: 2px;">{{ state.user.madow || '0' }} <span style="font-size: 10px;">颗</span></div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div style="background: #ffffff; padding: 8px 10px; border-radius: 6px; border: 1px solid #edf2f7; text-align: center;">
|
||||
<div style="font-size: 11px; color: #64748b;">商城积分</div>
|
||||
<div style="font-size: 13px; font-weight: 600; color: #d97706; margin-top: 2px;">{{ state.user.score || 0 }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="6">
|
||||
<div style="background: #ffffff; padding: 8px 10px; border-radius: 6px; border: 1px solid #edf2f7; text-align: center;">
|
||||
<div style="font-size: 11px; color: #64748b;">可用兑换券</div>
|
||||
<div style="font-size: 13px; font-weight: 600; color: #ea580c; margin-top: 2px;">¥{{ state.user.voucher_available || '0.00' }}</div>
|
||||
</div>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<!-- 充值操作表单 -->
|
||||
<el-form ref="formRef" :model="form.model" :rules="form.rules" label-width="110px" label-position="right">
|
||||
<el-form-item label="充值资产类型" prop="type">
|
||||
<el-radio-group v-model="form.model.type" @change="onTypeChange">
|
||||
<el-radio-button label="money">余额 (元)</el-radio-button>
|
||||
<el-radio-button label="madow">马豆 (颗)</el-radio-button>
|
||||
<el-radio-button label="score">积分 (分)</el-radio-button>
|
||||
<el-radio-button label="voucher">兑换券 (元)</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="form.model.memo"></el-input>
|
||||
|
||||
<el-form-item label="当前可用持有">
|
||||
<div style="font-size: 15px; font-weight: 600; color: #334155;">
|
||||
<span v-if="form.model.type === 'money'">¥{{ currentBalance }}</span>
|
||||
<span v-else-if="form.model.type === 'madow'">{{ currentBalance }} 颗 (实时估值 ¥{{ state.user ? state.user.madow_value : '0.00' }})</span>
|
||||
<span v-else-if="form.model.type === 'score'">{{ currentBalance }} 积分</span>
|
||||
<span v-else-if="form.model.type === 'voucher'">¥{{ currentBalance }} (计划总额 ¥{{ state.user ? state.user.voucher_total : '0.00' }})</span>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="`变动${assetLabel}`" prop="amount">
|
||||
<el-input
|
||||
v-model="form.model.amount"
|
||||
type="number"
|
||||
:step="form.model.type === 'madow' ? '0.00000001' : (form.model.type === 'score' ? '1' : '0.01')"
|
||||
:placeholder="form.model.type === 'madow' ? '正数为充值,负数为扣除 (支持8位小数)' : '正数为充值,负数为扣除'"
|
||||
clearable>
|
||||
<template #append>{{ assetUnit }}</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 动态变动试算与防呆预警 -->
|
||||
<el-form-item v-if="previewBalance !== null" label="变动试算">
|
||||
<div style="width: 100%; padding: 8px 12px; border-radius: 6px;" :style="{ background: isOverdrawn ? '#fef2f2' : '#f0fdf4', border: isOverdrawn ? '1px solid #fecaca' : '1px solid #bbf7d0' }">
|
||||
<div style="font-size: 13px;" :style="{ color: isOverdrawn ? '#dc2626' : '#15803d' }">
|
||||
<span>变动后预计持有:<b>{{ previewBalance }} {{ assetUnit }}</b></span>
|
||||
<span v-if="isOverdrawn" style="margin-left: 8px; font-weight: bold;">(⚠️ 扣除超出当前持有量,提交将被拦截)</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="操作备注" prop="memo">
|
||||
<el-input v-model="form.model.memo" placeholder="请输入充值或扣除原因(可追溯审计)" maxlength="100" show-word-limit></el-input>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-main>
|
||||
<el-footer class="sa-footer--submit sa-flex sa-row-right">
|
||||
<el-button type="primary" @click="onConfirm">确定</el-button>
|
||||
|
||||
<el-footer class="sa-footer--submit sa-flex sa-row-right" style="display: flex; justify-content: flex-end; padding-top: 10px; border-top: 1px solid #f1f5f9;">
|
||||
<el-button @click="onCancel">取消</el-button>
|
||||
<el-button type="primary" :loading="state.submitting" :disabled="isOverdrawn" @click="onConfirm">
|
||||
确认{{ form.model.amount && parseFloat(form.model.amount) < 0 ? '扣除' : '充值' }}
|
||||
</el-button>
|
||||
</el-footer>
|
||||
</el-container>
|
||||
</div>
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
// 公共助手函数
|
||||
|
||||
// 全局设置文件掩码,确保 CLI / Web / 队列生成的文件和目录具备全读写权限,杜绝权限冲突
|
||||
umask(0000);
|
||||
|
||||
use think\exception\HttpResponseException;
|
||||
use think\Response;
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户