feat: 期权交易后端 — 下单+自动结算+后台管理

This commit is contained in:
testadmin
2026-04-28 16:05:00 +08:00
parent db5ed78ea9
commit dc19fdb5da
6 changed files with 387 additions and 0 deletions
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace App\Console\Commands;
use App\OptionOrder;
use App\OptionPair;
use App\UsersWallet;
use App\AccountLog;
use App\CurrencyMatch;
use App\Currency;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class AutoOptionSettle extends Command
{
protected $signature = "auto_option_settle";
protected $description = "自动结算到期的期权订单";
public function handle()
{
$orderIds = OptionOrder::where('status', 0)
->where('delivery_time', '<=', now())
->pluck('id');
if ($orderIds->isEmpty()) return;
foreach ($orderIds as $orderId) {
DB::beginTransaction();
try {
$affected = OptionOrder::where('id', $orderId)->where('status', 0)
->update(['status' => 99]);
if ($affected === 0) {
DB::rollBack();
continue;
}
$order = OptionOrder::lockForUpdate()->find($orderId);
$pair = OptionPair::find($order->pair_id);
$match = $pair ? CurrencyMatch::find($pair->currency_match_id) : null;
$currency = $match ? Currency::find($match->currency_id) : null;
$close_price = $currency ? sprintf("%.6f", $currency->price) : '0';
if (bccomp($close_price, '0', 6) <= 0) {
$order->update(['status' => 0]);
DB::commit();
$this->warn("Order #{$orderId}: close_price=0, skip");
continue;
}
$cmp = bccomp($close_price, sprintf("%.6f", $order->open_price), 6);
$win = $order->direction == 'up' ? $cmp > 0 : $cmp < 0;
$income = $win
? bcmul(sprintf("%.6f", $order->amount), bcdiv(sprintf("%.6f", $order->profit_rate), '100', 8), 6)
: bcmul(sprintf("%.6f", $order->amount), '-1', 6);
$order->update([
'close_price' => $close_price,
'income' => $income,
'status' => $win ? 1 : 2,
]);
if ($win) {
$wallet = UsersWallet::where('user_id', $order->user_id)
->where('currency', 3)->lockForUpdate()->first();
if ($wallet) {
$return_amount = bcadd(sprintf("%.6f", $order->amount), $income, 6);
$before = $wallet->legal_balance;
$wallet->legal_balance = bcadd($wallet->legal_balance, $return_amount, 6);
$wallet->save();
AccountLog::insertLog(
['user_id' => $order->user_id, 'value' => $return_amount, 'info' => 'Option Win - ' . $order->symbol, 'type' => AccountLog::MICRO_TRADE_CLOSE_SETTLE, 'currency' => 3],
['balance_type' => 1, 'wallet_id' => $wallet->id, 'lock_type' => 0, 'before' => $before, 'change' => $return_amount, 'after' => $wallet->legal_balance]
);
}
}
DB::commit();
$this->info("Order #{$orderId} settled: " . ($win ? 'WIN' : 'LOSE'));
} catch (\Exception $e) {
DB::rollBack();
$this->error("Order #{$orderId} error: " . $e->getMessage());
}
}
}
}
@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Admin;
use App\OptionPair;
use App\OptionOrder;
use App\UsersWallet;
use App\AccountLog;
use App\CurrencyMatch;
use App\Currency;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class OptionController extends Controller
{
public function orderIndex() { return view('manages.option.orders'); }
public function orderList(Request $request)
{
$query = OptionOrder::query()
->leftJoin('users', 'option_order.user_id', '=', 'users.id')
->leftJoin('option_pair', 'option_order.pair_id', '=', 'option_pair.id')
->select('option_order.*', 'users.phone as user_phone', 'users.email as user_email', 'option_pair.coinname');
$keyword = $request->input('keyword');
if ($keyword) {
$query->where(function ($q) use ($keyword) {
$q->where('option_order.user_id', $keyword)
->orWhere('users.email', 'like', "%{$keyword}%")
->orWhere('users.phone', 'like', "%{$keyword}%");
});
}
$status = $request->input('status');
if ($status !== null && $status !== '') {
$query->where('option_order.status', intval($status));
}
$data = $query->orderBy('option_order.id', 'desc')->paginate($request->input('limit', 20));
$statusMap = ['0'=>'待交割','1'=>'盈利','2'=>'亏损','3'=>'已取消'];
foreach ($data as $item) {
$item->status_text = $statusMap[$item->status] ?? '';
$item->direction_text = $item->direction == 'up' ? '买涨' : '买跌';
}
return $this->layuiData($data);
}
public function settle(Request $request)
{
$ids = $request->input('ids');
$force = $request->input('force', '');
if (!$ids) return $this->error('请先选择订单');
$orders = OptionOrder::whereIn('id', explode(',', $ids))->where('status', 0)->get();
if ($orders->isEmpty()) return $this->error('没有待结算订单');
$settled = 0;
DB::beginTransaction();
try {
foreach ($orders as $order) {
$pair = OptionPair::find($order->pair_id);
$match = $pair ? CurrencyMatch::find($pair->currency_match_id) : null;
$currency = $match ? Currency::find($match->currency_id) : null;
$close_price = $currency ? floatval($currency->price) : 0;
if ($force === 'win') $win = true;
elseif ($force === 'lose') $win = false;
else $win = $order->direction == 'up' ? $close_price > $order->open_price : $close_price < $order->open_price;
$income = $win ? sprintf("%.6f", $order->amount * $order->profit_rate / 100) : -$order->amount;
$order->update(['close_price' => $close_price, 'income' => $income, 'status' => $win ? 1 : 2]);
$wallet = UsersWallet::where('user_id', $order->user_id)->where('currency', 3)->first();
if ($wallet) {
$return_amount = $win ? $order->amount + abs($income) : 0;
if ($return_amount > 0) {
$before = $wallet->legal_balance;
$wallet->legal_balance = bc_add($wallet->legal_balance, $return_amount, 6);
$wallet->save();
AccountLog::insertLog(
['user_id'=>$order->user_id,'value'=>$return_amount,'info'=>'Option '.($win?'Win':'Refund').' - '.$order->symbol,'type'=>AccountLog::MICRO_TRADE_CLOSE_SETTLE,'currency'=>3],
['balance_type'=>1,'wallet_id'=>$wallet->id,'lock_type'=>0,'before'=>$before,'change'=>$return_amount,'after'=>$wallet->legal_balance]
);
}
}
$settled++;
}
DB::commit();
return $this->success("已结算 {$settled} 个订单");
} catch (\Exception $e) {
DB::rollBack();
return $this->error($e->getMessage());
}
}
}
@@ -0,0 +1,111 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Controller;
use App\OptionPair;
use App\OptionOrder;
use App\UsersWallet;
use App\AccountLog;
use App\CurrencyMatch;
use App\Currency;
use Illuminate\Http\Request;
use App\Token;
use App\Users;
use Illuminate\Support\Facades\DB;
class OptionController extends Controller
{
public function pairs()
{
$pairs = OptionPair::where('status', 1)->orderBy('weigh', 'desc')->get();
foreach ($pairs as $p) {
$match = CurrencyMatch::find($p->currency_match_id);
if ($match) {
$currency = Currency::find($match->currency_id);
$p->close = $currency ? $currency->price : 0;
$p->price_change = $currency ? ($currency->change ?? 0) : 0;
$p->high = $currency ? ($currency->high ?? $p->close) : 0;
$p->low = $currency ? ($currency->low ?? $p->close) : 0;
$p->match_legal_id = $match->legal_id;
$p->match_currency_id = $match->currency_id;
$p->volume_24h = $currency ? ($currency->vol ?? 0) : 0;
}
}
return $this->success($pairs);
}
public function placeOrder(Request $request)
{
$user_id = Token::getUserIdByToken(Token::getToken());
$user = \App\Users::find($user_id);
if (!$user) return $this->error('Please login');
$pair_id = $request->input('pair_id');
$direction = $request->input('direction');
$amount = floatval($request->input('amount'));
$delivery_time = $request->input('delivery_time');
if (!in_array($direction, ['up', 'down'])) return $this->error('Invalid direction');
if ($amount <= 0) return $this->error('Invalid amount');
if (!$delivery_time) return $this->error('Select delivery time');
$pair = OptionPair::where('id', $pair_id)->where('status', 1)->first();
if (!$pair) return $this->error('Pair not available');
if ($amount < $pair->min_amount) return $this->error('Min: ' . $pair->min_amount . ' USDC');
if ($amount > $pair->max_amount) return $this->error('Max: ' . $pair->max_amount . ' USDC');
$delivery_ts = is_numeric($delivery_time) ? date('Y-m-d H:i:s', $delivery_time) : $delivery_time;
if (strtotime($delivery_ts) <= time() + 10) return $this->error('Delivery time must be in the future');
$match = CurrencyMatch::find($pair->currency_match_id);
$currency = $match ? Currency::find($match->currency_id) : null;
$open_price = $currency ? floatval($currency->price) : 0;
$fee = sprintf("%.6f", $amount * $pair->fee_rate / 100);
DB::beginTransaction();
try {
$wallet = UsersWallet::where('user_id', $user->id)->where('currency', 3)->lockForUpdate()->first();
if (!$wallet || $wallet->legal_balance < $amount) {
DB::rollBack();
return $this->error('Insufficient balance');
}
$before = $wallet->legal_balance;
$wallet->legal_balance = bc_sub($wallet->legal_balance, $amount, 6);
$wallet->save();
AccountLog::insertLog(
['user_id' => $user->id, 'value' => -$amount, 'info' => 'Option ' . strtoupper($direction) . ' - ' . $pair->coinname, 'type' => AccountLog::MICRO_TRADE_SUBMIT, 'currency' => 3],
['balance_type' => 1, 'wallet_id' => $wallet->id, 'lock_type' => 0, 'before' => $before, 'change' => -$amount, 'after' => $wallet->legal_balance]
);
OptionOrder::create([
'user_id' => $user->id, 'pair_id' => $pair_id,
'symbol' => $pair->symbol, 'direction' => $direction,
'amount' => $amount, 'fee' => $fee,
'open_price' => $open_price, 'profit_rate' => $pair->profit_rate,
'delivery_time' => $delivery_ts, 'status' => 0,
]);
DB::commit();
return $this->success('Order placed');
} catch (\Exception $e) {
DB::rollBack();
return $this->error($e->getMessage());
}
}
public function myOrders(Request $request)
{
$user_id = Token::getUserIdByToken(Token::getToken());
$user = \App\Users::find($user_id);
if (!$user) return $this->error('Please login');
$status = $request->input('status', 'all');
$query = OptionOrder::where('user_id', $user->id)->with('pair:id,coinname');
if ($status !== 'all') $query->where('status', $status);
$list = $query->orderBy('id', 'desc')->paginate(20);
$statusMap = ['0'=>'Pending','1'=>'Win','2'=>'Lose','3'=>'Canceled'];
foreach ($list as $item) {
$item->status_text = $statusMap[$item->status] ?? '';
$item->direction_text = $item->direction == 'up' ? 'Buy Up' : 'Buy Down';
$item->coinname = $item->pair ? $item->pair->coinname : $item->symbol;
}
return $this->success($list);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class OptionOrder extends Model
{
protected $table = 'option_order';
protected $fillable = ['user_id','pair_id','symbol','direction','amount','fee','open_price','close_price','profit_rate','income','delivery_time','status'];
public function pair() { return $this->belongsTo(OptionPair::class, 'pair_id'); }
}
+9
View File
@@ -0,0 +1,9 @@
<?php
namespace App;
use Illuminate\Database\Eloquent\Model;
class OptionPair extends Model
{
protected $table = 'option_pair';
protected $fillable = ['currency_match_id','symbol','coinname','profit_rate','fee_rate','min_amount','max_amount','status','weigh'];
}
@@ -0,0 +1,79 @@
@extends('admin._layoutNew')
@section('title', '期权订单管理')
@section('page-content')
<div class="layui-card">
<div class="layui-card-header">期权订单管理</div>
<div class="layui-card-body">
<form class="layui-form" style="margin-bottom:10px;">
<div class="layui-inline">
<input id="f_keyword" class="layui-input" placeholder="用户ID/邮箱">
</div>
<div class="layui-inline">
<select id="f_status">
<option value="">全部状态</option>
<option value="0">待交割</option>
<option value="1">盈利</option>
<option value="2">亏损</option>
</select>
</div>
<div class="layui-inline">
<button type="button" class="layui-btn layui-btn-sm" onclick="searchList()">搜索</button>
</div>
</form>
<div class="layui-btn-group" style="margin-bottom:15px;">
<button class="layui-btn layui-btn-sm" onclick="loadData()"><i class="layui-icon">&#xe9aa;</i> 刷新</button>
<button class="layui-btn layui-btn-sm layui-btn-normal" onclick="settle('')"><i class="layui-icon">&#xe63c;</i> 自动结算</button>
<button class="layui-btn layui-btn-sm" style="background:#00c48c;" onclick="settle('win')"><i class="layui-icon">&#xe605;</i> 强制盈利</button>
<button class="layui-btn layui-btn-sm layui-btn-danger" onclick="settle('lose')"><i class="layui-icon">&#x1006;</i> 强制亏损</button>
</div>
<table id="optionTable" lay-filter="optionTable"></table>
</div>
</div>
@endsection
@section('scripts')
<script>
layui.use(['table','layer','form'], function(){
var table = layui.table;
window.loadData = function(){ table.reload('optionTable', {where:{keyword:$('#f_keyword').val(), status:$('#f_status').val()}}); };
window.searchList = loadData;
table.render({
elem:'#optionTable', id:'optionTable', url:'/admin/option/order/list', page:true,
cols:[[
{type:'checkbox'},
{field:'id',title:'ID',width:60},
{field:'user_id',title:'用户ID',width:70},
{field:'user_email',title:'邮箱',width:170},
{field:'coinname',title:'交易对',width:100},
{field:'direction',title:'方向',width:80,templet:function(d){
return d.direction=='up'?'<span style="color:#00c48c;font-weight:600">买涨</span>':'<span style="color:#ff4040;font-weight:600">买跌</span>';
}},
{field:'amount',title:'金额',width:100},
{field:'open_price',title:'开仓价',width:110},
{field:'close_price',title:'平仓价',width:110},
{field:'profit_rate',title:'收益率%',width:80},
{field:'income',title:'盈亏',width:100},
{field:'status_text',title:'状态',width:90,templet:function(d){
var cls=d.status==1?'layui-bg-green':(d.status==2?'':'layui-bg-orange');
return '<span class="layui-badge '+cls+'">'+d.status_text+'</span>';
}},
{field:'delivery_time',title:'交割时间',width:160},
{field:'created_at',title:'下单时间',width:160}
]]
});
window.settle = function(force){
var checked = table.checkStatus('optionTable').data;
if(!checked.length) return layer.msg('请先选择订单');
var ids = checked.map(function(d){return d.id}).join(',');
var msg = force==='win'?'强制盈利':(force==='lose'?'强制亏损':'自动结算');
layer.confirm('确定对选中的 '+checked.length+' 个订单执行"'+msg+'"', function(ci){
layer.close(ci);
$.post('/admin/option/order/settle',{ids:ids,force:force,_token:'{{csrf_token()}}'},function(r){
if(r.type=='ok'){layer.msg(r.message);loadData();}else layer.msg(r.message,{icon:2});
});
});
};
});
</script>
@endsection