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