From 3bbe727343a3dff8c74a045e286b9185b0aab024 Mon Sep 17 00:00:00 2001 From: cody <648753004@qq.com> Date: Tue, 3 Mar 2026 09:30:56 +0800 Subject: [PATCH] update --- .../Controllers/Mobile/OtherController.php | 16 +- .../Controllers/Mobile/UserController.php | 16 +- app/Http/functions.php | 31 +- app/Repositories/DoorRepository.php | 22 +- doc/业界动态-爬虫与AI方案探讨.md | 216 ++++++++++++++ doc/积分系统与商城结合促进校友活跃度方案.md | 132 +++++++++ doc/线上课程平台功能设计与承载量评估.md | 266 ++++++++++++++++++ doc/邮件打开率与回执统计实现方案.md | 131 +++++++++ 科技商学院学员信息-匹配人才标签_匹配是否.xlsx | Bin 31130 -> 0 bytes 9 files changed, 798 insertions(+), 32 deletions(-) create mode 100644 doc/业界动态-爬虫与AI方案探讨.md create mode 100644 doc/积分系统与商城结合促进校友活跃度方案.md create mode 100644 doc/线上课程平台功能设计与承载量评估.md create mode 100644 doc/邮件打开率与回执统计实现方案.md delete mode 100644 科技商学院学员信息-匹配人才标签_匹配是否.xlsx 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/科技商学院学员信息-匹配人才标签_匹配是否.xlsx b/科技商学院学员信息-匹配人才标签_匹配是否.xlsx deleted file mode 100644 index 103a04b5423130a26d1282c780c66f87e4416ee9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 31130 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