|
|
@@ -5,6 +5,7 @@ namespace App\Services;
|
|
|
use App\Consts\ErrorConst;
|
|
|
use App\Facade\Site;
|
|
|
use App\Libs\Utils;
|
|
|
+use App\Models\MpGeneratePicTask;
|
|
|
use App\Models\MpGenerateVideoTask;
|
|
|
use App\Models\MpUserPointsDetail;
|
|
|
use Illuminate\Support\Facades\DB;
|
|
|
@@ -318,7 +319,7 @@ class PointsService
|
|
|
/**
|
|
|
* 视频生成成功后记录计费明细并扣减用户积分
|
|
|
*
|
|
|
- * 幂等处理:同一任务只允许计费一次(task_id 唯一索引兜底)。
|
|
|
+ * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
|
|
|
*
|
|
|
* @param MpGenerateVideoTask $task
|
|
|
* @return array
|
|
|
@@ -326,7 +327,10 @@ class PointsService
|
|
|
public function recordVideoTaskCharge(MpGenerateVideoTask $task): array
|
|
|
{
|
|
|
// 重复计费保护
|
|
|
- $exists = DB::table('mp_user_points_details')->where('task_id', $task->id)->exists();
|
|
|
+ $exists = DB::table('mp_user_points_details')
|
|
|
+ ->where('type', MpUserPointsDetail::TYPE_VIDEO)
|
|
|
+ ->where('task_id', $task->id)
|
|
|
+ ->exists();
|
|
|
if ($exists) {
|
|
|
return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
|
|
|
}
|
|
|
@@ -352,14 +356,183 @@ class PointsService
|
|
|
$points = (float)$this->getVideoChargePoints($chargeInfo);
|
|
|
$tokens = $this->getVideoTokensConsumed($task);
|
|
|
|
|
|
+ $result = $this->deductAndRecord(
|
|
|
+ $uid,
|
|
|
+ $task->id,
|
|
|
+ MpUserPointsDetail::TYPE_VIDEO,
|
|
|
+ (string)getProp($task, 'api_type', ''),
|
|
|
+ $points,
|
|
|
+ $tokens,
|
|
|
+ $chargeInfo,
|
|
|
+ '视频生成成功计费'
|
|
|
+ );
|
|
|
+
|
|
|
+ // 自动时长被实际时长覆盖时,计费成功后同步回填任务表的 charge_info
|
|
|
+ if ($durationBackfilled && !empty($result['charged'])) {
|
|
|
+ $task->update(['charge_info' => $chargeInfo]);
|
|
|
+ }
|
|
|
+
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取图片生成单张应扣积分数
|
|
|
+ *
|
|
|
+ * 从 mp_image_models 表读取(charge_type=per_image),按分辨率档位(1k/2k/4k)取单张积分。
|
|
|
+ *
|
|
|
+ * @param array $chargeInfo 计费信息(model、resolution、width、height 等)
|
|
|
+ * @return int
|
|
|
+ */
|
|
|
+ public function getImageChargePoints(array $chargeInfo = []): int
|
|
|
+ {
|
|
|
+ $model = (string)getProp($chargeInfo, 'model', '');
|
|
|
+ $resolution = strtolower((string)getProp($chargeInfo, 'resolution', '2k'));
|
|
|
+
|
|
|
+ $modelRow = DB::table('mp_image_models')->where('model', $model)->first();
|
|
|
+ if (!$modelRow || ($modelRow->charge_type ?? '') !== 'per_image') {
|
|
|
+ return self::DEFAULT_VIDEO_CHARGE_POINTS;
|
|
|
+ }
|
|
|
+
|
|
|
+ $priceJson = $modelRow->price_json;
|
|
|
+ $prices = is_string($priceJson) ? json_decode($priceJson, true) : $priceJson;
|
|
|
+ if (!is_array($prices) || empty($prices)) {
|
|
|
+ return self::DEFAULT_VIDEO_CHARGE_POINTS;
|
|
|
+ }
|
|
|
+
|
|
|
+ $price = $prices[$resolution] ?? null;
|
|
|
+ if ($price === null || (float)$price <= 0) {
|
|
|
+ return self::DEFAULT_VIDEO_CHARGE_POINTS;
|
|
|
+ }
|
|
|
+
|
|
|
+ return (int)max(1, round((float)$price));
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 根据图片宽高归一化分辨率档位(1k/2k/4k)
|
|
|
+ *
|
|
|
+ * 2k 常见尺寸:2048x2048、1600x2848、2560x1440 等;
|
|
|
+ * 4k:4096x4096、4992x3328、3040x5504 等;其余更小尺寸归为 1k。
|
|
|
+ *
|
|
|
+ * @param int $width
|
|
|
+ * @param int $height
|
|
|
+ * @return string
|
|
|
+ */
|
|
|
+ public function normalizeImageResolutionKey(int $width, int $height): string
|
|
|
+ {
|
|
|
+ if ($width <= 0 || $height <= 0) {
|
|
|
+ return '2k';
|
|
|
+ }
|
|
|
+ $maxDim = max($width, $height);
|
|
|
+ $area = $width * $height;
|
|
|
+ if ($maxDim > 4096 || $area >= 4096 * 4096) {
|
|
|
+ return '4k';
|
|
|
+ }
|
|
|
+ if ($maxDim > 1536) {
|
|
|
+ return '2k';
|
|
|
+ }
|
|
|
+ return '1k';
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取图片生成任务实际消耗的 token 量
|
|
|
+ *
|
|
|
+ * 图片接口返回格式:result_json.usage.total_tokens / output_tokens
|
|
|
+ *
|
|
|
+ * @param MpGeneratePicTask $task
|
|
|
+ * @return int
|
|
|
+ */
|
|
|
+ public function getImageTokensConsumed(MpGeneratePicTask $task): int
|
|
|
+ {
|
|
|
+ $resultJson = $task->result_json;
|
|
|
+ if (is_string($resultJson)) {
|
|
|
+ $resultJson = json_decode($resultJson, true);
|
|
|
+ }
|
|
|
+ if (!is_array($resultJson)) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ $tokens = $resultJson['usage']['total_tokens'] ?? $resultJson['usage']['output_tokens'] ?? 0;
|
|
|
+ return (int)$tokens;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 图片生成成功后记录计费明细并扣减用户积分
|
|
|
+ *
|
|
|
+ * 幂等处理:同一任务只允许计费一次(type + task_id 唯一索引兜底)。
|
|
|
+ *
|
|
|
+ * @param MpGeneratePicTask $task
|
|
|
+ * @return array
|
|
|
+ */
|
|
|
+ public function recordImageTaskCharge(MpGeneratePicTask $task): array
|
|
|
+ {
|
|
|
+ // 重复计费保护
|
|
|
+ $exists = DB::table('mp_user_points_details')
|
|
|
+ ->where('type', MpUserPointsDetail::TYPE_IMAGE)
|
|
|
+ ->where('task_id', $task->id)
|
|
|
+ ->exists();
|
|
|
+ if ($exists) {
|
|
|
+ return ['charged' => false, 'reason' => 'already_charged', 'task_id' => $task->id];
|
|
|
+ }
|
|
|
+
|
|
|
+ $chargeInfo = $task->charge_info;
|
|
|
+ if (is_string($chargeInfo)) {
|
|
|
+ $chargeInfo = json_decode($chargeInfo, true);
|
|
|
+ }
|
|
|
+ if (!is_array($chargeInfo) || empty($chargeInfo['user_id'])) {
|
|
|
+ dLog('points')->warning('图片任务缺少计费信息,跳过扣费', ['task_id' => $task->id]);
|
|
|
+ return ['charged' => false, 'reason' => 'no_charge_info', 'task_id' => $task->id];
|
|
|
+ }
|
|
|
+
|
|
|
+ $uid = (int)$chargeInfo['user_id'];
|
|
|
+
|
|
|
+ // 积分 = 单张价格 × 实际生成图片数
|
|
|
+ $pointsPerImage = (float)$this->getImageChargePoints($chargeInfo);
|
|
|
+ $imageCount = is_array($task->result_url) ? count($task->result_url) : 0;
|
|
|
+ if ($imageCount <= 0) {
|
|
|
+ $imageCount = (int)($chargeInfo['image_num'] ?? 1);
|
|
|
+ }
|
|
|
+ if ($imageCount <= 0) {
|
|
|
+ $imageCount = 1;
|
|
|
+ }
|
|
|
+ // 单张价格 × 实际生成图片数(未配置价格时为0,仍记录明细与token)
|
|
|
+ $points = (float)round($pointsPerImage * $imageCount);
|
|
|
+ $tokens = $this->getImageTokensConsumed($task);
|
|
|
+
|
|
|
+ return $this->deductAndRecord(
|
|
|
+ $uid,
|
|
|
+ $task->id,
|
|
|
+ MpUserPointsDetail::TYPE_IMAGE,
|
|
|
+ (string)getProp($task, 'model', ''),
|
|
|
+ $points,
|
|
|
+ $tokens,
|
|
|
+ $chargeInfo,
|
|
|
+ '图片生成成功计费'
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 扣减积分并记录积分使用明细(视频/图片共用)
|
|
|
+ *
|
|
|
+ * @param int $uid
|
|
|
+ * @param int $taskId
|
|
|
+ * @param string $type
|
|
|
+ * @param string $apiType
|
|
|
+ * @param float $points
|
|
|
+ * @param int $tokens
|
|
|
+ * @param array $chargeInfo
|
|
|
+ * @param string $remark
|
|
|
+ * @return array
|
|
|
+ */
|
|
|
+ private function deductAndRecord(int $uid, int $taskId, string $type, string $apiType, float $points, int $tokens, array $chargeInfo, string $remark): array
|
|
|
+ {
|
|
|
try {
|
|
|
DB::beginTransaction();
|
|
|
|
|
|
$user = DB::table('mp_manage_users')->where('id', $uid)->first();
|
|
|
if (!$user) {
|
|
|
DB::rollBack();
|
|
|
- dLog('points')->error('扣费失败:用户不存在', ['task_id' => $task->id, 'uid' => $uid]);
|
|
|
- return ['charged' => false, 'reason' => 'user_not_found', 'task_id' => $task->id];
|
|
|
+ dLog('points')->error('扣费失败:用户不存在', ['task_id' => $taskId, 'uid' => $uid]);
|
|
|
+ return ['charged' => false, 'reason' => 'user_not_found', 'task_id' => $taskId];
|
|
|
}
|
|
|
|
|
|
$pointsBefore = (float)getProp($user, 'points', 0);
|
|
|
@@ -367,7 +540,7 @@ class PointsService
|
|
|
|
|
|
if ($pointsBefore < $points) {
|
|
|
dLog('points')->warning('用户积分不足,扣费后积分为负数', [
|
|
|
- 'task_id' => $task->id,
|
|
|
+ 'task_id' => $taskId,
|
|
|
'uid' => $uid,
|
|
|
'points_before' => $pointsBefore,
|
|
|
'points_consumed' => $points
|
|
|
@@ -380,31 +553,26 @@ class PointsService
|
|
|
'updated_at' => date('Y-m-d H:i:s')
|
|
|
]);
|
|
|
|
|
|
- // 自动时长被实际时长覆盖时,同步回填任务表的 charge_info
|
|
|
- if ($durationBackfilled) {
|
|
|
- $task->update(['charge_info' => $chargeInfo]);
|
|
|
- }
|
|
|
-
|
|
|
// 记录积分使用明细
|
|
|
DB::table('mp_user_points_details')->insert([
|
|
|
'uid' => $uid,
|
|
|
- 'task_id' => $task->id,
|
|
|
- 'type' => MpUserPointsDetail::TYPE_VIDEO,
|
|
|
- 'api_type' => getProp($task, 'api_type', ''),
|
|
|
+ 'task_id' => $taskId,
|
|
|
+ 'type' => $type,
|
|
|
+ 'api_type' => $apiType,
|
|
|
'charge_info' => json_encode($chargeInfo, JSON_UNESCAPED_UNICODE),
|
|
|
'points_consumed' => $points,
|
|
|
'points_before' => $pointsBefore,
|
|
|
'points_after' => $pointsAfter,
|
|
|
'tokens_consumed' => $tokens,
|
|
|
- 'remark' => '视频生成成功计费',
|
|
|
+ 'remark' => $remark,
|
|
|
'created_at' => date('Y-m-d H:i:s'),
|
|
|
'updated_at' => date('Y-m-d H:i:s')
|
|
|
]);
|
|
|
|
|
|
DB::commit();
|
|
|
|
|
|
- dLog('points')->info('视频生成计费成功', [
|
|
|
- 'task_id' => $task->id,
|
|
|
+ dLog('points')->info($type . '计费成功', [
|
|
|
+ 'task_id' => $taskId,
|
|
|
'uid' => $uid,
|
|
|
'points_consumed' => $points,
|
|
|
'points_after' => $pointsAfter,
|
|
|
@@ -413,7 +581,7 @@ class PointsService
|
|
|
|
|
|
return [
|
|
|
'charged' => true,
|
|
|
- 'task_id' => $task->id,
|
|
|
+ 'task_id' => $taskId,
|
|
|
'uid' => $uid,
|
|
|
'points_consumed' => $points,
|
|
|
'points_before' => $pointsBefore,
|
|
|
@@ -423,12 +591,12 @@ class PointsService
|
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
DB::rollBack();
|
|
|
- dLog('points')->error('视频生成计费失败: ' . $e->getMessage(), ['task_id' => $task->id]);
|
|
|
- logDB('points', 'error', '视频生成计费失败', [
|
|
|
- 'task_id' => $task->id,
|
|
|
+ dLog('points')->error($type . '计费失败: ' . $e->getMessage(), ['task_id' => $taskId]);
|
|
|
+ logDB('points', 'error', $type . '计费失败', [
|
|
|
+ 'task_id' => $taskId,
|
|
|
'error' => $e->getMessage()
|
|
|
]);
|
|
|
- return ['charged' => false, 'reason' => 'exception: ' . $e->getMessage(), 'task_id' => $task->id];
|
|
|
+ return ['charged' => false, 'reason' => 'exception: ' . $e->getMessage(), 'task_id' => $taskId];
|
|
|
}
|
|
|
}
|
|
|
}
|