业务表解析-余额系统

业务表解析-余额系统

业务要求
  1. 有个地方可以查看用户的 可用余额 与 冻结余额
  2. 还有个地方可以查看用户余额(可用余额 + 冻结余额)变动的明细
  3. 后台可以查看用户余额变动明细,可通过类型,变更类型,甚至备注去匹配记录
业务例子
  1. 有那么一种关系,下级购物,上级可以获得佣金
  2. 当下级购物并且付款后,上级立马获得冻结余额。具体实现就是给上级创建一条增加冻结余额的记录,然后增加上级的冻结余额
  3. 当下级确认收货,并且订单得到结算后,就需要将冻结余额转移到可用余额中。具体实现就是给上级创建一条减少冻结余额的记录,然后上级减少冻结余额。接着,给上级创建一条添加可用余额的记录,然后增加上级的可用余额
表结构设计(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='用户余额流水表';

字段解释

  1. users.balance:可用余额,用来统计可以使用的余额,≥ 0
  2. users.frozen_balance:冻结余额,用来统计冻结的余额,≥ 0
  3. user_balance_records.status:入账状态,1=可用余额,2=冻结余额
  4. user_balance_records.targetable_type 与 user_balance_records.targetable_type_id, 对应引起余额变化的目标模型。例子:下单使用了余额,这里就是订单模型 与 订单ID了
  5. user_balance_records.type:类型,必须要足够精确。使用余额下单是一种类型,订单取消后,退还也是一种类型,使用余额发红包是一种,领取别人的红包是一种,因未领取完而回退的红包金额也是一种类型。这里,可能有人会问,既然已经有了目标模型,难道还无法确认什么类型吗?可以,但是不直观。例子:如果开发人员把充值与退款所消耗的余额都记录到了一条记录上(暂不考虑设计合理与否),此时仅由记录(targetable_type + targetable_type_id) 来识别的话,我们是无法一眼就看出这条记录是因为充值还是退款引起了,后期做搜索也不好做
  6. user_balance_records.change_action:值的变更类型,正数就是 增加 ,负数就是减少,冗余做筛选用的
  7. user_balance_records.change_value:变更值,负数时带符号
  8. user_balance_records.old_value:旧值,也就是变更前的 users.balance 或者 users.frozen_balance
  9. user_balance_records.new_value:新值,也就是变更后的 users.balance 或者 users.frozen_balance
  10. user_balance_records.comment:备注
  11. user_balance_records.finance_comment 财务备注,部分公司需要
程序思考
  1. 从表设计中可以发现,每一次余额的变动,都会修改 user_balance_records 与 users 表,所需存储记录时,必须加上事务
  2. 为了余额的准确性,必须先获得锁后,才能进行余额的相关操作

以此为界限,下面是 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()
代码说明
  1. 读者可能会很好奇,代码里面怎么看不到启用事务代码的?这是因为changeBalancechangeFrozenBalance方法的实现,是个相对底层的代码。调用这个方法的地方,往往还会有一些其他的数据库操作的,并且会在那里启用事务。所以这里不需要启用事务,这样子还可以减少事物嵌套了
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值