无尘阁日记

无尘阁日记

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

这个接口只做两件事:

  1. 校验用户是否有权限看该视频

  2. 把前端传来的 duration 记入学习记录

其后续逻辑是:

  • VideoBus::userVideoWatchDurationRecord($userId, $videoId, $duration)

    • 查视频时长

    • 判断是否看完

    • 更新 user_video_watch_records

    • 更新 user_watch_stat(按天累计学习秒数)

也就是说,现有系统只支持“学习时长上报”,不支持“播放过程中弹验证码校验”。

2)当前后台统计链路

后台 V2 统计接口目前只有:

  • 交易统计

  • 用户注册统计

  • 用户付费统计

没有:

  • 年度学习时长统计

  • 个人学习时长排行

  • 部门维度学习统计

3)当前课程/视频配置模型

现有 coursesvideos 表里没有任何“学习验证码”相关字段。
所以如果你要求:

  • 不改旧表

  • 不大动旧逻辑

那这次最合适的方式就是:

外挂一套新表 + 新接口,按 course_id / video_id 关联旧数据。


1.2 我对新需求的理解

需求 1:录播课验证码弹窗

你的意思不是改播放器本身,而是给后端补一套“验证码校验配置 + 校验流程接口”,让前端可以这样接:

  1. 后台给某个录播课程配置“等分弹窗次数”

  2. 前端播放到某个时点时,调用“是否需要弹验证码”接口

  3. 如果需要,后端返回本次验证码

  4. 前端弹窗输入

  5. 前端把用户输入提交到后端校验

  6. 校验通过后继续播放

需求 4:学习统计

你的目标不是交易统计,而是新增一个“学员学习统计”模块,至少要有:

  1. 按年度汇总学习秒数

  2. 按个人汇总学习时长

  3. 如果系统里有部门数据,就支持部门维度展示


1.3 这次改造的核心变化

这次改造的核心,不是改旧表,而是:

核心变化 A:在旧课程体系外,新增“录播验证码配置层”

  • 用新表记录“某课程是否开启验证码”

  • 用新表记录“某用户某视频某次弹窗是否已通过”

核心变化 B:在旧学习记录体系外,新增“学习统计 API 层”

  • 复用现有 user_watch_stat

  • 新增后台统计接口

  • 不碰旧统计控制器和旧页面接口


第二部分:影响面分析

2.1 数据库层

需求 1 会影响

必须新增至少 2 张表

表 1:课程验证码配置表

用途:

  • 记录某课程是否开启验证码

  • 记录触发方式

  • 记录等分次数

表 2:验证码校验记录表

用途:

  • 记录某用户某视频某个触发点是否已校验

  • 避免重复弹窗

  • 保留审计日志

需求 4 会影响

严格按你现在的范围,可以不新增表,直接复用现有:

  • user_watch_stat

  • user_video_watch_records

  • users

  • videos

  • courses

但有一个前提:

部门维度目前待确认。

因为现有 users 表里没有 department 字段。
所以“按部门统计”这件事,如果你坚持不改旧表,那就只能:

  • 先返回 department = null

  • 或者后续再单独补一张用户组织扩展表

这次如果只做 1 和 4,我建议:

  • 本批先不强行上部门扩展表

  • 统计接口预留 department_name 字段,当前返回空


2.2 Model 层

需求 1 需要新增

  • VodWatchCaptchaConfig

  • VodWatchCaptchaLog

需求 4 可新增一个纯查询型服务,不一定新增 Model

如果你想代码更整洁,可以加一个:

  • StudyStatsQueryService


2.3 Service 层

需求 1 需要新增服务

建议新增:

  • VodWatchCaptchaService

职责:

  1. 读取课程验证码配置

  2. 按视频时长计算触发点

  3. 判断当前播放进度是否该弹窗

  4. 生成验证码

  5. 校验验证码

  6. 记录通过状态

需求 4 需要新增服务

建议新增:

  • StudyStatsService

职责:

  1. 年度学习总览

  2. 个人学习排行

  3. 个人年度学习明细

  4. 部门维度聚合(当前可先空实现/兼容)


2.4 Controller / API 层

需求 1 需要新增 3 类接口

后台接口

给管理端配置课程验证码规则:

  • 查询课程验证码配置

  • 保存课程验证码配置

前台接口

给播放器用:

  • 查询当前是否需要弹窗

  • 提交验证码校验

需求 4 需要新增后台统计接口

建议新增:

  • 年度统计总览

  • 年度个人排行

  • 个人年度明细


2.5 日志层

需求 1

需要记录两类日志:

  1. 管理员配置日志
    用现有 administrator_logs

  2. 学员验证码校验日志
    用新增表 vod_watch_captcha_logs

需求 4

后台查看统计时可继续写 administrator_logs


2.6 状态流转层

需求 1 的状态流转

建议:

  • PENDING:已生成验证码,待输入

  • PASSED:用户输入正确,已通过

  • EXPIRED:验证码过期

  • FAILED:本次输入错误(可选)

最小方案里我建议只保留:

  • PENDING

  • PASSED

  • EXPIRED

错误次数可直接记 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.php

  • routes/frontend-v3-watch-captcha.php

Controller

  • App\Http\Controllers\Backend\Api\V3\VodWatchCaptchaController

  • App\Http\Controllers\Backend\Api\V3\StudyStatsController

  • App\Http\Controllers\Api\V3\VideoWatchCaptchaController

Model

  • App\Models\VodWatchCaptchaConfig

  • App\Models\VodWatchCaptchaLog

Service

  • App\Services\Custom\VodWatchCaptchaService

  • App\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/seconds

  • user_video_watch_records.user_id/course_id/video_id/watch_seconds/updated_at

  • users.id/nick_name/mobile

  • videos.id/course_id/duration

  • courses.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 是否遗漏空值判断

结论

主要入参都做了空值判断:

  • duration

  • biz_no

  • input_code

  • trigger_type

  • trigger_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)

第二阶段

前端接完以后,再决定要不要把验证码强制拦到旧学习链路里。