当前位置:网站首页>业务表解析-余额系统
业务表解析-余额系统
2022-08-03 05:07:00 【DBCai】
业务表解析-余额系统
业务要求
- 有个地方可以查看用户的 可用余额 与 冻结余额
- 还有个地方可以查看用户余额(可用余额 + 冻结余额)变动的明细
- 后台可以查看用户余额变动明细,可通过类型,变更类型,甚至备注去匹配记录
业务例子
- 有那么一种关系,下级购物,上级可以获得佣金
- 当下级购物并且付款后,上级立马获得冻结余额。具体实现就是给上级创建一条增加冻结余额的记录,然后增加上级的冻结余额
- 当下级确认收货,并且订单得到结算后,就需要将冻结余额转移到可用余额中。具体实现就是给上级创建一条减少冻结余额的记录,然后上级减少冻结余额。接着,给上级创建一条添加可用余额的记录,然后增加上级的可用余额
表结构设计(mysql)
//可用余额 与 冻结余额
CREATE TABLE `users` (
`id` bigint(20) unsigned NOT NULL AUTO_INCREMENT,
`balance` decimal(12,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '可用余额',
`frozen_balance` decimal(12,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '冻结余额',
PRIMARY KEY (`id`) USING BTREE,
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
//余额记录表
CREATE TABLE `user_balance_records` (
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
`user_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '用户ID',
`status` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '余额状态 1=可用余额 2=冻结余额',
`targetable_type` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '目标类型',
`targetable_id` bigint(20) unsigned NOT NULL DEFAULT '0' COMMENT '目标模型ID',
`type` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '类型',
`change_action` tinyint(3) unsigned NOT NULL DEFAULT '1' COMMENT '变更类型 1=增加 2=减少',
`change_value` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '变更值',
`old_value` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '旧值',
`new_value` decimal(12,2) NOT NULL DEFAULT '0.00' COMMENT '新值',
`comment` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '备注',
`finance_comment` varchar(191) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '财务备注',
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `user_balance_records_user_id_index` (`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户余额流水表';
字段解释
- users.balance:可用余额,用来统计可以使用的余额,≥ 0
- users.frozen_balance:冻结余额,用来统计冻结的余额,≥ 0
- user_balance_records.status:入账状态,1=可用余额,2=冻结余额
- user_balance_records.targetable_type 与 user_balance_records.targetable_type_id, 对应引起余额变化的目标模型。例子:下单使用了余额,这里就是订单模型 与 订单ID了
- user_balance_records.type:类型,必须要足够精确。使用余额下单是一种类型,订单取消后,退还也是一种类型,使用余额发红包是一种,领取别人的红包是一种,因未领取完而回退的红包金额也是一种类型。这里,可能有人会问,既然已经有了目标模型,难道还无法确认什么类型吗?可以,但是不直观。例子:如果开发人员把充值与退款所消耗的余额都记录到了一条记录上(暂不考虑设计合理与否),此时仅由记录(targetable_type + targetable_type_id) 来识别的话,我们是无法一眼就看出这条记录是因为充值还是退款引起了,后期做搜索也不好做
- user_balance_records.change_action:值的变更类型,正数就是 增加 ,负数就是减少,冗余做筛选用的
- user_balance_records.change_value:变更值,负数时带符号
- user_balance_records.old_value:旧值,也就是变更前的 users.balance 或者 users.frozen_balance
- user_balance_records.new_value:新值,也就是变更后的 users.balance 或者 users.frozen_balance
- user_balance_records.comment:备注
- user_balance_records.finance_comment 财务备注,部分公司需要
程序思考
- 从表设计中可以发现,每一次余额的变动,都会修改 user_balance_records 与 users 表,所需存储记录时,必须加上事务
- 为了余额的准确性,必须先获得锁后,才能进行余额的相关操作
以此为界限,下面是 laravel 中的具体实现
数据表迁移文件
//给用户表添加余额字段的 up 方法(原本就存在了users 表,所有里面没有ID)
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->unsignedDecimal('balance', 12)->default('0.00')->comment('余额');
$table->unsignedDecimal('frozen_balance', 12)->default('0.00')->comment('冻结余额');
});
}
//创建余额记录表的 up 方法
public function up()
{
Schema::create('user_balance_records', function (Blueprint $table) {
$table->increments('id');
$table->unsignedBigInteger('user_id')->default('0')->comment('用户ID')->index();
$table->unsignedTinyInteger('status')->default('1')->comment('入账状态 1=可用余额,2=冻结余额');
$table->string('targetable_type')->default('')->comment('目标类型');
$table->unsignedBigInteger('targetable_id')->default('0')->comment('目标模型ID');
$table->unsignedTinyInteger('type')->default('1')->comment('类型');
$table->unsignedTinyInteger('change_action')->default('1')->comment('变更类型 1=增加 2=减少');
$table->decimal('change_value', 12)->default('0.00')->comment('变更值');
$table->decimal('old_value', 12)->default('0.00')->comment('旧值');
$table->decimal('new_value', 12)->default('0.00')->comment('新值');
$table->string('comment')->default('')->comment('备注');
$table->string('finance_comment')->default('')->comment('财务备注');
$table->timestamps();
$table->comment('用户余额流水表');
});
}
UserBalanceRecord 模型类
<?php
namespace App\Models\User;
use App\Models\User;
use Dcat\Admin\Traits\HasDateTimeFormatter;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
/** * App\Models\User\UserBalanceRecord * * @property int $id * @property int $user_id 用户ID * @property int $status 入账状态 1=可用余额,2=冻结余额 * @property string $targetable_type 目标类型 * @property int $targetable_id 目标模型ID * @property int $type 类型 * @property int $change_action 变更类型 1=增加 2=减少 * @property string $change_value 变更值 * @property string $old_value 旧金额 * @property string $new_value 新金额 * @property string $comment 备注 * @property string $finance_comment 财务备注 * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property User|null $user */
class UserBalanceRecord extends Model
{
use HasDateTimeFormatter;
protected $table = 'user_balance_records';
protected $guarded = [];
//改变类型
public const CHANGE_TYPE_INC = 1;
public const CHANGE_TYPE_DEC = 2;
public const CHANGE_TYPE_MAP = [
self::CHANGE_TYPE_INC => '增加',
self::CHANGE_TYPE_DEC => '减少'
];
//入账状态 1=可用余额,2=冻结余额
public const STATUS_ALREADY = 1;
public const STATUS_WAIT = 2;
public const STATUS_MAP = [
self::STATUS_ALREADY => '可用余额',
self::STATUS_WAIT => '冻结余额'
];
/** * 类型:根据改变余额的类型,自行添加 */
public const TYPE_RED_ENVELOPE_SEND = 1;
public const TYPE_RED_ENVELOPE_RECEIVE = 2;
public const TYPE_MAP = [
self::TYPE_RED_ENVELOPE_SEND => '发红包',
self::TYPE_RED_ENVELOPE_RECEIVE => '领红包',
];
public function targetable()
{
return $this->morphTo();
}
public function user()
{
return $this->belongsTo(User::class);
}
}
UserBalanceRecordService 类
<?php
namespace App\Services\User;
use App\Constants\CacheKey;
use App\Models\User;
use App\Models\User\UserBalanceRecord;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Cache;
class UserBalanceRecordService
{
/** * 修改余额 * * @param Model $model * @param int $userId * @param float $changeValue * @param int $type 看模型 * @param string $comment * * @return bool */
public static function changeBalance(
Model $model,
int $userId,
float $changeValue,
int $type,
string $comment = ''
): bool {
$lock = Cache::lock(CacheKey::LOCK . 'user-balance:' . $userId, 10);
try {
// 为获得锁等待最多 5 秒...
$lock->block(5);
$isNaturalNumber = bccomp((string)$changeValue, '0.00', 2) !== -1;
$changRow = User::query()
->where('id', $userId)
->when(!$isNaturalNumber, function ($query) use ($changeValue) {
$query->where('balance', '>=', abs($changeValue));
})
->increment('balance', $changeValue);
if ($changRow === 0) {
return false;
}
$newValue = User::query()->where('id', $userId)->value('balance') ?? '0.00';
//添加记录
$record = new UserBalanceRecord();
$record->user_id = $userId;
$record->type = $type;
$record->change_value = $changeValue;
$record->old_value = bcsub($newValue, (string)$changeValue, 2);
$record->new_value = $newValue;
$record->change_action = $isNaturalNumber ? UserBalanceRecord::CHANGE_TYPE_INC : UserBalanceRecord::CHANGE_TYPE_DEC;
$record->comment = $comment;
$record->status = UserBalanceRecord::STATUS_ALREADY;
$record->targetable()->associate($model);
return $record->save();
} catch (\Exception $e) {
return false;
}
finally {
optional($lock)->release();
}
}
/** * 修改冻结余额 * * @param Model $model * @param int $userId * @param float $changeValue * @param int $type 看模型 * @param string $comment * * @return bool */
public static function changeFrozenBalance(
Model $model,
int $userId,
float $changeValue,
int $type,
string $comment = ''
): bool {
$lock = Cache::lock(CacheKey::LOCK . 'user-frozen-balance:' . $userId, 10);
try {
// 为获得锁等待最多 5 秒...
$lock->block(5);
$isNaturalNumber = bccomp((string)$changeValue, '0.00', 2) !== -1;
$changRow = User::query()
->where('id', $userId)
->when(!$isNaturalNumber, function ($query) use ($changeValue) {
$query->where('frozen_balance', '>=', abs($changeValue));
})
->increment('frozen_balance', $changeValue);
if ($changRow === 0) {
return false;
}
$newValue = User::query()->where('id', $userId)->value('frozen_balance') ?? '0.00';
//添加记录
$record = new UserBalanceRecord();
$record->user_id = $userId;
$record->type = $type;
$record->change_value = $changeValue;
$record->old_value = bcsub($newValue, (string)$changeValue, 2);
$record->new_value = $newValue;
$record->change_action = $isNaturalNumber ? UserBalanceRecord::CHANGE_TYPE_INC : UserBalanceRecord::CHANGE_TYPE_DEC;
$record->comment = $comment;
$record->status = UserBalanceRecord::STATUS_WAIT;
$record->targetable()->associate($model);
return $record->save();
} catch (\Exception $e) {
return false;
}
finally {
optional($lock)->release();
}
}
}
//CacheKey::LOCK 为一个常量,这里的定义的值是 “lock:”
具体使用
//记得传自己的参数哈
UserBalanceRecordService::changeBalance()
UserBalanceRecordService::changeFrozenBalance()
代码说明
- 读者可能会很好奇,代码里面怎么看不到启用事务代码的?这是因为
changeBalance
与changeFrozenBalance
方法的实现,是个相对底层的代码。调用这个方法的地方,往往还会有一些其他的数据库操作的,并且会在那里启用事务。所以这里不需要启用事务,这样子还可以减少事物嵌套了
边栏推荐
- 移动流量的爆发式增长,社交电商如何选择商业模式
- JS底层手写
- typescript47-函数之间的类型兼容性
- Interface testing framework combat (3) | JSON request and response assertion
- Common fluorescent dyes to modify a variety of groups and its excitation and emission wavelength data in the data
- MySQL 删除表数据,重置自增 id 为 0 的两个方式
- js中的闭包
- 接口测试框架实战(三)| JSON 请求与响应断言
- Unity2D horizontal board game tutorial 6 - enemy AI and attack animation
- Concepts and Methods of Exploratory Testing
猜你喜欢
随机推荐
MCM箱模型建模方法及大气O3来源解析
typescript47-函数之间的类型兼容性
普乐蛙VR台风体验馆厂家VR防震减灾模拟VR沉浸式体验设备
Shell之条件语句
How to use the interface management tool YApi?Beautiful, easy to manage, super easy to use
超好用的画图工具推荐
多肽介导PEG磷脂——靶向功能材料之DSPE-PEG-RGD/TAT/NGR/APRPG
私域流量时代来临,电商企业如何布局?
Detailed explanation of MOSN reverse channel
presto安装部署教程
typescript41-class类的私有修饰符
数字化时代,企业如何建立自身的云平台与商业模式的选择?
打破传统电商格局,新型社交电商到底有什么优点?
OSI的分层特点、传输过程与三次握手、四次挥手、tcp与udp包头的描述
Technology Sharing | How to do assertion verification for xml format in interface automation testing?
自组织是管理者和成员的双向奔赴
Interface testing framework combat (3) | JSON request and response assertion
How to prepare for the test interface test data
idea使用@Autowired注解爆红原因及解决方法
typescript46-函数之间的类型兼容性