驾驶舱调整

master
lion 2 weeks ago
commit bd77d35940

@ -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();
}

@ -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) {

@ -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) {

@ -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();

@ -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`,控制请求频率(如 15 秒/页),设置合理 User-Agent
#### 1.1Vue / 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/账号安全;请求频率需控制(如分页间隔 25 秒),避免触发后台风控;仅限内部使用,遵守平台服务条款。
- **参考**[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**
- 仅做「摘要 + 35 个标签」,便于快速浏览与检索
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 目录,供项目评审与开发参考*

@ -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` 可复用,需区分 type1 获得 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」在同一事务中避免只写日志不改余额或只改余额不写日志。
- **合规**:积分与兑换若涉及实物或权益,需在规则页说明解释权、过期与清零政策(若有),避免纠纷。
本方案在不改变现有校友与课程主流程的前提下,通过「积分获取扩展 + 积分商城 + 校友专属」三条线,与系统现有功能深度结合,达到促进校友活跃度的目标。实施时可按阶段落地,先做积分规则与展示,再做商城与运营策略。

@ -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 | 同一时刻在看视频/直播的人数上界 |
| 峰值系数 | 23× | 活动日/开课日相对平日 |
| 单课程并发观看 | 200 | 单一直播或单课同时观看上限假设 |
| 视频码率 | 12 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 合计** | 留 23 倍余量 | **5080 QPS** 可覆盖当前假设 |
若日活或同时在线提升,可按比例放大;单机 LaravelPHP-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 条进度 | 约 **15 GB** 量级,随日志与行为表增长 |
| 行为/学习日志 | 2000 日活 × 30 条/天 × 0.5 KB | 约 **30 MB/天**,可定期归档或冷热分离 |
| Redis | 会话、进度缓存、限流 | **12 GB** 足够 |
### 5.5 压测与容量建议
- **业务 API**:对「课程列表、进度上报、我的进度」等接口做压测,目标 **80 QPS 下 P99 &lt; 500 ms**;数据库加索引、避免 N+1。
- **视频**:使用云点播/直播后,由云厂商保证可用性与带宽扩展,平台侧重点保证「获取播放地址」接口稳定与鉴权正确。
- **扩展**
- 应用:单机可先支撑上述量级;超过可加机器 + 负载均衡,会话与缓存用 Redis 集中。
- 数据库:主从读写分离、慢查询优化;日志表可按时分区或归档。
- 带宽与转码:随量在云上扩容,无需自建。
### 5.6 承载量结论表(示例)
| 维度 | 当前假设下容量 | 扩展方式 |
|------|----------------|----------|
| 业务 API | 5080 QPSP99 &lt; 500 ms | 增加应用节点、DB 优化/读写分离 |
| 同时在线学习 | 约 500点播+ 单场 200直播 | 视频全部走 CDN/云服务,按量扩容 |
| 存储DB | 15 GB30 MB/天日志 | 分区/归档、冷热分离 |
| 预期可支撑 | 约 5,000 注册、2,000 日活、500 同时学习 | 按客户目标替换假设后重算 |
---
## 六、实施建议与风险
### 6.1 实施阶段建议
| 阶段 | 内容 | 周期参考 |
|------|------|----------|
| 一期 | 课程管理、录播点播、学习进度、基础报表;对接云点播 | 46 周 |
| 二期 | 直播、练习/考试、评论与笔记 | 34 周 |
| 三期 | 运营报表增强、错题本、消息通知、体验优化 | 23 周 |
先保证「能上课、能记进度、能看数据」,再上直播与练习,可控制风险与返工。
### 6.2 风险与应对
| 风险 | 应对 |
|------|------|
| 视频带宽/成本超预期 | 明确用 CDN + 云点播/直播,按量计费;设置清晰度与码率策略 |
| 进度与完成规则复杂 | 先实现「按课时完成比例」的简单规则,再迭代条件(如必须做完练习) |
| 高并发下 DB 成为瓶颈 | 索引优化、读写分离、热点表(如 progress可考虑分表或缓存 |
| 直播延迟与稳定性 | 完全依赖云直播方案,选成熟厂商并做单场压测与回放验证 |
### 6.3 与客户对焦事项
- **用户规模**:确认日活、同时在线、单课程最大并发,用于替换 4.1 的假设并重算承载量。
- **完成规则**:是否必须做题、是否必须看完全部视频、是否有证书/学分。
- **直播需求**:是否必须、场次频率、是否需要连麦/白板等,以决定是否采用「云直播 + 回放」即可。
- **合规与版权**:课程版权、个人信息与学习数据留存策略、审计日志要求。
---
## 七、文档与交付物建议
- **功能规格**:在本文第二、四节基础上细化成「课程管理」「学习进度」「视频/直播」「练习考试」「统计报表」等子规格,便于开发与验收。
- **承载量报告**:将 5.1 的假设改为客户确认数据后,重新计算 5.25.5,输出一页「承载量评估结论表」和「扩展建议」,便于运维与采购。
- **接口文档**:对 4.3 中接口出 OpenAPI/Swagger便于前后端联调与测试。
---
**文档版本**v1.0
**适用场景**:线上课程平台客户需求的技术实现方案与承载量评估,可作为投标/立项文档的附录或正文依据。

@ -0,0 +1,131 @@
# 邮件打开率统计功能实现方案
## 一、需求概述
在现有邮件发送能力(`EmailRecord` / `EmailRecordUser`、定时任务 `send_email`)基础上,增加:
- **打开率统计**:统计每封邮件是否被收件人「打开」(基于追踪像素),并在后台展示汇总与明细。
便于评估邮件触达效果,为后续运营与内容优化提供数据支撑。
## 二、现状简要分析
| 模块 | 现状 |
|------|------|
| 发送记录 | `email_records`:主题、模版、发送时间、状态等 |
| 收件人明细 | `email_record_users`邮箱、status0 待发送 / 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 正文末尾追加:`<img src="{{ $trackingUrl }}" width="1" height="1" alt="" style="display:block" />`。
- 若当前邮件是纯文本,可考虑转为 multiparttext + 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%)」。
- 可选:筛选「打开率 &gt; 某值」或「未打开」。
- **邮件记录详情(收件人明细)**
- 表格列:邮箱、发送状态、发送时间、**是否已打开**、**首次打开时间**、打开次数。
- 可导出为 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 | (可选)增加简单防刷与限频、日志 |
按上述步骤即可在现有项目上完成邮件打开率统计功能。

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Loading…
Cancel
Save