diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 0375adb..a3442af 100755 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -36,7 +36,7 @@ class Kernel extends ConsoleKernel // 更新公司信息 $schedule->command('update_company')->everyFiveMinutes(); // 全量同步公司信息(每天凌晨执行,不同步经纬度和地址) - $schedule->command('sync:company')->dailyAt('02:00'); + // $schedule->command('sync:company')->dailyAt('02:00'); // 同步老师课程方向 $schedule->command('sync:teacher_direction')->hourly(); } diff --git a/app/Http/Controllers/Mobile/OtherController.php b/app/Http/Controllers/Mobile/OtherController.php index 03d6413..825bab7 100755 --- a/app/Http/Controllers/Mobile/OtherController.php +++ b/app/Http/Controllers/Mobile/OtherController.php @@ -195,14 +195,14 @@ class OtherController extends CommonController ->whereHas('users', function ($query) { $query->where('is_schoolmate', 1); })->with([ - 'users' => function ($query) { - $query->select('id', 'name', 'username', 'company_position', 'company_id')->where('is_schoolmate', 1)->with([ - 'courseSigns' => function ($query) { - $query->select('id', 'status', 'user_id', 'course_id')->with('course')->orderBy('fee_status', 'desc'); - } - ]); - } - ]); + 'users' => function ($query) { + $query->select('id', 'name', 'username', 'company_position', 'company_id')->where('is_schoolmate', 1)->with([ + 'courseSigns' => function ($query) { + $query->select('id', 'status', 'user_id', 'course_id')->with('course')->orderBy('fee_status', 'desc'); + } + ]); + } + ]); // 根据排序字段进行排序 if ($sortName !== 'distance') { $query->orderBy($sortName, $sortType); diff --git a/app/Http/Controllers/Mobile/UserController.php b/app/Http/Controllers/Mobile/UserController.php index 31a19b0..75844bf 100755 --- a/app/Http/Controllers/Mobile/UserController.php +++ b/app/Http/Controllers/Mobile/UserController.php @@ -240,10 +240,10 @@ class UserController extends CommonController { $user = User::with('appointments') ->withCount([ - 'appointments as pass_appointments' => function ($query) { - $query->whereIn('status', [0, 1]); - } - ])->with([ + 'appointments as pass_appointments' => function ($query) { + $query->whereIn('status', [0, 1]); + } + ])->with([ 'courseSigns' => function ($query) { $query->whereHas('course')->with('course.typeDetail')->where('status', 1)->where('fee_status', 1); } @@ -255,7 +255,9 @@ class UserController extends CommonController ->orderBy('id', 'desc') ->first(); if ($door_appointments) { - $door_appointments->qrcode = $doorRepository->getEmpQrCode($door_appointments, $out); + $out = null; + $qrcode = $doorRepository->getEmpQrCode($door_appointments, $out, 2); + $door_appointments->qrcode = ($qrcode !== false && $qrcode !== null) ? $qrcode : null; } // 进行中的课程 $course_signs = CourseSign::where('user_id', $this->getUserId()) @@ -265,7 +267,9 @@ class UserController extends CommonController $query->where('start_date', '<=', $nowDate)->where('end_date', '>=', $nowDate); })->first(); if ($course_signs) { - $course_signs->qrcode = $doorRepository->getEmpQrCodeByCourse($course_signs, $out); + $out = null; + $qrcode = $doorRepository->getEmpQrCodeByCourse($course_signs, $out, 2); + $course_signs->qrcode = ($qrcode !== false && $qrcode !== null) ? $qrcode : null; } // 是否有资格进入校友库 $enter_schoolmate = User::whereHas('courseSigns', function ($query) { diff --git a/app/Http/functions.php b/app/Http/functions.php index bacbb45..5f8d727 100755 --- a/app/Http/functions.php +++ b/app/Http/functions.php @@ -135,7 +135,7 @@ function find_children($array, $key, $parent_key, $val, $return = []) */ function id_number2age($IDnumber) { - $year = (int)substr($IDnumber, 6, 4); + $year = (int) substr($IDnumber, 6, 4); return date("Y") - $year; } @@ -175,7 +175,7 @@ function number2chinese($num, $lower = false) } $i = $i + 1; $num = $num / 10; - $num = (int)$num; + $num = (int) $num; if ($num == 0) { break; } @@ -216,7 +216,7 @@ function string2secret($string = NULL) return NULL; } $length = mb_strlen($string); - $visibleCount = (int)round($length / 4); + $visibleCount = (int) round($length / 4); $hiddenCount = $length - ($visibleCount * 2); return mb_substr($string, 0, $visibleCount) . str_repeat('*', $hiddenCount) . mb_substr($string, ($visibleCount * -1), $visibleCount); } @@ -316,10 +316,10 @@ function multiline2radio($text, $value = "", $block = false) $block = $block === true ? "d-block" : ""; for ($i = 0; $i < count($text); $i++) { $checked = ""; - if ($value === (string)$text[$i]) { + if ($value === (string) $text[$i]) { $checked = "checked"; } - if ((string)$text[$i] == "splitter") { + if ((string) $text[$i] == "splitter") { $res .= "
"; } else { $res .= '
'; @@ -511,9 +511,12 @@ function getDomain() function getFieldWithName($detail) { $array = []; - if (isset($detail['local_key'])) $array[] = $detail['local_key']; - if (isset($detail['link_table_name'])) $array[] = $detail['link_table_name']; - if (isset($detail['foreign_key'])) $array[] = $detail['foreign_key']; + if (isset($detail['local_key'])) + $array[] = $detail['local_key']; + if (isset($detail['link_table_name'])) + $array[] = $detail['link_table_name']; + if (isset($detail['foreign_key'])) + $array[] = $detail['foreign_key']; $array[] = 'relation'; return implode('_', $array); } @@ -528,15 +531,15 @@ function getBatchNo() return date('YmdHis') . $millisecond; } -function httpCurl($url, $method = 'GET', $params = [], $header = []) +function httpCurl($url, $method = 'GET', $params = [], $header = [], $timeout = 3) { $ch = curl_init(); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); //将获取的信息以字符串返回,而不是直接输出 curl_setopt($ch, CURLOPT_URL, $method == "POST" ? $url : $url . '?' . http_build_query($params)); //http_build_query数组转Url格式参数 //设置超时时间 - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 60); - curl_setopt($ch, CURLOPT_TIMEOUT, 60); + curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout); + curl_setopt($ch, CURLOPT_TIMEOUT, $timeout); //如果是https协议,取消检测SSL证书 if (stripos($url, "https://") !== FALSE) { @@ -620,7 +623,7 @@ function idCardToSex($idcard) if (empty($idcard) || !isIdCard($idcard)) { return '男'; } - $sexint = (int)substr($idcard, 16, 1); + $sexint = (int) substr($idcard, 16, 1); return $sexint % 2 === 0 ? '女' : '男'; } @@ -643,7 +646,7 @@ function isIdCard($idCard) $sigma = 0; # 提取前17位的其中一位,并将变量类型转为实数 for ($i = 0; $i < 17; $i++) { - $b = (int)$idcard[$i]; + $b = (int) $idcard[$i]; # 提取相应的加权因子 $w = $wi[$i]; # 把从身份证号码中提取的一位数字和加权因子相乘,并累加 @@ -678,7 +681,7 @@ function isMultiDimensionalArray($array) **/ function getDistance($lat1, $lng1, $lat2, $lng2) { -//将角度转为狐度 + //将角度转为狐度 $radLat1 = deg2rad($lat1);//deg2rad()函数将角度转换为弧度 $radLat2 = deg2rad($lat2); $radLng1 = deg2rad($lng1); diff --git a/app/Repositories/DoorRepository.php b/app/Repositories/DoorRepository.php index 3709bd5..1572703 100755 --- a/app/Repositories/DoorRepository.php +++ b/app/Repositories/DoorRepository.php @@ -207,8 +207,9 @@ class DoorRepository /** * 获取员工开门二维码 + * @param int|null $timeout 超时秒数,不传则使用 httpCurl 默认 */ - public function getEmpQrCode($model, &$out) + public function getEmpQrCode($model, &$out, $timeout = null) { $url = $this->baseUrl . '/api/AccessInterface/GetEmpQrCode'; $header[] = 'Content-Type: application/json;charset=UTF-8'; @@ -221,8 +222,14 @@ class DoorRepository $finally = 0; try { $params = json_encode($params); - $result = httpCurl($url, 'POST', $params, $header); + $result = $timeout !== null + ? httpCurl($url, 'POST', $params, $header, $timeout) + : httpCurl($url, 'POST', $params, $header); $result = json_decode($result, true); + if (!is_array($result) || !array_key_exists('code', $result)) { + $out = '请求超时或返回异常'; + return false; + } if (empty($result['code'])) { $model->qrcode = $result['data']; $model->save(); @@ -340,8 +347,11 @@ class DoorRepository /** * 获取员工开门二维码(报名渠道) + * @param mixed $model + * @param mixed $out + * @param int|null $timeout 超时秒数,不传则使用默认 60 秒 */ - public function getEmpQrCodeByCourse($model, &$out) + public function getEmpQrCodeByCourse($model, &$out, $timeout = null) { $user = User::find($model->user_id); $url = $this->baseUrl . '/api/AccessInterface/GetEmpQrCode'; @@ -355,8 +365,12 @@ class DoorRepository $finally = 0; try { $params = json_encode($params); - $result = httpCurl($url, 'POST', $params, $header); + $result = httpCurl($url, 'POST', $params, $header, $timeout ?? 60); $result = json_decode($result, true); + if (!is_array($result) || !array_key_exists('code', $result)) { + $out = '请求超时或返回异常'; + return false; + } if (empty($result['code'])) { $model->qrcode = $result['data']; $model->save(); diff --git a/doc/业界动态-爬虫与AI方案探讨.md b/doc/业界动态-爬虫与AI方案探讨.md new file mode 100644 index 0000000..6757166 --- /dev/null +++ b/doc/业界动态-爬虫与AI方案探讨.md @@ -0,0 +1,216 @@ +# 业界动态解决方案探讨:爬虫与 AI 技术方案 + +## 一、需求与目标 + +**核心需求**:定时爬取、分析指定公众号与网站的文章,将内容纳入系统供内部参考,形成可检索、可分析的「业界动态」信息库。 + +**目标**: +- 自动采集:按计划抓取指定公众号、网站的最新文章 +- 智能分析:用 AI 对文章做摘要、分类、关键词提取、趋势判断 +- 系统沉淀:结构化入库,支持检索、订阅、报表与决策参考 + +--- + +## 二、方案可行性结论 + +**结论**:**可以**通过爬虫 + AI 实现定时爬取与分析,但公众号与普通网站在实现难度和合规性上差异较大,需分场景设计。 + +| 数据源类型 | 技术可行性 | 主要难点与约束 | +|----------------|------------|---------------------------| +| 普通网站/博客 | 高 | 反爬、频率控制、版权 | +| 微信公众号文章 | 中~高 | 无公开 API,可通过后台「查找文章」接口(需自有订阅号) | + +--- + +## 三、技术方案概览 + +### 3.1 整体流程 + +``` +[定时任务] → [爬虫采集] → [清洗与去重] → [AI 分析] → [入库与索引] → [系统展示/检索] + ↑ ↑ + 公众号 / 网站 摘要、分类、标签、趋势 +``` + +### 3.2 爬虫技术方案 + +#### 3.2.0 借助 AI 智能获取「只想要的内容」 + +**可以**。在爬取网站时,用 AI 参与「抓什么、取哪块」,能减少为每个站点写死规则,并只保留真正需要的内容。 + +| 思路 | 做法 | 适用场景 | +|------|------|----------| +| **AI 辅助正文/字段提取** | 爬虫先拿到整页 HTML(或简化后的 DOM/文本),把「页面片段 + 需求描述」交给大模型,用自然语言说明要提取的字段(如:标题、正文、发布时间、作者)。由 AI 直接返回结构化结果,或指出对应区块的语义描述,再映射到 DOM。 | 站点多、页面结构各异,不想为每个站维护 XPath/CSS | +| **AI 判断「是否想要」** | 先抓列表页或整页文本,对每个链接/段落用 AI 做一次相关性判断(如:是否属于「业界动态」、是否与指定主题相关)。只对判定为「想要」的链接再请求正文,或只入库通过筛选的段落。 | 目标站内容杂,需要过滤噪音、只留相关文章 | +| **AI 生成/推荐选择器** | 给 AI 一张样例页的 HTML 或 DOM 树摘要,让 AI 输出「标题 / 正文 / 时间」的 XPath 或 CSS 选择器,人工审核后写入配置,爬虫仍按传统方式用选择器抓取。 | 希望减少手写规则,但保留规则可审、可改、可回滚 | + +**实现要点**: +- **输入控制**:整页 HTML 可能超长,可先做**正文预提取**(如用 readability、trafilatura 等库)再送 AI,或只送主要区块的 HTML 片段,以节省 token、提高稳定性。 +- **输出约定**:与 AI 约定好输出格式(如 JSON:`title`、`body`、`publish_time`),便于直接落库;必要时加一层校验或回退逻辑(如解析失败时 fallback 到规则或整页存储)。 +- **成本与延迟**:每条页面调用一次 AI 会带来时延与费用,可对「新站点/新模板」用 AI 抽取,对已稳定站点用缓存的选择器或规则,或仅对列表页用 AI 做「要不要抓」的过滤。 + +这样即可在**爬取阶段**就借助 AI,只获取想要的内容,减少无关信息和后续清洗成本。 + +--- + +#### (1)普通网站 / 博客 + +- **方式一:自建爬虫** + - 使用 **Scrapy**、**Playwright** 或 **Puppeteer** 抓取目标站点 + - 针对不同站点写解析规则(XPath/CSS/正则),提取标题、正文、时间、作者等 + - 建议:遵守 `robots.txt`,控制请求频率(如 1~5 秒/页),设置合理 User-Agent + +#### (1.1)Vue / React 等 SPA:能爬取到动态数据吗? + +**可以**。Vue、React、Angular 等前端框架做的是**单页应用(SPA)**:首屏 HTML 往往只有一个壳,正文、列表等是页面加载后由 JavaScript 请求接口、再渲染到 DOM 的。用只发 HTTP 请求、不执行 JS 的爬虫(如仅用 requests + 解析 HTML)拿到的多是空壳,**拿不到动态渲染出来的内容**。要拿到动态数据,有两种常见做法: + +| 方式 | 做法 | 优点 | 注意点 | +|------|------|------|--------| +| **无头浏览器** | 用 **Playwright**、**Puppeteer** 或 **Selenium** 打开页面,等待 JS 执行、接口返回、DOM 渲染完成,再对当前 DOM 做选择器提取(或把最终 HTML 交给 AI 抽取)。 | 与用户看到的完全一致,不关心前端用的什么框架。 | 耗资源、较慢,需设置合理的等待条件(如等某元素出现、或固定延迟),并控制并发。 | +| **直接请求接口** | 在浏览器开发者工具里看该 Vue 站点的「Network」:文章列表、正文往往来自某个 REST 或 GraphQL 接口。用爬虫**直接请求这些接口**,拿到 JSON,再解析入库。 | 速度快、省资源、数据已结构化。 | 需人工分析接口格式与鉴权(如 token、cookie);接口变更或鉴权加强时需维护。 | + +**建议**:目标站若是 Vue/React 等 SPA,优先看能否抓到其**数据接口**并直接请求;若接口难找、鉴权复杂或经常变,再用 **Playwright/Puppeteer** 做无头渲染后从 DOM 里取动态内容。二者都可与前述「AI 智能提取只想要的内容」结合使用。 + +#### (2)微信公众号 + +微信公众号**没有官方开放的文章列表/正文 API**。采用**公众号后台「查找文章」接口**获取文章数据: + +- **思路**:拥有一个微信**个人订阅号**,登录 [微信公众平台](https://mp.weixin.qq.com/) 后,利用后台「新建图文素材」里的**超链接 → 查找文章**功能:输入目标公众号名称即可拉取该号的**历史文章列表**。该能力对应后台的搜索与文章列表接口,可用于程序化获取最新及历史数据。 +- **操作路径**:登录公众号 → 管理 → 素材管理 → 新建图文素材 → 工具栏「超链接」→「查找文章」→ 输入要查找的公众号。 +- **技术实现要点**(供自建爬虫参考): + 1. **登录与 Cookie**:用 Selenium/Playwright 等打开 `https://mp.weixin.qq.com/`,完成扫码或账号密码登录,将登录后的 **cookies** 持久化(如保存到本地),后续请求携带。 + 2. **获取 token**:访问首页后,从跳转 URL 中解析出 `token=(\d+)`,后续接口均需携带该 token。 + 3. **搜索公众号得到 fakeid**:请求 `https://mp.weixin.qq.com/cgi-bin/searchbiz?`,参数含 `action=search_biz`、`query=目标公众号名`、`token` 等,从返回 JSON 的 `list[0].fakeid` 得到该公众号的 **fakeid**。 + 4. **分页拉取文章列表**:请求 `https://mp.weixin.qq.com/cgi-bin/appmsg?`,参数含 `action=list_ex`、`fakeid`、`begin`(分页偏移,每页 5 条递增)、`count=5`、`token` 等,可循环翻页直至取完;返回中有标题、链接、摘要等,可直接入库或再抓正文。 +- **优点**:可拉取**更多甚至全部历史文章**,数据来自微信官方后台,结构稳定、易解析。 +- **注意点**:依赖自有订阅号登录态,需保管好 Cookie/账号安全;请求频率需控制(如分页间隔 2~5 秒),避免触发后台风控;仅限内部使用,遵守平台服务条款。 +- **参考**:[python 之抓取微信公众号文章系列 2 - 腾讯云开发者社区](https://cloud.tencent.com/developer/article/1406410)(含个人公众号接口抓取思路与示例)。 + +### 3.3 AI 分析能力 + +在文章入库前/后接入 AI,实现「从原文到可参考信息」的转化: + +| 能力 | 说明 | 典型实现 | +|--------------|--------------------------|------------------------------| +| 摘要生成 | 一段话概括文章要点 | 大模型 API(如 GPT/国产大模型)| +| 分类/打标签 | 行业、主题、业务线 | 提示词 + 分类接口或 embedding | +| 关键词提取 | 便于检索与聚合 | NLP 关键词 / 大模型 | +| 情感/倾向 | 正面/中性/负面等 | 情感分析模型或大模型 | +| 与内部关联 | 与本单位业务、项目的关联 | 自定义 prompt + 知识库 | +| 趋势与周报 | 按主题聚合、生成周报 | 定时任务 + 大模型汇总 | + +**技术选型建议**: +- 摘要/分类/标签:调用 **OpenAI API**、**通义千问**、**文心一言**、**智谱** 等 +- 若需私有化:可部署 **Llama**、**ChatGLM**、**Qwen** 等,或使用支持私有化的大模型服务 + +### 3.4 系统架构示意 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 调度层(定时任务) │ +│ Cron / Celery Beat / 云函数 等,按日/周触发采集与分析 │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 采集层 │ +│ 网站爬虫(Scrapy/Playwright) │ 公众号(后台「查找文章」接口) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 清洗与去重 │ +│ 正文提取、时间归一化、URL/标题去重、简单反作弊检测 │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ AI 分析层 │ +│ 摘要 | 分类 | 关键词 | 情感 | 与业务关联度 │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 存储与检索 │ +│ 数据库(MySQL/PostgreSQL) + 全文检索(Elasticsearch/Meilisearch) │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ 应用层 │ +│ 业界动态列表、检索、订阅、看板、周报/月报导出 │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 四、实现要点与注意事项 + +### 4.1 合规与版权 + +- **版权**:爬取内容仅供**内部参考**,不对外全文再发表;建议在系统内标注来源、链接,并控制传播范围 +- ** robots.txt**:遵守目标站点的 robots 规则 +- **服务条款**:注意目标站与公众号平台的服务条款,避免违反约定用途 +- **个人信息**:若抓取到评论、作者信息等,需符合《个人信息保护法》等要求(脱敏或取得同意) + +### 4.2 反爬与稳定性 + +- 控制访问频率,避免对目标站造成压力 +- 必要时使用代理池、更换 User-Agent,但需权衡合规与伦理 +- 页面结构变更时,解析规则需可配置、可快速调整(如规则配置表 + 版本管理) + +### 4.3 公众号特殊说明 + +- 通过**公众号后台「查找文章」**所对应的接口获取目标号的文章列表(见 3.2 节),再抓正文与做 AI 分析;需保管好订阅号登录态并控制请求频率,仅限内部使用、遵守平台服务条款。 + +### 4.4 成本粗算 + +- **爬虫**:开发与维护人力;若用云主机/代理,有少量机器与带宽成本 +- **AI**:按调用量计费(摘要/分类等),可先对部分文章采样,再全量开启 +- **第三方数据**:若目标站有数据 API(如新榜等),可按条或按订阅量采购;公众号采用后台「查找文章」接口则无此项费用 + +--- + +## 五、推荐实施路径 + +### 阶段一:MVP(最小可行) + +1. **先做网站 + 链接录入** + - 支持「添加目标网站 + 简单爬虫规则」或「手动/半自动录入文章 URL」 + - 爬虫只抓正文,落库(标题、来源、URL、正文、抓取时间) +2. **接入一种 AI** + - 仅做「摘要 + 3~5 个标签」,便于快速浏览与检索 +3. **简单列表与检索** + - 按时间、来源、标签筛选,关键词全文检索 + +### 阶段二:增强 + +1. 增加**分类、关键词、情感**等 AI 能力 +2. 引入**公众号**:采用后台「查找文章」接口(需自有个人订阅号)获取目标号文章列表,再抓正文与做 AI 分析 +3. 增加**订阅与通知**(如按标签/来源推送日报、周报) + +### 阶段三:深度用起来 + +1. **趋势与报表**:按主题/行业聚合,自动生成周报/月报 +2. **与业务关联**:用 AI 判断文章与本单位业务、项目的相关度,并打标 +3. 对爬虫规则、AI 提示词做**配置化与版本管理**,便于扩展更多公众号与站点 + +--- + +## 六、小结 + +| 问题 | 结论 | +|------------------------------|----------------------------------------------------------------------| +| 能否用爬虫+AI 做业界动态? | **可以**,技术上可行,需区分「网站」与「公众号」分别设计。 | +| 爬取时能否借 AI 只取想要内容?| **可以**:AI 辅助字段提取、AI 过滤相关性、或 AI 生成选择器再人工审核。| +| Vue/SPA 动态站能爬吗? | **可以**:用无头浏览器(Playwright/Puppeteer)等渲染后再取 DOM,或直接请求前端调用的数据接口。| +| 公众号如何获取? | 用**公众号后台「查找文章」接口**:自有个人订阅号登录后,通过后台接口获取目标号文章列表,再抓正文与做 AI 分析。| +| 直接请求接口 vs 无头浏览器? | 直接请求接口易触发风控;无头/有头浏览器更接近真人,相对更顺利,但仍需控频与反检测,无头并非万能。| +| 如何发挥 AI 价值? | 摘要、分类、标签、关键词、趋势汇总、与业务关联度分析等。 | +| 主要风险 | 版权与合规、公众号来源不稳定、反爬与维护成本。 | + +将上述方案落地为「**定时爬取 + 清洗去重 + AI 分析 + 入库检索**」的闭环,即可在系统内形成可持续更新的业界动态参考库;实施时建议从网站与少量公众号试点,再逐步扩大数据源与 AI 能力。 + +--- + +*文档版本:v1.0 | 建议放置于 doc 目录,供项目评审与开发参考* diff --git a/doc/积分系统与商城结合促进校友活跃度方案.md b/doc/积分系统与商城结合促进校友活跃度方案.md new file mode 100644 index 0000000..625f6a0 --- /dev/null +++ b/doc/积分系统与商城结合促进校友活跃度方案.md @@ -0,0 +1,132 @@ +# 积分系统与商城功能结合促进校友活跃度方案 + +## 一、现状与目标 + +### 1.1 系统现有能力 + +| 模块 | 现状 | 说明 | +|------|------|------| +| **积分** | 已具备基础能力 | `users.score` 字段、`score_logs` 表,`ScoreLog::add()` 方法;当前仅「分享获得」一种获取方式,依赖配置项 `share_score` | +| **校友** | 已完善 | `is_schoolmate`、校友库、校友企业、课程审核后自动入校友库、校友捐赠(donates)、校友动态(articles) | +| **移动端** | 课程/预约/供需/图书 | 课程报名与签到、课表签到(content_check)、预约、供需信息、图书、用户信息、捐赠 | +| **商城** | 未建设 | 无商品、兑换、订单模块 | + +### 1.2 目标 + +- **积分与行为挂钩**:将课程、签到、供需、捐赠等校友相关行为纳入积分体系,明确「做什么得多少分」。 +- **积分可消费**:建设积分商城(礼品/权益),支持积分兑换,让积分有实际价值。 +- **校友身份加成**:部分积分规则或商品仅对校友开放,激励完成认证、持续参与。 +- **提升活跃**:通过「赚积分→换好礼」闭环,提高打开率、参与课程与活动、使用供需等功能的频率。 + +--- + +## 二、积分获取规则设计(与现有功能结合) + +在保留现有「分享获得」基础上,新增以下规则,均可在后台通过 Config 或独立配置表配置分值,便于运营调整。 + +| 行为 | 触发节点 | 建议分值(示例) | 说明 | +|------|----------|----------------|------| +| 分享获新用户 | 小程序 `applet-login` 带 pid,新用户首次登录 | 沿用 `share_score` | 已实现 | +| 完善资料 | 用户首次补全姓名/公司/职位等关键信息 | 50~200 | 需在 `updateUser` 或资料页增加「是否首次完善」判断 | +| 课程报名成功 | 报名审核通过(CourseSign status 通过) | 20~50/次 | 在审核通过逻辑中调用 `ScoreLog::add` | +| 单次课签到 | 课表签到成功(CourseContentCheck) | 5~15/次 | 签到成功回调中发积分 | +| 课程评价 | 提交课程/单次课评价 | 10~30/次 | 评价提交接口中发积分 | +| 供需发布 | 供需信息审核通过 | 30~80/条 | 审核通过时给发布者积分 | +| 供需留言/对接 | 在供需下有效留言或标记对接成功 | 5~20/次 | 需定义「有效」规则 | +| 校友捐赠 | 捐赠信息审核通过(donates) | 100~500/次 | 审核通过时发积分(可与捐赠金额档位挂钩) | +| 预约到场 | 预约完成且到场核销 | 10~30/次 | 需有到场核销节点 | +| 每日登录 | 当日首次打开小程序并完成接口请求 | 1~5/天 | 需防刷(同 user 同日仅一次) | +| 校友专属任务 | 如「邀请一名校友注册并认证」 | 100~300 | 需定义「校友」与「认证」口径 | + +**实现要点:** + +- 所有新增发分点统一调用 `ScoreLog::add($user_id, $score, $remark)`,`remark` 建议用统一文案如「课程报名」「课表签到」「供需发布」等,便于对账与统计。 +- 建议新增 Config 键:`score_register`、`score_course_sign`、`score_content_check`、`score_evaluation`、`score_supply_demand`、`score_donate`、`score_daily_login` 等,后台可调。 +- 部分规则需加「仅校友可得」:在发分前判断 `User::find($user_id)->is_schoolmate == 1`,不满足则不发或发减半(由产品决定)。 + +--- + +## 三、积分商城功能设计 + +### 3.1 模块范围 + +- **商品/礼品管理**(后台):商品名称、图片、所需积分、库存、是否仅校友可兑换、上下架、排序。 +- **兑换记录**(前后台):谁、何时、兑换何商品、扣减积分、订单状态(待发货/已发货/已完成)。 +- **积分明细与余额**(移动端):当前积分、积分明细列表(现有 `score_logs` 可复用,需区分 type:1 获得 2 消耗)。 + +### 3.2 数据表建议(与现有库表风格一致) + +```text +# 积分商品表 score_goods +id, name, image_id, score_price, stock, is_schoolmate_only(0/1), sort, status(0下架1上架), created_at, updated_at, deleted_at + +# 积分兑换订单表 score_orders +id, user_id, score_good_id, score_amount, status(0待发货1已发货2已完成3已取消), address_snapshot, created_at, updated_at, deleted_at +``` + +- 兑换时:扣减 `users.score`,写入 `score_logs`(score 为负、remark 如「兑换-商品名」),扣减 `score_goods.stock`,生成 `score_orders`。 +- 若需物流:在 `score_orders` 或扩展表记录收货人、手机、地址(可 address_snapshot JSON),后台发货后更新 status。 + +### 3.3 与校友身份结合 + +- **商品维度**:部分商品设置「仅校友可兑换」,移动端列表与详情中展示「仅校友」标签,未认证校友点击兑换时提示「请先完成校友认证」。 +- **价格维度**(可选):同一商品可设「校友价」与「非校友价」,用积分差体现校友优势,激励认证与保持校友身份。 + +--- + +## 四、促进校友活跃的具体策略 + +### 4.1 拉新与认证 + +- 分享得积分(已有)+ 被邀请人完成「校友认证」后给邀请人额外积分,形成二次激励。 +- 新用户引导任务:完善资料→报名一门课→完成首次签到,每步给积分,最后一步可给较大额,引导完成从「注册」到「校友行为」的路径。 + +### 4.2 课程与活动 + +- 报名、签到、评价均给积分,并可在课程详情/签到成功页展示「本次获得 XX 积分」,强化即时反馈。 +- 重要活动(如开学典礼、年会)可设「参与即得积分」或「签到得双倍积分」,由后台配置或活动开关控制。 + +### 4.3 内容与互动 + +- 供需发布、有效留言给积分,鼓励发布需求与对接。 +- 校友动态(articles)可扩展「点赞/评论得积分」或「优质动态被采纳得积分」,需防刷与内容审核。 + +### 4.4 捐赠与品牌 + +- 捐赠审核通过后给积分,并可设「捐赠专属礼品」仅可用积分+捐赠记录兑换,增强荣誉感与品牌绑定。 + +### 4.5 积分商城本身 + +- 上架校友专属商品(如学院周边、活动名额、线下权益),仅校友可兑换或校友享受更低积分价。 +- 限时兑换、限量商品,制造稀缺感,促进行为前置(先攒积分再抢兑)。 + +--- + +## 五、实施节奏建议 + +| 阶段 | 内容 | 依赖 | +|------|------|------| +| **第一阶段** | 扩展积分获取:在现有课程报名通过、课表签到、分享处增加 `ScoreLog::add`,并增加 Config 配置项 | 现有 CourseSign/CourseContentCheck/UserController | +| **第二阶段** | 移动端「我的积分」:展示当前积分与明细列表(拉取 score_logs,区分正负),必要时在 score_logs 增加 type 字段 | score_logs、users.score | +| **第三阶段** | 积分商城后台:score_goods、score_orders 表与 CRUD,商品管理、订单管理、发货 | 新建表与 Admin 控制器/菜单 | +| **第四阶段** | 积分商城移动端:商品列表/详情、兑换下单、订单列表与状态 | 新建 Mobile 控制器与小程序页 | +| **第五阶段** | 校友专属与运营策略:仅校友可兑换商品、校友价、捐赠/活动专项积分与礼品 | 上述全部 + 运营配置 | + +--- + +## 六、与现有代码的衔接点 + +- **发积分**:统一用 `App\Models\ScoreLog::add($user_id, $score, $remark)`;若需支持「消耗」积分,可扩展 `ScoreLog::consume($user_id, $score, $remark)`(内部 decrement users.score 并写入一条负 score 记录)。 +- **配置**:积分分值继续用 `Config::getValueByKey('xxx')` 读取,在后台 Config 管理中加入 `score_*` 系列 key。 +- **校友判断**:`User::is_schoolmate == 1`,与现有校友库、课程自动入库逻辑一致。 +- **移动端鉴权**:兑换、积分明细等接口放在现有 `mobile` 路由组内,使用现有 `getUserId()` 等鉴权即可。 + +--- + +## 七、风险与注意 + +- **防刷**:每日登录、签到等按人按日去重;分享需确认为新用户且未重复给同一 pid 发过该新用户的奖励;兑换需校验库存与积分余额,并发下用事务与行锁。 +- **积分一致性**:发分/扣分务必「改 users.score + 写 score_logs」在同一事务中,避免只写日志不改余额或只改余额不写日志。 +- **合规**:积分与兑换若涉及实物或权益,需在规则页说明解释权、过期与清零政策(若有),避免纠纷。 + +本方案在不改变现有校友与课程主流程的前提下,通过「积分获取扩展 + 积分商城 + 校友专属」三条线,与系统现有功能深度结合,达到促进校友活跃度的目标。实施时可按阶段落地,先做积分规则与展示,再做商城与运营策略。 diff --git a/doc/线上课程平台功能设计与承载量评估.md b/doc/线上课程平台功能设计与承载量评估.md new file mode 100644 index 0000000..12c84d5 --- /dev/null +++ b/doc/线上课程平台功能设计与承载量评估.md @@ -0,0 +1,266 @@ +# 线上课程平台功能设计与承载量评估 + +## 一、需求概述与目标 + +**核心需求**:为客户建设一套可用的线上课程平台,支持课程发布、学习管理、直播/录播、互动与数据统计,并能在预期用户规模下稳定运行。 + +**目标**: +- **功能完整**:覆盖课程生命周期、学习行为、互动与运营统计 +- **可落地**:技术方案与现有技术栈兼容,便于实施与迭代 +- **可评估**:对并发、带宽、存储等承载量有清晰估算与扩展路径 + +--- + +## 二、功能模块设计 + +### 2.1 功能总览 + +| 模块 | 功能要点 | 优先级 | +|------|----------|--------| +| 课程管理 | 课程/章节/课时创建、上下架、排序、分类与标签 | P0 | +| 学习进度 | 报名、学习记录、进度同步、完成规则(按课时/按章节) | P0 | +| 视频能力 | 录播点播(上传、转码、CDN 分发)、倍速/拖拽 | P0 | +| 直播能力 | 创建直播、推流/拉流、回放生成、签到/互动(可选) | P1 | +| 练习与考试 | 练习题/试卷、答题记录、自动批改、成绩与错题本 | P1 | +| 互动 | 评论/问答、笔记、讨论区、弹幕(可选) | P1 | +| 运营与统计 | 学习报表、完课率、热门课程、行为分析、导出 | P0 | + +### 2.2 核心业务流程 + +``` +[管理员] 创建课程 → 添加章节/课时 → 上传视频或配置直播 + ↓ +[学员] 选课/报名 → 按章节学习 → 观看视频/直播 → 做题/考试 → 获得完成凭证 + ↓ +[系统] 记录学习进度、行为日志 → 统计报表、完课率、热门分析 +``` + +--- + +## 三、使用第三方平台的方案 + +除自建系统外,可选用**第三方线上课程/知识付费平台**,由平台提供课程、视频、直播、练习、统计等能力,客户在平台内创建课程、招募学员即可,无需自建服务器与开发业务系统。 + +### 3.1 典型第三方平台(示例) + +| 类型 | 代表产品 | 能力概览 | 适用场景 | +|------|----------|----------|----------| +| 综合型知识付费/网校 | **小鹅通**、**有赞教育**、**千聊** | 课程/专栏、录播/直播、打卡、考试、分销、数据统计 | 培训、知识付费、企业内训 | +| 云课堂/公开课 | **腾讯课堂**、**网易云课堂**、**慕课网** | 课程体系、视频、直播、作业/考试、证书、学习数据 | 公开招生、职业技能培训 | +| 会议/互动教室 | **ClassIn**、**腾讯会议**、**钉钉**(云课堂/在线课堂) | 实时互动、直播授课、白板、录播回放、考勤与数据 | 强互动、小班课、会议式授课 | +| 企业学习平台 | **云学堂**、**魔学院**、**问鼎** | 学习计划、必修/选修、考试、证书、报表、与 OA/HR 对接 | 企业内训、合规学习、人才发展 | + +以上仅为示例,选型时需以当前产品能力、报价与客户业务匹配度为准。 + +### 3.2 第三方方案的优势与约束 + +| 维度 | 说明 | +|------|------| +| **上线快** | 注册/开通即可创建课程、上传视频、开直播,无需开发与部署,几天内可对外开课 | +| **无基础设施负担** | 带宽、转码、存储、并发由平台承担,无需自建 CDN、流媒体与承载量评估 | +| **功能现成** | 报名、学习进度、打卡、考试、证书、报表等一般已具备,按需开通与配置 | +| **成本结构** | 多为按年/按人数/按流量付费,初期投入低;规模大时需看阶梯价格与定制费用 | +| **定制与集成** | 界面、流程、业务规则受平台限制;与自有系统(如 CRM、公众号、支付)集成需看是否提供 API/ webhook/ 嵌入能力 | +| **数据与合规** | 学习数据在平台侧,需确认导出能力、留存周期及是否满足客户的数据主权与合规要求 | + +### 3.3 选型建议 + +- **适合优先考虑第三方平台的场景**:希望快速上线、团队无研发/运维资源、以标准课程+直播+简单练习为主、对深度定制与系统对接要求不高。 +- **适合自建的场景**:需与现有业务系统深度打通、有强定制与品牌统一要求、对数据与合规有明确自管要求、或长期规模与成本测算后自建更优。 +- **混合方式**:核心授课与学习在第三方完成,通过 API/ 嵌入/ 单点登录与自有系统做账号与门户统一、订单与数据回传,形成「第三方授课 + 自建门户/运营」的折中方案。 + +若选择**自建**,则采用下文**第四、五、六节**的技术方案、承载量评估与实施建议;若选择**第三方或混合**,可仅将第四~六节作为技术参考或对接设计依据。 + +--- + +## 四、技术方案与实现要点(自建方案) + +### 4.1 整体架构建议 + +``` + ┌─────────────────────────────────────────┐ + │ 用户端(Web / 小程序 / H5) │ + └───────────────────┬─────────────────────┘ + │ + ┌───────────────────▼─────────────────────┐ + │ API 网关 / 负载均衡 │ + └───────────────────┬─────────────────────┘ + │ + ┌───────────────┬───────────────┼───────────────┬───────────────┐ + ▼ ▼ ▼ ▼ ▼ + [业务服务] [点播/直播] [文件/转码] [搜索/统计] [消息/通知] + │ │ │ │ │ + └───────────────┴───────┬───────┴───────────────┴───────────────┘ + ▼ + ┌─────────────────────────────────────────┐ + │ MySQL / Redis / 对象存储 / 流媒体 │ + └─────────────────────────────────────────┘ +``` + +- **业务服务**:课程、报名、进度、练习、考试、互动等,可用现有 Laravel 等框架扩展。 +- **点播/直播**:建议对接成熟云厂商(阿里云、腾讯云、七牛等)的「视频点播 + 直播」产品,自建成本高、运维重。 +- **文件/转码**:视频上传至对象存储,转码与多清晰度由云点播完成;仅元数据与播放地址自管。 +- **搜索/统计**:列表与简单检索用 DB;若需全文/复杂筛选可引入 Elasticsearch(可选)。 + +### 4.2 数据库设计要点 + +#### 4.2.1 课程与内容 + +| 表名 | 主要字段 | 说明 | +|------|----------|------| +| `courses` | id, title, cover, intro, category_id, tags, status, sort, created_at | 课程主表 | +| `course_chapters` | id, course_id, title, sort | 章节 | +| `course_lessons` | id, chapter_id, title, type(video/live/text), duration, video_id, live_id, sort | 课时;type 区分录播/直播/图文 | +| `course_videos` | id, file_path, cover, duration, transcoded(多清晰度信息 JSON) | 视频元数据,实际文件在对象存储/点播 | +| `course_lives` | id, title, start_at, end_at, stream_name, replay_video_id, status | 直播场次及回放关联 | + +#### 4.2.2 学习与进度 + +| 表名 | 主要字段 | 说明 | +|------|----------|------| +| `course_enrollments` | id, user_id, course_id, enrolled_at, status | 选课/报名 | +| `lesson_progress` | id, user_id, lesson_id, progress_seconds, completed_at, status | 课时学习进度(可细化到秒) | +| `course_progress` | user_id, course_id, completed_lessons_count, finished_at | 课程维度进度汇总(可冗余或统计得出) | + +**完成规则**:可在 `courses` 增加 `completion_rule`(如:需完成 80% 课时 / 全部必修课时),在 `lesson_progress` 与 `course_progress` 上计算是否达标。 + +#### 4.2.3 练习与考试 + +| 表名 | 主要字段 | 说明 | +|------|----------|------| +| `course_exams` | id, course_id, title, type(practice/exam), total_score, pass_score, time_limit_minutes | 练习/试卷定义 | +| `exam_questions` | id, exam_id, type(single/multi/judge/text), content, options, answer, score | 题目 | +| `exam_attempts` | id, user_id, exam_id, started_at, submitted_at, score, passed, answers_snapshot | 作答记录 | +| `exam_attempt_answers` | attempt_id, question_id, user_answer, is_correct, score | 每题作答明细(错题本来源) | + +#### 4.2.4 互动与运营 + +| 表名 | 主要字段 | 说明 | +|------|----------|------| +| `lesson_comments` | id, lesson_id, user_id, content, parent_id, created_at | 评论/问答(可带 parent 做楼中楼) | +| `lesson_notes` | id, user_id, lesson_id, content, video_timestamp_seconds | 笔记(可关联时间点) | +| `learning_logs` | id, user_id, lesson_id, event(play/pause/complete), progress_seconds, created_at | 行为日志(用于统计与报表) | + +索引建议:`lesson_progress(user_id, lesson_id)` 唯一;`learning_logs(user_id, created_at)`、`(lesson_id, created_at)`;`course_enrollments(user_id, course_id)` 唯一。 + +### 4.3 关键接口与实现要点 + +| 能力 | 接口/实现方式 | 要点 | +|------|----------------|------| +| 课程列表/详情 | GET /api/courses, GET /api/courses/{id} | 分页、分类/标签筛选、只返回上架课程 | +| 章节与课时树 | GET /api/courses/{id}/chapters | 一次返回章节+课时树,前端按需展示 | +| 报名 | POST /api/courses/{id}/enroll | 幂等;校验是否已报名、课程是否可报名 | +| 进度上报 | POST /api/lessons/{id}/progress | body: progress_seconds, completed;防刷(如单次上报跨度不超过总时长) | +| 进度查询 | GET /api/courses/{id}/my-progress | 当前用户该课程下各课时进度与完成状态 | +| 视频播放地址 | GET /api/lessons/{id}/play-info | 返回带签名的点播 URL 或 HLS 地址(由云点播生成,避免盗链) | +| 直播地址 | GET /api/lives/{id}/stream-info | 返回推流/拉流地址与鉴权参数 | +| 练习/考试 | GET/POST /api/exams/{id}/attempt, POST submit | 计时在服务端校验;交卷后算分、写 attempt 与 answers | +| 评论/笔记 | CRUD /api/lessons/{id}/comments, /notes | 分页、敏感词过滤(可选) | +| 学习报表 | GET /api/admin/stats/learning | 按课程/时间维度:报名数、完课率、学习时长分布等 | + +**视频与直播**:不推荐自建转码与流媒体;使用云点播/直播的 API 完成「上传 → 转码 → 获取播放/推拉流地址」,平台只存 `course_videos`、`course_lives` 与对应 `video_id`/`stream_name`。 + +--- + +## 五、承载量评估 + +### 5.1 评估假设(需按客户实际修正) + +| 指标 | 假设值 | 说明 | +|------|--------|------| +| 注册/日活用户 | 5,000 / 2,000 | 日活为估算并发与带宽的基准 | +| 同时在线学习 | 500 | 同一时刻在看视频/直播的人数上界 | +| 峰值系数 | 2~3× | 活动日/开课日相对平日 | +| 单课程并发观看 | 200 | 单一直播或单课同时观看上限假设 | +| 视频码率 | 1~2 Mbps | 标清~高清,按 1.5 Mbps 估算 | +| 人均学习时长 | 30 min/日 | 用于估算存储与日志量 | + +以下计算均基于上述假设,实际需替换为客户真实或目标数据。 + +### 5.2 并发与 QPS + +| 场景 | 估算方式 | 结果(示例) | +|------|----------|--------------| +| 列表/详情/进度查询 | 日活 × 5 次/天,按 8 小时摊 | 约 1,250 req/h → 0.35 req/s,取整 **5 QPS** 可留余量 | +| 进度上报 | 同时在线 × 每 30 s 一次 | 500 × (1/30) ≈ **17 QPS** | +| 评论/笔记/提交试卷 | 按同时在线 10% 有写操作 | 50 × 0.5 次/min ≈ **1 QPS** | +| **业务 API 合计** | 留 2~3 倍余量 | **50~80 QPS** 可覆盖当前假设 | + +若日活或同时在线提升,可按比例放大;单机 Laravel(PHP-FPM)在简单接口下通常可支撑数百 QPS,瓶颈多在 DB 与外部服务。 + +### 5.3 视频带宽(关键) + +- **点播**:同时观看 500 人 × 1.5 Mbps ≈ **750 Mbps** 下行(约 94 MB/s)。 +- **直播**:单场 200 人 × 1.5 Mbps ≈ **300 Mbps** 下行。 + +建议全部走 **CDN + 云点播/直播**,带宽由云厂商按量计费,平台侧不直接出带宽;需在评估报告中写清「预计峰值带宽约 750 Mbps(点播)+ 300 Mbps(直播单场)」,便于客户与云厂商选型与计费。 + +### 5.4 存储估算 + +| 类型 | 估算方式 | 结果(示例) | +|------|----------|--------------| +| 视频文件 | 不落本地,在对象存储/点播;仅元数据在 DB | 按课程数量与时长估云侧费用 | +| 数据库 | 用户/课程/进度/答题/日志,按 1 万用户、100 课程、每人 50 条进度 | 约 **1~5 GB** 量级,随日志与行为表增长 | +| 行为/学习日志 | 2000 日活 × 30 条/天 × 0.5 KB | 约 **30 MB/天**,可定期归档或冷热分离 | +| Redis | 会话、进度缓存、限流 | **1~2 GB** 足够 | + +### 5.5 压测与容量建议 + +- **业务 API**:对「课程列表、进度上报、我的进度」等接口做压测,目标 **80 QPS 下 P99 < 500 ms**;数据库加索引、避免 N+1。 +- **视频**:使用云点播/直播后,由云厂商保证可用性与带宽扩展,平台侧重点保证「获取播放地址」接口稳定与鉴权正确。 +- **扩展**: + - 应用:单机可先支撑上述量级;超过可加机器 + 负载均衡,会话与缓存用 Redis 集中。 + - 数据库:主从读写分离、慢查询优化;日志表可按时分区或归档。 + - 带宽与转码:随量在云上扩容,无需自建。 + +### 5.6 承载量结论表(示例) + +| 维度 | 当前假设下容量 | 扩展方式 | +|------|----------------|----------| +| 业务 API | 50~80 QPS,P99 < 500 ms | 增加应用节点、DB 优化/读写分离 | +| 同时在线学习 | 约 500(点播)+ 单场 200(直播) | 视频全部走 CDN/云服务,按量扩容 | +| 存储(DB) | 1~5 GB,30 MB/天日志 | 分区/归档、冷热分离 | +| 预期可支撑 | 约 5,000 注册、2,000 日活、500 同时学习 | 按客户目标替换假设后重算 | + +--- + +## 六、实施建议与风险 + +### 6.1 实施阶段建议 + +| 阶段 | 内容 | 周期参考 | +|------|------|----------| +| 一期 | 课程管理、录播点播、学习进度、基础报表;对接云点播 | 4~6 周 | +| 二期 | 直播、练习/考试、评论与笔记 | 3~4 周 | +| 三期 | 运营报表增强、错题本、消息通知、体验优化 | 2~3 周 | + +先保证「能上课、能记进度、能看数据」,再上直播与练习,可控制风险与返工。 + +### 6.2 风险与应对 + +| 风险 | 应对 | +|------|------| +| 视频带宽/成本超预期 | 明确用 CDN + 云点播/直播,按量计费;设置清晰度与码率策略 | +| 进度与完成规则复杂 | 先实现「按课时完成比例」的简单规则,再迭代条件(如必须做完练习) | +| 高并发下 DB 成为瓶颈 | 索引优化、读写分离、热点表(如 progress)可考虑分表或缓存 | +| 直播延迟与稳定性 | 完全依赖云直播方案,选成熟厂商并做单场压测与回放验证 | + +### 6.3 与客户对焦事项 + +- **用户规模**:确认日活、同时在线、单课程最大并发,用于替换 4.1 的假设并重算承载量。 +- **完成规则**:是否必须做题、是否必须看完全部视频、是否有证书/学分。 +- **直播需求**:是否必须、场次频率、是否需要连麦/白板等,以决定是否采用「云直播 + 回放」即可。 +- **合规与版权**:课程版权、个人信息与学习数据留存策略、审计日志要求。 + +--- + +## 七、文档与交付物建议 + +- **功能规格**:在本文第二、四节基础上细化成「课程管理」「学习进度」「视频/直播」「练习考试」「统计报表」等子规格,便于开发与验收。 +- **承载量报告**:将 5.1 的假设改为客户确认数据后,重新计算 5.2~5.5,输出一页「承载量评估结论表」和「扩展建议」,便于运维与采购。 +- **接口文档**:对 4.3 中接口出 OpenAPI/Swagger,便于前后端联调与测试。 + +--- + +**文档版本**:v1.0 +**适用场景**:线上课程平台客户需求的技术实现方案与承载量评估,可作为投标/立项文档的附录或正文依据。 diff --git a/doc/邮件打开率与回执统计实现方案.md b/doc/邮件打开率与回执统计实现方案.md new file mode 100644 index 0000000..96bc9f2 --- /dev/null +++ b/doc/邮件打开率与回执统计实现方案.md @@ -0,0 +1,131 @@ +# 邮件打开率统计功能实现方案 + +## 一、需求概述 + +在现有邮件发送能力(`EmailRecord` / `EmailRecordUser`、定时任务 `send_email`)基础上,增加: + +- **打开率统计**:统计每封邮件是否被收件人「打开」(基于追踪像素),并在后台展示汇总与明细。 + +便于评估邮件触达效果,为后续运营与内容优化提供数据支撑。 + +## 二、现状简要分析 + +| 模块 | 现状 | +|------|------| +| 发送记录 | `email_records`:主题、模版、发送时间、状态等 | +| 收件人明细 | `email_record_users`:邮箱、status(0 待发送 / 1 成功 / 2 失败)、send_time、reason | +| 发送方式 | `SendEmail` 命令 + `EmailRecordUser::email()`,使用 Laravel `Mail::send()` | +| 统计 | 仅有「成功/失败」数量,无打开相关字段与逻辑 | + +## 三、技术方案 + +### 打开率统计 + +**思路**:采用「追踪像素 + 唯一链接」方式。 + +1. **追踪像素(Open Tracking)** + 在 HTML 邮件正文末尾插入一张 1×1 透明图片,`src` 指向本站追踪接口,并携带 `email_record_user_id` 作为唯一标识。 + +2. **接口行为** + - 收到 GET 请求后,根据参数识别到对应的 `email_record_user`。 + - 若该条尚未记录打开,则写入「首次打开时间」并计数;可同时记录打开次数、最近打开时间(视需求扩展)。 + - 返回 1×1 透明 GIF 图片响应(或 204 No Content),避免影响邮件展示。 + +3. **打开率计算** + - 单条邮件任务:`打开率 = 有过打开记录的 email_record_user 数 / 发送成功的 email_record_user 数`。 + - 列表/汇总:在 `EmailRecord` 维度聚合「已打开人数」「发送成功人数」后计算百分比。 + +**注意**: + +- 部分邮件客户端或隐私保护会屏蔽远程图片,打开率会偏保守。 +- 需做好参数校验与防刷(同一 user 可多次请求只计一次或按业务规则限频)。 + +## 四、数据库设计 + +在现有表结构上做最小扩展,便于与当前 `EmailRecord` / `EmailRecordUser` 一致。 + +### 4.1 `email_record_users` 表增加字段(推荐) + +| 字段名 | 类型 | 说明 | +|--------|------|------| +| `opened_at` | `dateTime` nullable | 首次打开时间(用于判断是否「已打开」) | +| `open_count` | `int` default 0 | 打开次数(可选,用于重复打开统计) | + +**说明**: +- 仅当 status = 1(发送成功)时,才参与打开率计算。 +- 若只需「是否打开」,仅 `opened_at` 即可;`open_count` 可用于「最近打开时间」等扩展。 + +### 4.2 迁移示例 + +```php +// database/migrations/xxxx_add_open_tracking_to_email_record_users_table.php +Schema::table('email_record_users', function (Blueprint $table) { + $table->dateTime('opened_at')->nullable()->after('send_time')->comment('首次打开时间'); + $table->unsignedInteger('open_count')->default(0)->after('opened_at')->comment('打开次数'); +}); +``` + +无需新建表即可满足「按人维度打开 + 按邮件记录聚合打开率」的需求。 + +## 五、后端实现要点 + +### 5.1 追踪像素接口(公开,无需登录) + +- **路由**:例如 `GET /api/email/open/{id}` 或 `GET /track/email/open?id=xxx`,其中 `id` 为 `email_record_user_id`。 +- **逻辑**: + 1. 根据 `email_record_user_id` 查出 `EmailRecordUser`,校验 status = 1(发送成功)。 + 2. 若 `opened_at` 为空,则写入 `opened_at = now()`,并可选 `open_count += 1`;若已存在,可按策略只更新 `open_count` 或忽略。 + 3. 返回 1×1 透明 GIF(可把一张静态 GIF 放在 `public` 或 `storage`,或使用二进制输出)。 + +### 5.2 邮件内容中插入追踪像素 + +- 在 `SendEmail` 命令(或统一封装「生成邮件 HTML」的地方)中,对每条 `EmailRecordUser`: + - 生成该条记录对应的追踪 URL(带 `email_record_user_id`),如:`https://your-domain.com/api/email/open/{{ $recordUser->id }}`。 + - 在 HTML 正文末尾追加:``。 +- 若当前邮件是纯文本,可考虑转为 multipart(text + html),仅在 html 部分加像素;若暂时只发 HTML,则直接拼接即可。 + +### 5.3 统计查询 + +- **列表(EmailRecord 维度)**: + - 已有:`withCount('emailRecordUsers')`、成功数、失败数。 + - 新增:`withCount(['emailRecordUsers as opened_count' => function ($q) { $q->where('status', 1)->whereNotNull('opened_at'); }])`,或单独查询「发送成功数」与「已打开数」,再算打开率。 +- **详情**:在 `emailRecordUsers` 中直接展示 `opened_at`、`open_count`,列表页可展示「已打开 / 已发送」与打开率百分比。 + +### 5.4 接口返回建议 + +- 列表接口在每条 `EmailRecord` 上增加: + - `sent_count`:发送成功数(status=1)。 + - `opened_count`:已打开数(status=1 且 opened_at 非空)。 + - `open_rate`:百分比,如 `sent_count > 0 ? round(opened_count / sent_count * 100, 2) : 0`。 +- 详情接口在每条 `EmailRecordUser` 上增加 `opened_at`、`open_count`(若存在)。 + +## 六、前端展示建议 + +- **邮件记录列表**: + - 增加列:「发送成功数」「已打开数」「打开率%(如 65.00%)」。 + - 可选:筛选「打开率 > 某值」或「未打开」。 + +- **邮件记录详情(收件人明细)**: + - 表格列:邮箱、发送状态、发送时间、**是否已打开**、**首次打开时间**、打开次数。 + - 可导出为 Excel,便于线下分析。 + +- **仪表盘/统计页(可选)**: + - 按时间范围汇总:总发送数、总打开数、平均打开率;或按某次邮件任务排行。 + +## 七、安全与隐私 + +- **防刷**:同一 `email_record_user_id` 多次请求只记一次「打开」或按业务限频,避免恶意请求虚高打开率。 +- **隐私**:追踪像素仅记录「是否/何时打开」,不在方案中记录 IP、User-Agent 等(若后续需要可单独评估合规性)。 + +## 八、实施步骤建议 + +| 步骤 | 内容 | +|------|------| +| 1 | 新增迁移:`email_record_users` 增加 `opened_at`、`open_count` | +| 2 | 实现追踪接口(路由 + 控制器,按 `email_record_user_id` 查询并更新 + 返回 1×1 图片) | +| 3 | 在发送逻辑中为每条收件人生成追踪 URL,并在 HTML 邮件末尾插入追踪像素 | +| 4 | 在 `EmailRecordController` 的 index/show 中增加 `opened_count`、`open_rate` 及明细字段的查询与返回 | +| 5 | 前端:列表与详情页增加打开率、已打开数、首次打开时间等展示与导出 | +| 6 | (可选)增加简单防刷与限频、日志 | + +按上述步骤即可在现有项目上完成邮件打开率统计功能。 diff --git a/e6c3dd54ed7f22db16bafaf65efac481.png b/e6c3dd54ed7f22db16bafaf65efac481.png new file mode 100644 index 0000000..f3b74db Binary files /dev/null and b/e6c3dd54ed7f22db16bafaf65efac481.png differ