5分钟读完一整个项目,实现2个新增需求,AI牛逼
2026-04-08
下面我按你要求的结构来,并且只围绕你这次明确范围:先做需求 1 和 4;以后端接口为主;尽量不动现有系统和旧表;采用“新增 API 文件 + 新表”的外挂式方案。
你给的需求文档里,当前明确要做的是:
1)编辑录播课程时,配置“观看视频过程中弹出验证码输入框”
4)数据模块增加“学员学习统计”,按年度、按个人学习时长、按部门(如果有)
同时,我已经结合你给的代码包看过现有实现,当前系统是一个 Laravel 8 / PHP 7.3+ / MySQL 的 MeEdu 二开项目,现有学习链路核心是:
前台学习进度上报:
/api/v2/video/{id}/record写学习进度:
App\Bus\VideoBus::userVideoWatchDurationRecord写用户视频观看记录:
App\Services\Member\Services\UserService::recordUserVideoWatch写用户每日学习秒数:
App\Services\Member\Services\UserService::watchStatSave后台现有统计:
Backend\Api\V2\StatsController里只有交易和用户统计后台现有学员详情:
Backend\Api\V2\MemberController里能看课程、课时、进度,但没有年度学习统计,也没有验证码配置能力
第一部分:需求理解
1.1 我对当前系统逻辑的理解
1)当前录播课学习链路
现有系统里,用户看录播视频时,会调用:
POST /api/v2/video/{id}/record
这个接口只做两件事:
校验用户是否有权限看该视频
把前端传来的
duration记入学习记录
其后续逻辑是:
VideoBus::userVideoWatchDurationRecord($userId, $videoId, $duration)查视频时长
判断是否看完
更新
user_video_watch_records更新
user_watch_stat(按天累计学习秒数)
也就是说,现有系统只支持“学习时长上报”,不支持“播放过程中弹验证码校验”。
2)当前后台统计链路
后台 V2 统计接口目前只有:
交易统计
用户注册统计
用户付费统计
没有:
年度学习时长统计
个人学习时长排行
部门维度学习统计
3)当前课程/视频配置模型
现有 courses、videos 表里没有任何“学习验证码”相关字段。
所以如果你要求:
不改旧表
不大动旧逻辑
那这次最合适的方式就是:
外挂一套新表 + 新接口,按 course_id / video_id 关联旧数据。
1.2 我对新需求的理解
需求 1:录播课验证码弹窗
你的意思不是改播放器本身,而是给后端补一套“验证码校验配置 + 校验流程接口”,让前端可以这样接:
后台给某个录播课程配置“等分弹窗次数”
前端播放到某个时点时,调用“是否需要弹验证码”接口
如果需要,后端返回本次验证码
前端弹窗输入
前端把用户输入提交到后端校验
校验通过后继续播放
需求 4:学习统计
你的目标不是交易统计,而是新增一个“学员学习统计”模块,至少要有:
按年度汇总学习秒数
按个人汇总学习时长
如果系统里有部门数据,就支持部门维度展示
1.3 这次改造的核心变化
这次改造的核心,不是改旧表,而是:
核心变化 A:在旧课程体系外,新增“录播验证码配置层”
用新表记录“某课程是否开启验证码”
用新表记录“某用户某视频某次弹窗是否已通过”
核心变化 B:在旧学习记录体系外,新增“学习统计 API 层”
复用现有
user_watch_stat新增后台统计接口
不碰旧统计控制器和旧页面接口
第二部分:影响面分析
2.1 数据库层
需求 1 会影响
必须新增至少 2 张表:
表 1:课程验证码配置表
用途:
记录某课程是否开启验证码
记录触发方式
记录等分次数
表 2:验证码校验记录表
用途:
记录某用户某视频某个触发点是否已校验
避免重复弹窗
保留审计日志
需求 4 会影响
严格按你现在的范围,可以不新增表,直接复用现有:
user_watch_statuser_video_watch_recordsusersvideoscourses
但有一个前提:
部门维度目前待确认。
因为现有 users 表里没有 department 字段。
所以“按部门统计”这件事,如果你坚持不改旧表,那就只能:
先返回
department = null或者后续再单独补一张用户组织扩展表
这次如果只做 1 和 4,我建议:
本批先不强行上部门扩展表
统计接口预留
department_name字段,当前返回空
2.2 Model 层
需求 1 需要新增
VodWatchCaptchaConfigVodWatchCaptchaLog
需求 4 可新增一个纯查询型服务,不一定新增 Model
如果你想代码更整洁,可以加一个:
StudyStatsQueryService
2.3 Service 层
需求 1 需要新增服务
建议新增:
VodWatchCaptchaService
职责:
读取课程验证码配置
按视频时长计算触发点
判断当前播放进度是否该弹窗
生成验证码
校验验证码
记录通过状态
需求 4 需要新增服务
建议新增:
StudyStatsService
职责:
年度学习总览
个人学习排行
个人年度学习明细
部门维度聚合(当前可先空实现/兼容)
2.4 Controller / API 层
需求 1 需要新增 3 类接口
后台接口
给管理端配置课程验证码规则:
查询课程验证码配置
保存课程验证码配置
前台接口
给播放器用:
查询当前是否需要弹窗
提交验证码校验
需求 4 需要新增后台统计接口
建议新增:
年度统计总览
年度个人排行
个人年度明细
2.5 日志层
需求 1
需要记录两类日志:
管理员配置日志
用现有administrator_logs学员验证码校验日志
用新增表vod_watch_captcha_logs
需求 4
后台查看统计时可继续写 administrator_logs
2.6 状态流转层
需求 1 的状态流转
建议:
PENDING:已生成验证码,待输入PASSED:用户输入正确,已通过EXPIRED:验证码过期FAILED:本次输入错误(可选)
最小方案里我建议只保留:
PENDINGPASSEDEXPIRED
错误次数可直接记 fail_count
2.7 历史数据兼容层
需求 1
完全兼容历史数据:
老课程默认无验证码配置
未配置时,接口直接返回
need_popup = 0
需求 4
完全兼容历史学习数据:
直接读取
user_watch_stat无需迁移旧数据
2.8 风险点与边界条件
这里有一个很关键的坑,我直接指出来:
风险 1:你现在“不改旧播放/旧上报逻辑”,那验证码无法后端强制拦截
因为现有学习上报还是走:
/api/v2/video/{id}/record
这个接口完全不知道“验证码是否已通过”。
所以如果你坚持:
不改旧接口
不改旧播放逻辑
那本次后端只能做到:
提供“该不该弹窗”和“校验验证码”的能力
但不能做到:
服务端强制阻断未校验用户继续上报学习进度
也就是说,这次方案是:
前端配合型强约束,后端外挂型能力补齐。
如果以后你要做到真正“后端硬拦截”,必须改:
video record或者
playinfo或者播放器鉴权链路
风险 2:现有学习秒数缓存键疑似未带 user_id
现有 VideoBus::userVideoWatchDurationRecord 里,缓存键使用的是:
USER_VIDEO_WATCH_DURATION['name']传入的是
video['id']
从代码看,缓存键只带了 video_id,没有 user_id。
这意味着多用户并发学习同一个视频时,理论上存在“学习秒数增量互相污染”的风险。
这不是这次需求引入的问题,是旧逻辑里本身就值得复核的风险点。
但因为你这次要求“不改旧系统”,所以我这里只能列为待确认风险。
第三部分:数据库改造方案
3.1 是否需要新表
必需新增
1)vod_watch_captcha_configs
课程验证码配置表
2)vod_watch_captcha_logs
用户验证码校验记录表
暂不新增
3)部门扩展表
本批不建议强上,因为你的范围只做 1 和 4,且你明确说尽量不动现有系统。
部门统计先做“字段预留 + 空值兼容”。
3.2 是否需要新增字段
不改旧表,不加旧字段。
3.3 是否需要新增索引
需要。
vod_watch_captcha_configs
unique(course_id)
vod_watch_captcha_logs
index(user_id, video_id, trigger_order, status)index(course_id, video_id)index(expired_at)
3.4 是否需要唯一约束
建议:
配置表
unique(course_id)
日志表
不建议强唯一约束死锁业务,建议用“查询最新一条 PENDING / PASSED”控制逻辑。
因为验证码可能过期重发,强唯一会麻烦。
3.5 是否需要数据迁移
不需要。
3.6 SQL
CREATE TABLE `vod_watch_captcha_configs` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `course_id` INT NOT NULL COMMENT '课程ID,对应courses.id', `is_enabled` TINYINT NOT NULL DEFAULT 0 COMMENT '是否开启:0否 1是', `trigger_type` VARCHAR(32) NOT NULL DEFAULT 'equal_parts' COMMENT '触发类型:equal_parts', `trigger_value` INT NOT NULL DEFAULT 3 COMMENT '等分弹窗次数', `code_length` TINYINT NOT NULL DEFAULT 4 COMMENT '验证码长度', `expire_seconds` INT NOT NULL DEFAULT 300 COMMENT '验证码有效期(秒)', `remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注', `created_by` INT NOT NULL DEFAULT 0 COMMENT '创建管理员ID', `updated_by` INT NOT NULL DEFAULT 0 COMMENT '更新管理员ID', `created_at` TIMESTAMP NULL DEFAULT NULL, `updated_at` TIMESTAMP NULL DEFAULT NULL, `deleted_at` TIMESTAMP NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_course_id` (`course_id`), KEY `idx_is_enabled` (`is_enabled`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='录播课程验证码配置表';
CREATE TABLE `vod_watch_captcha_logs` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `biz_no` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '业务流水号', `user_id` INT NOT NULL COMMENT '用户ID', `course_id` INT NOT NULL COMMENT '课程ID', `video_id` INT NOT NULL COMMENT '视频ID', `trigger_order` INT NOT NULL DEFAULT 0 COMMENT '第几次触发', `trigger_second` INT NOT NULL DEFAULT 0 COMMENT '触发秒数', `verify_code` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '验证码明文,当前方案用于前端显示后输入校验', `input_code` VARCHAR(16) NOT NULL DEFAULT '' COMMENT '用户输入值', `status` VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT 'PENDING/PASSED/EXPIRED', `fail_count` INT NOT NULL DEFAULT 0 COMMENT '失败次数', `passed_at` TIMESTAMP NULL DEFAULT NULL COMMENT '通过时间', `expired_at` TIMESTAMP NULL DEFAULT NULL COMMENT '过期时间', `created_at` TIMESTAMP NULL DEFAULT NULL, `updated_at` TIMESTAMP NULL DEFAULT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uk_biz_no` (`biz_no`), KEY `idx_user_video_trigger_status` (`user_id`, `video_id`, `trigger_order`, `status`), KEY `idx_course_video` (`course_id`, `video_id`), KEY `idx_expired_at` (`expired_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='录播观看验证码校验记录表';
第四部分:代码改造方案
4.1 哪些类要改
必需新增
路由
routes/backend-v3-custom.phproutes/frontend-v3-watch-captcha.php
Controller
App\Http\Controllers\Backend\Api\V3\VodWatchCaptchaControllerApp\Http\Controllers\Backend\Api\V3\StudyStatsControllerApp\Http\Controllers\Api\V3\VideoWatchCaptchaController
Model
App\Models\VodWatchCaptchaConfigApp\Models\VodWatchCaptchaLog
Service
App\Services\Custom\VodWatchCaptchaServiceApp\Services\Custom\StudyStatsService
Migration
两个 migration 文件
4.2 哪些旧类要改
只建议极小改 1 个旧文件
app/Providers/RouteServiceProvider.php
原因:
你要求“新增 API 文件”
Laravel 不把新路由文件映射进去,就不会生效
这个改动是唯一必要的旧文件改动,且不碰旧业务逻辑。
4.3 每个改动点的目的
需求 1
VodWatchCaptchaController
给后台管理端用,配置课程验证码规则。
VideoWatchCaptchaController
给前端播放器用,判断是否需要弹窗、提交验证码。
VodWatchCaptchaService
封装规则计算,避免控制器里堆业务。
需求 4
StudyStatsController
提供后台统计接口。
StudyStatsService
封装年度聚合查询,便于后续扩展部门维度。
第五部分:完整代码
下面给你可直接落地的代码。
我按“尽量新增,不改旧逻辑”写。
5.1 路由文件:routes/backend-v3-custom.php
<?php
use Illuminate\Support\Facades\Route;
Route::group([
'middleware' => ['auth:administrator'],
], function () {
Route::group(['prefix' => 'vod-watch-captcha'], function () {
Route::get('/course/{courseId}', 'VodWatchCaptchaController@detail');
Route::put('/course/{courseId}', 'VodWatchCaptchaController@save');
});
Route::group(['prefix' => 'study-stats'], function () {
Route::get('/overview', 'StudyStatsController@overview');
Route::get('/users', 'StudyStatsController@users');
Route::get('/users/{userId}', 'StudyStatsController@userDetail');
Route::get('/years', 'StudyStatsController@years');
});
});5.2 路由文件:routes/frontend-v3-watch-captcha.php
<?php
use Illuminate\Support\Facades\Route;
Route::group([
'middleware' => ['auth:apiv2'],
], function () {
Route::group(['prefix' => 'video'], function () {
Route::get('/{id}/watch-captcha/check', 'VideoWatchCaptchaController@check');
Route::post('/{id}/watch-captcha/verify', 'VideoWatchCaptchaController@verify');
});
});5.3 旧文件最小改动:app/Providers/RouteServiceProvider.php
在 map() 里追加:
$this->mapBackendApiV3CustomRoutes(); $this->mapFrontendV3WatchCaptchaRoutes();
新增两个方法:
protected function mapBackendApiV3CustomRoutes()
{
Route::prefix('/backend/api/v3')
->middleware(['api'])
->namespace($this->namespace . '\Backend\Api\V3')
->group(base_path('routes/backend-v3-custom.php'));
}
protected function mapFrontendV3WatchCaptchaRoutes()
{
Route::prefix('/api/v3')
->middleware('api')
->namespace($this->namespace . '\Api\V3')
->group(base_path('routes/frontend-v3-watch-captcha.php'));
}5.4 Migration:database/migrations/2026_04_08_000001_create_vod_watch_captcha_configs_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateVodWatchCaptchaConfigsTable extends Migration
{
public function up()
{
Schema::create('vod_watch_captcha_configs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->integer('course_id')->comment('课程ID,对应courses.id');
$table->tinyInteger('is_enabled')->default(0)->comment('是否开启:0否 1是');
$table->string('trigger_type', 32)->default('equal_parts')->comment('触发类型:equal_parts');
$table->integer('trigger_value')->default(3)->comment('等分弹窗次数');
$table->tinyInteger('code_length')->default(4)->comment('验证码长度');
$table->integer('expire_seconds')->default(300)->comment('验证码有效期(秒)');
$table->string('remark', 255)->default('')->comment('备注');
$table->integer('created_by')->default(0)->comment('创建管理员ID');
$table->integer('updated_by')->default(0)->comment('更新管理员ID');
$table->timestamps();
$table->softDeletes();
$table->unique('course_id', 'uk_course_id');
$table->index('is_enabled', 'idx_is_enabled');
$table->engine = 'InnoDB';
});
}
public function down()
{
Schema::dropIfExists('vod_watch_captcha_configs');
}
}5.5 Migration:database/migrations/2026_04_08_000002_create_vod_watch_captcha_logs_table.php
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateVodWatchCaptchaLogsTable extends Migration
{
public function up()
{
Schema::create('vod_watch_captcha_logs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('biz_no', 64)->default('')->comment('业务流水号');
$table->integer('user_id')->comment('用户ID');
$table->integer('course_id')->comment('课程ID');
$table->integer('video_id')->comment('视频ID');
$table->integer('trigger_order')->default(0)->comment('第几次触发');
$table->integer('trigger_second')->default(0)->comment('触发秒数');
$table->string('verify_code', 16)->default('')->comment('验证码明文');
$table->string('input_code', 16)->default('')->comment('用户输入值');
$table->string('status', 16)->default('PENDING')->comment('PENDING/PASSED/EXPIRED');
$table->integer('fail_count')->default(0)->comment('失败次数');
$table->timestamp('passed_at')->nullable()->default(null)->comment('通过时间');
$table->timestamp('expired_at')->nullable()->default(null)->comment('过期时间');
$table->timestamps();
$table->unique('biz_no', 'uk_biz_no');
$table->index(['user_id', 'video_id', 'trigger_order', 'status'], 'idx_user_video_trigger_status');
$table->index(['course_id', 'video_id'], 'idx_course_video');
$table->index('expired_at', 'idx_expired_at');
$table->engine = 'InnoDB';
});
}
public function down()
{
Schema::dropIfExists('vod_watch_captcha_logs');
}
}5.6 Model:app/Models/VodWatchCaptchaConfig.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class VodWatchCaptchaConfig extends Model
{
use SoftDeletes;
protected $table = 'vod_watch_captcha_configs';
protected $fillable = [
'course_id',
'is_enabled',
'trigger_type',
'trigger_value',
'code_length',
'expire_seconds',
'remark',
'created_by',
'updated_by',
];
}5.7 Model:app/Models/VodWatchCaptchaLog.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VodWatchCaptchaLog extends Model
{
public const STATUS_PENDING = 'PENDING';
public const STATUS_PASSED = 'PASSED';
public const STATUS_EXPIRED = 'EXPIRED';
protected $table = 'vod_watch_captcha_logs';
protected $fillable = [
'biz_no',
'user_id',
'course_id',
'video_id',
'trigger_order',
'trigger_second',
'verify_code',
'input_code',
'status',
'fail_count',
'passed_at',
'expired_at',
];
}5.8 Service:app/Services/Custom/VodWatchCaptchaService.php
<?php
namespace App\Services\Custom;
use Carbon\Carbon;
use Illuminate\Support\Str;
use App\Models\VodWatchCaptchaLog;
use App\Models\VodWatchCaptchaConfig;
use App\Services\Course\Models\Video;
class VodWatchCaptchaService
{
public function getCourseConfig(int $courseId): array
{
$config = VodWatchCaptchaConfig::query()
->where('course_id', $courseId)
->first();
if (!$config) {
return [
'course_id' => $courseId,
'is_enabled' => 0,
'trigger_type' => 'equal_parts',
'trigger_value' => 3,
'code_length' => 4,
'expire_seconds' => 300,
'remark' => '',
];
}
return $config->toArray();
}
public function saveCourseConfig(int $courseId, array $data, int $adminId): array
{
$config = VodWatchCaptchaConfig::query()->where('course_id', $courseId)->first();
$payload = [
'course_id' => $courseId,
'is_enabled' => (int)($data['is_enabled'] ?? 0),
'trigger_type' => $data['trigger_type'] ?? 'equal_parts',
'trigger_value' => max(0, (int)($data['trigger_value'] ?? 3)),
'code_length' => max(4, (int)($data['code_length'] ?? 4)),
'expire_seconds' => max(60, (int)($data['expire_seconds'] ?? 300)),
'remark' => (string)($data['remark'] ?? ''),
'updated_by' => $adminId,
];
if ($config) {
$config->fill($payload)->save();
} else {
$payload['created_by'] = $adminId;
$config = VodWatchCaptchaConfig::query()->create($payload);
}
return $config->toArray();
}
public function checkNeedPopup(int $userId, int $videoId, int $duration): array
{
$video = Video::query()->where('id', $videoId)->firstOrFail();
$config = $this->getCourseConfig((int)$video['course_id']);
if ((int)$config['is_enabled'] !== 1) {
return [
'need_popup' => 0,
'course_id' => (int)$video['course_id'],
'video_id' => (int)$video['id'],
'trigger_order' => 0,
'trigger_second' => 0,
'display_code' => '',
'biz_no' => '',
];
}
$plans = $this->buildEqualPartPlans((int)$video['duration'], (int)$config['trigger_value']);
if (!$plans) {
return [
'need_popup' => 0,
'course_id' => (int)$video['course_id'],
'video_id' => (int)$video['id'],
'trigger_order' => 0,
'trigger_second' => 0,
'display_code' => '',
'biz_no' => '',
];
}
foreach ($plans as $plan) {
if ($duration < $plan['trigger_second']) {
continue;
}
$passed = VodWatchCaptchaLog::query()
->where('user_id', $userId)
->where('video_id', $videoId)
->where('trigger_order', $plan['trigger_order'])
->where('status', VodWatchCaptchaLog::STATUS_PASSED)
->exists();
if ($passed) {
continue;
}
$pending = VodWatchCaptchaLog::query()
->where('user_id', $userId)
->where('video_id', $videoId)
->where('trigger_order', $plan['trigger_order'])
->where('status', VodWatchCaptchaLog::STATUS_PENDING)
->orderByDesc('id')
->first();
if ($pending) {
if ($pending->expired_at && Carbon::parse($pending->expired_at)->isPast()) {
$pending->status = VodWatchCaptchaLog::STATUS_EXPIRED;
$pending->save();
} else {
return [
'need_popup' => 1,
'course_id' => (int)$video['course_id'],
'video_id' => (int)$video['id'],
'trigger_order' => (int)$plan['trigger_order'],
'trigger_second' => (int)$plan['trigger_second'],
'display_code' => (string)$pending['verify_code'],
'biz_no' => (string)$pending['biz_no'],
];
}
}
$log = VodWatchCaptchaLog::query()->create([
'biz_no' => strtoupper(Str::random(32)),
'user_id' => $userId,
'course_id' => (int)$video['course_id'],
'video_id' => $videoId,
'trigger_order' => (int)$plan['trigger_order'],
'trigger_second' => (int)$plan['trigger_second'],
'verify_code' => $this->makeNumericCode((int)$config['code_length']),
'input_code' => '',
'status' => VodWatchCaptchaLog::STATUS_PENDING,
'fail_count' => 0,
'expired_at' => Carbon::now()->addSeconds((int)$config['expire_seconds']),
]);
return [
'need_popup' => 1,
'course_id' => (int)$video['course_id'],
'video_id' => (int)$video['id'],
'trigger_order' => (int)$plan['trigger_order'],
'trigger_second' => (int)$plan['trigger_second'],
'display_code' => (string)$log['verify_code'],
'biz_no' => (string)$log['biz_no'],
];
}
return [
'need_popup' => 0,
'course_id' => (int)$video['course_id'],
'video_id' => (int)$video['id'],
'trigger_order' => 0,
'trigger_second' => 0,
'display_code' => '',
'biz_no' => '',
];
}
public function verify(int $userId, int $videoId, string $bizNo, string $inputCode): array
{
$log = VodWatchCaptchaLog::query()
->where('biz_no', $bizNo)
->where('user_id', $userId)
->where('video_id', $videoId)
->firstOrFail();
if ($log['status'] === VodWatchCaptchaLog::STATUS_PASSED) {
return [
'is_passed' => 1,
'message' => '已通过,无需重复校验',
];
}
if ($log['expired_at'] && Carbon::parse($log['expired_at'])->isPast()) {
$log->fill([
'status' => VodWatchCaptchaLog::STATUS_EXPIRED,
'input_code' => $inputCode,
])->save();
return [
'is_passed' => 0,
'message' => '验证码已过期,请重新获取',
];
}
if ((string)$log['verify_code'] !== (string)$inputCode) {
$log->fill([
'input_code' => $inputCode,
'fail_count' => (int)$log['fail_count'] + 1,
])->save();
return [
'is_passed' => 0,
'message' => '验证码错误',
];
}
$log->fill([
'input_code' => $inputCode,
'status' => VodWatchCaptchaLog::STATUS_PASSED,
'passed_at' => Carbon::now(),
])->save();
return [
'is_passed' => 1,
'message' => '验证通过',
];
}
protected function buildEqualPartPlans(int $videoDuration, int $times): array
{
$videoDuration = max(0, $videoDuration);
$times = max(0, $times);
if ($videoDuration <= 0 || $times <= 0) {
return [];
}
$plans = [];
$seen = [];
$order = 1;
for ($i = 1; $i <= $times; $i++) {
$second = (int)floor($videoDuration * $i / ($times + 1));
if ($second <= 0 || isset($seen[$second])) {
continue;
}
$seen[$second] = 1;
$plans[] = [
'trigger_order' => $order,
'trigger_second' => $second,
];
$order++;
}
return $plans;
}
protected function makeNumericCode(int $length = 4): string
{
$length = max(4, $length);
$code = '';
for ($i = 0; $i < $length; $i++) {
$code .= (string)random_int(0, 9);
}
return $code;
}
}5.9 Service:app/Services/Custom/StudyStatsService.php
<?php
namespace App\Services\Custom;
use Illuminate\Support\Facades\DB;
class StudyStatsService
{
public function years(): array
{
return DB::table('user_watch_stat')
->select('year')
->distinct()
->orderByDesc('year')
->pluck('year')
->toArray();
}
public function overview(int $year): array
{
$summary = DB::table('user_watch_stat')
->where('year', $year)
->selectRaw('SUM(seconds) as total_seconds, COUNT(DISTINCT user_id) as user_count')
->first();
$topUser = DB::table('user_watch_stat as s')
->leftJoin('users as u', 'u.id', '=', 's.user_id')
->where('s.year', $year)
->groupBy('s.user_id', 'u.nick_name', 'u.mobile')
->selectRaw('s.user_id, u.nick_name, u.mobile, SUM(s.seconds) as total_seconds')
->orderByDesc('total_seconds')
->first();
return [
'year' => $year,
'total_seconds' => (int)($summary->total_seconds ?? 0),
'total_hours' => round(((int)($summary->total_seconds ?? 0)) / 3600, 2),
'user_count' => (int)($summary->user_count ?? 0),
'department_supported' => 0,
'top_user' => $topUser ? [
'user_id' => (int)$topUser->user_id,
'nick_name' => (string)$topUser->nick_name,
'mobile' => (string)$topUser->mobile,
'total_seconds' => (int)$topUser->total_seconds,
'total_hours' => round(((int)$topUser->total_seconds) / 3600, 2),
] : null,
];
}
public function users(int $year, int $page, int $size, string $keywords = ''): array
{
$baseQuery = DB::table('user_watch_stat as s')
->leftJoin('users as u', 'u.id', '=', 's.user_id')
->where('s.year', $year)
->when($keywords !== '', function ($query) use ($keywords) {
$query->where(function ($inner) use ($keywords) {
$inner->where('u.nick_name', 'like', "%{$keywords}%")
->orWhere('u.mobile', 'like', "%{$keywords}%")
->orWhere('u.id', $keywords);
});
});
$countQuery = clone $baseQuery;
$total = $countQuery
->select('s.user_id')
->groupBy('s.user_id')
->get()
->count();
$offset = ($page - 1) * $size;
$rows = $baseQuery
->groupBy('s.user_id', 'u.nick_name', 'u.mobile')
->selectRaw('
s.user_id,
u.nick_name,
u.mobile,
SUM(s.seconds) as total_seconds
')
->orderByDesc('total_seconds')
->offset($offset)
->limit($size)
->get();
$data = [];
foreach ($rows as $row) {
$data[] = [
'user_id' => (int)$row->user_id,
'nick_name' => (string)$row->nick_name,
'mobile' => (string)$row->mobile,
'department_name' => null,
'total_seconds' => (int)$row->total_seconds,
'total_hours' => round(((int)$row->total_seconds) / 3600, 2),
];
}
return [
'data' => $data,
'total' => $total,
'department_supported' => 0,
];
}
public function userDetail(int $year, int $userId): array
{
$user = DB::table('users')
->where('id', $userId)
->select(['id', 'nick_name', 'mobile'])
->first();
$monthRows = DB::table('user_watch_stat')
->where('year', $year)
->where('user_id', $userId)
->groupBy('month')
->selectRaw('month, SUM(seconds) as total_seconds')
->orderBy('month')
->get();
$months = [];
for ($i = 1; $i <= 12; $i++) {
$months[$i] = [
'month' => $i,
'total_seconds' => 0,
'total_hours' => 0,
];
}
foreach ($monthRows as $row) {
$m = (int)$row->month;
$seconds = (int)$row->total_seconds;
$months[$m] = [
'month' => $m,
'total_seconds' => $seconds,
'total_hours' => round($seconds / 3600, 2),
];
}
$courseRows = DB::table('user_video_watch_records as r')
->leftJoin('videos as v', 'v.id', '=', 'r.video_id')
->leftJoin('courses as c', 'c.id', '=', 'r.course_id')
->where('r.user_id', $userId)
->whereYear('r.updated_at', $year)
->groupBy('r.course_id', 'c.title')
->selectRaw('r.course_id, c.title as course_title, SUM(r.watch_seconds) as total_seconds')
->orderByDesc('total_seconds')
->limit(20)
->get();
$courses = [];
foreach ($courseRows as $row) {
$courses[] = [
'course_id' => (int)$row->course_id,
'course_title' => (string)$row->course_title,
'total_seconds' => (int)$row->total_seconds,
'total_hours' => round(((int)$row->total_seconds) / 3600, 2),
];
}
$yearSeconds = array_sum(array_column($months, 'total_seconds'));
return [
'year' => $year,
'user' => $user ? [
'id' => (int)$user->id,
'nick_name' => (string)$user->nick_name,
'mobile' => (string)$user->mobile,
'department_name' => null,
] : null,
'year_total_seconds' => $yearSeconds,
'year_total_hours' => round($yearSeconds / 3600, 2),
'months' => array_values($months),
'top_courses' => $courses,
];
}
}5.10 后台 Controller:app/Http/Controllers/Backend/Api/V3/VodWatchCaptchaController.php
<?php
namespace App\Http\Controllers\Backend\Api\V3;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use App\Models\AdministratorLog;
use App\Services\Course\Models\Course;
use App\Services\Custom\VodWatchCaptchaService;
use App\Http\Controllers\Backend\Api\V2\BaseController;
class VodWatchCaptchaController extends BaseController
{
public function detail(VodWatchCaptchaService $service, $courseId)
{
$courseId = (int)$courseId;
Course::query()->where('id', $courseId)->firstOrFail();
$data = $service->getCourseConfig($courseId);
AdministratorLog::storeLog('vod-watch-captcha', AdministratorLog::OPT_VIEW, compact('courseId'));
return $this->successData($data);
}
public function save(Request $request, VodWatchCaptchaService $service, $courseId)
{
$courseId = (int)$courseId;
Course::query()->where('id', $courseId)->firstOrFail();
$data = $request->only([
'is_enabled',
'trigger_type',
'trigger_value',
'code_length',
'expire_seconds',
'remark',
]);
if (!in_array(($data['trigger_type'] ?? 'equal_parts'), ['equal_parts'])) {
return $this->error('trigger_type仅支持equal_parts');
}
if ((int)($data['trigger_value'] ?? 0) < 0) {
return $this->error('trigger_value不能小于0');
}
$adminId = (int)Auth::guard('administrator')->id();
$result = $service->saveCourseConfig($courseId, $data, $adminId);
AdministratorLog::storeLog('vod-watch-captcha', AdministratorLog::OPT_UPDATE, [
'course_id' => $courseId,
'data' => $data,
]);
return $this->successData($result);
}
}5.11 后台 Controller:app/Http/Controllers/Backend/Api/V3/StudyStatsController.php
<?php
namespace App\Http\Controllers\Backend\Api\V3;
use Illuminate\Http\Request;
use App\Models\AdministratorLog;
use App\Services\Custom\StudyStatsService;
use App\Http\Controllers\Backend\Api\V2\BaseController;
class StudyStatsController extends BaseController
{
public function years(StudyStatsService $service)
{
AdministratorLog::storeLog('study-stats-v3', AdministratorLog::OPT_VIEW, []);
return $this->successData([
'years' => $service->years(),
]);
}
public function overview(Request $request, StudyStatsService $service)
{
$year = (int)$request->input('year', date('Y'));
AdministratorLog::storeLog('study-stats-v3', AdministratorLog::OPT_VIEW, compact('year'));
return $this->successData($service->overview($year));
}
public function users(Request $request, StudyStatsService $service)
{
$year = (int)$request->input('year', date('Y'));
$page = max(1, (int)$request->input('page', 1));
$size = max(1, (int)$request->input('size', 10));
$keywords = trim((string)$request->input('keywords', ''));
AdministratorLog::storeLog('study-stats-v3', AdministratorLog::OPT_VIEW, compact('year', 'page', 'size', 'keywords'));
return $this->successData($service->users($year, $page, $size, $keywords));
}
public function userDetail(Request $request, StudyStatsService $service, $userId)
{
$year = (int)$request->input('year', date('Y'));
$userId = (int)$userId;
AdministratorLog::storeLog('study-stats-v3', AdministratorLog::OPT_VIEW, compact('year', 'userId'));
return $this->successData($service->userDetail($year, $userId));
}
}5.12 前台 Controller:app/Http/Controllers/Api/V3/VideoWatchCaptchaController.php
<?php
namespace App\Http\Controllers\Api\V3;
use Illuminate\Http\Request;
use App\Services\Custom\VodWatchCaptchaService;
use App\Http\Controllers\Api\V2\BaseController;
class VideoWatchCaptchaController extends BaseController
{
public function check(Request $request, VodWatchCaptchaService $service, $id)
{
$videoId = (int)$id;
$duration = max(0, (int)$request->input('duration', 0));
if ($duration <= 0) {
return $this->error('duration参数错误');
}
$data = $service->checkNeedPopup((int)$this->id(), $videoId, $duration);
return $this->data($data);
}
public function verify(Request $request, VodWatchCaptchaService $service, $id)
{
$videoId = (int)$id;
$bizNo = trim((string)$request->input('biz_no', ''));
$inputCode = trim((string)$request->input('input_code', ''));
if ($bizNo === '' || $inputCode === '') {
return $this->error('biz_no或input_code不能为空');
}
$result = $service->verify((int)$this->id(), $videoId, $bizNo, $inputCode);
if ((int)$result['is_passed'] === 1) {
return $this->data($result);
}
return $this->error($result['message'], 1, $result);
}
}第六部分:自检清单
下面我按你要求强制自检。
6.1 是否有旧字段遗漏引用
结论
当前方案没有改旧表字段,也没有依赖不存在的旧字段。
统计部分只用了:
user_watch_stat.user_id/year/month/day/secondsuser_video_watch_records.user_id/course_id/video_id/watch_seconds/updated_atusers.id/nick_name/mobilevideos.id/course_id/durationcourses.id/title
待确认
user_watch_stat 的迁移文件我在代码包里没搜到,但该表在现有代码中被明确使用,所以我按“线上已存在”处理。
6.2 是否有查询条件和更新条件不一致
结论
验证码校验链路里,查询与更新条件保持一致:
biz_no + user_id + video_id
不会出现“查一条、改另一条”的问题。
6.3 是否有主键和业务主键混用
结论
没有混用。
配置表主键:
id配置业务键:
course_id校验日志主键:
id校验业务键:
biz_no
6.4 是否遗漏日志
结论
没有遗漏主要日志。
后台配置:写
administrator_logs后台查看统计:写
administrator_logs用户验证码过程:写
vod_watch_captcha_logs
6.5 是否遗漏事务
结论
当前最小方案里,单次写入都属于单表或单记录更新,不强依赖事务。
补充判断
如果你后续希望:
配置保存时同步写别的审计表
验证通过时同时写别的学习状态表
那时建议补事务。
6.6 是否遗漏幂等 / 防重
结论
做了基本防重,但不是绝对强幂等。
同一触发点已
PASSED就不再弹已有未过期
PENDING就复用原验证码,不重复生成
待确认
如果前端并发重复点“check”,极端情况下仍可能出现重复插入。
要彻底防重,可以再加一层:
DB::transaction + for update或 Redis 锁
本次先按最小改动不加。
6.7 是否遗漏历史数据兼容
结论
没有遗漏。
老课程没配置时,不弹验证码
老学习数据直接用于统计
6.8 是否遗漏空值判断
结论
主要入参都做了空值判断:
durationbiz_noinput_codetrigger_typetrigger_value
待确认
如果你们前端会传字符串 "0"、空数组、null 混合,需要再按你们现网风格补严一些。
6.9 是否遗漏异常处理
结论
主要依赖:
firstOrFail()参数校验
BaseController 的 error 返回
待确认
如果你们线上需要统一异常码,不只是 message,那还需要接你们现有异常体系。
6.10 是否遗漏索引优化
结论
本次新增表已补必要索引。
额外提示
统计查询主要吃旧表 user_watch_stat。
如果这张表线上量很大,建议确认是否已有:
(year, user_id)(user_id, year, month)
否则用户详情和年度排行会慢。
但因为你要求不改旧表,这次我只能把它列为性能待确认。
6.11 是否遗漏回滚 / 兜底逻辑
结论
做了兜底:
没配置验证码 => 不弹窗
验证码过期 => 提示重新获取
部门数据不存在 => 返回
department_name = null
最后给你一个直接结论
本次最小可落地方案
在你“不改旧表、不大动旧逻辑”的前提下,1 和 4 可以做,而且建议就按我上面这套做:
需求 1
走“外挂式验证码配置 + 校验记录”方案。
需求 4
走“复用现有学习时长表 + 新后台统计接口”方案。
但有一个必须说清的限制
需求 1 这次只能做到“后端提供弹窗校验能力”
不能做到“后端硬拦截继续学习”,因为旧的播放/学习上报链路没改。
要真正强制,后面必须动现有:
recordVideo或播放器鉴权链路
我建议你现在就按这个节奏推进
第一阶段
先上我这套:
2 张新表
3 个新 Controller
2 个新 Service
2 个新路由文件
1 个旧文件最小改动(RouteServiceProvider)
第二阶段
前端接完以后,再决定要不要把验证码强制拦到旧学习链路里。
发表评论: