feat: 新增整屏推荐页配置功能
- 7 个固定槽位(1920×1080 两行网格),槽位定义/校验/橘视厅元数据同步 - 海报按槽位自动选:竖图槽取 posterimg、横图槽取 thumbimg,支持自定义覆盖 - 一键导出 zip 交付包:海报按槽位精确尺寸居中裁剪 + config.json + README(am start 拉起命令) - 管理后台接口 6 个(/manage/recommend-page/*),前端对接文档 FE-RECOMMEND-GUIDE.md
This commit is contained in:
48
API_DOC.md
48
API_DOC.md
@@ -252,6 +252,54 @@
|
|||||||
| `/manage/partner-card/stock` | GET | partner库存查询 |
|
| `/manage/partner-card/stock` | GET | partner库存查询 |
|
||||||
| `/manage/partner-card/list` | GET | 池子记录分页 |
|
| `/manage/partner-card/list` | GET | 池子记录分页 |
|
||||||
|
|
||||||
|
### 2.22 推荐页配置 `/manage/recommend-page`
|
||||||
|
|
||||||
|
设备厂商 1920×1080 整屏推荐页:运营选片配置 7 个固定槽位,一键导出 zip 交付包(按槽位尺寸裁好的图片 + 影片编号 + am start 拉起命令)。
|
||||||
|
|
||||||
|
| 路径 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/manage/recommend-page/list` | GET | 分页列表(page/limit/name) |
|
||||||
|
| `/manage/recommend-page/info/{id}` | GET | 详情(含 slots 解析后数组) |
|
||||||
|
| `/manage/recommend-page/slot-definitions` | GET | 7 个槽位定义(code/类型/尺寸/默认动作/海报字段) |
|
||||||
|
| `/manage/recommend-page/save` | POST | 新建或更新(有 id 更新) |
|
||||||
|
| `/manage/recommend-page/delete` | POST | 软删除(body: [id]) |
|
||||||
|
| `/manage/recommend-page/export/{id}` | GET | 一键导出 zip 交付包(浏览器下载) |
|
||||||
|
|
||||||
|
**保存请求体**(slot_01~06 必填 contentcode,slot_07 更多入口必填 posterUrl):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": null,
|
||||||
|
"name": "2026年10月推荐",
|
||||||
|
"remark": "备注",
|
||||||
|
"slots": [
|
||||||
|
{ "slotCode": "slot_01", "contentcode": "23010012026061709581101832" },
|
||||||
|
{ "slotCode": "slot_02", "contentcode": "..." },
|
||||||
|
{ "slotCode": "slot_03", "contentcode": "..." },
|
||||||
|
{ "slotCode": "slot_04", "contentcode": "..." },
|
||||||
|
{ "slotCode": "slot_05", "contentcode": "..." },
|
||||||
|
{ "slotCode": "slot_06", "contentcode": "..." },
|
||||||
|
{ "slotCode": "slot_07", "posterUrl": "https://.../more.jpg" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 槽位可选字段:`posterUrl`(自定义海报,横图槽位补横版图)、`actionParam`(自定义拉起命令,支持 `{contentcode}`/`{mediaName}` 占位符)
|
||||||
|
- 保存时后端按 contentcode 调橘视厅同步影片名与海报(无效 contentcode 报错)
|
||||||
|
- 海报默认策略:竖图槽(slot_02~04)取 posterimg 竖版,横图槽(slot_01/05/06)取 thumbimg 横版
|
||||||
|
- 拉起命令默认值:DETAIL = `am start -n tv.huashi.jetsenx.jetsenfilx.jsttv.home/com.pukka.jiecheng.MainActivity --es media_id "{contentcode}"`(备选 `--es film_name "{mediaName}"`);LAUNCH = `am start -n tv.huashi.jetsenx.jetsenfilx.jsttv.home/com.pukka.jiecheng.MainActivity`
|
||||||
|
|
||||||
|
**导出 zip 结构**:
|
||||||
|
|
||||||
|
```
|
||||||
|
推荐页_2026年10月推荐.zip
|
||||||
|
├── images/slot_01.png ~ slot_07.png # 按槽位精确尺寸居中裁剪
|
||||||
|
├── recommend_config.json # 含 contentcode/actionType/actionParam(占位符已替换)
|
||||||
|
└── README.txt # 排版与拉起规则说明
|
||||||
|
```
|
||||||
|
|
||||||
|
> 前端下载 zip 需带 JWT 请求头,用 axios blob 方式(`responseType: 'blob'`)下载,不能直接 window.open。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 三、第三方开放接口(appKey签名鉴权)
|
## 三、第三方开放接口(appKey签名鉴权)
|
||||||
|
|||||||
248
FE-RECOMMEND-GUIDE.md
Normal file
248
FE-RECOMMEND-GUIDE.md
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
# 推荐页配置 · 前端对接文档
|
||||||
|
|
||||||
|
> 功能:设备厂商 1920×1080 整屏电影推荐页,运营在后台选片配置 7 个固定槽位,一键导出 zip 交付包发给厂商。
|
||||||
|
> 后端已上线,本文档为前端实施所需全部信息。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 通用约定
|
||||||
|
|
||||||
|
- **鉴权**:所有接口走管理后台 JWT,请求头带 `token`(与现有 /manage 接口一致)
|
||||||
|
- **返回格式**:`{ code: 0, msg: "", data: {...} }`,code=0 成功,非 0 失败(msg 为错误信息)
|
||||||
|
- **新增菜单**:「媒资管理 → 推荐页配置」,路由 `manage/recommend-page`(后台 sys_menu 已配置 id=126,超管自动可见,其他角色需在角色管理勾选)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 槽位定义(固定 7 个,不可增减)
|
||||||
|
|
||||||
|
整屏排版(1920×1080 两行网格,槽位间距 20px,左右边距 70px):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────┐ ┌─────┐ ┌─────┐ ┌─────┐
|
||||||
|
│ slot_01 │ │slot_02│ │slot_03│ │slot_04│ ← 上行
|
||||||
|
│ 700×400 │ │340×440│ │340×440│ │340×440│
|
||||||
|
└────────────────┘ └─────┘ └─────┘ └─────┘
|
||||||
|
┌──────────────┐ ┌──────────────┐ ┌─────────┐
|
||||||
|
│ slot_05 │ │ slot_06 │ │ slot_07 │ ← 下行
|
||||||
|
│ 700×300 │ │ 700×300 │ │ 340×300 │
|
||||||
|
└──────────────┘ └──────────────┘ └─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
| 槽位 | 类型 | 尺寸 | 说明 | 影片 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| slot_01 | MAIN 主推 | 700×400 | 横图 | 必填 contentcode |
|
||||||
|
| slot_02 | PORTRAIT 竖图 | 340×440 | 竖图 | 必填 |
|
||||||
|
| slot_03 | PORTRAIT 竖图 | 340×440 | 竖图 | 必填 |
|
||||||
|
| slot_04 | PORTRAIT 竖图 | 340×440 | 竖图 | 必填 |
|
||||||
|
| slot_05 | LANDSCAPE 横图 | 700×300 | 横图 | 必填 |
|
||||||
|
| slot_06 | LANDSCAPE 横图 | 700×300 | 横图 | 必填 |
|
||||||
|
| slot_07 | MORE 更多入口 | 340×300 | 无影片,点击拉起橘视厅 | 不填,必填海报 URL |
|
||||||
|
|
||||||
|
**编辑页建议**:按 0.25 倍缩放渲染线框(175×100 / 85×110 / 175×75 / 85×75),每槽位显示海报缩略图 + 影片名 + 尺寸标注。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 接口清单
|
||||||
|
|
||||||
|
Base:`/manage/recommend-page`
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/list` | 分页列表,参数 `page`、`limit`、`name`(可选) |
|
||||||
|
| GET | `/info/{id}` | 详情(slots 已解析为数组) |
|
||||||
|
| GET | `/slot-definitions` | 槽位定义(渲染线框用,见 §3.1) |
|
||||||
|
| POST | `/save` | 新建或更新(有 id 更新,无 id 新建) |
|
||||||
|
| POST | `/delete` | 软删除,body: `[1, 2]`(id 数组) |
|
||||||
|
| GET | `/export/{id}` | 导出 zip 交付包(**必须 blob 方式下载,见 §6**) |
|
||||||
|
|
||||||
|
### 3.1 槽位定义 GET `/slot-definitions`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"slots": {
|
||||||
|
"slot_01": { "slotCode": "slot_01", "slotType": "MAIN", "width": 700, "height": 400,
|
||||||
|
"defaultActionType": "DETAIL", "mediaRequired": true, "posterField": "thumbimg" },
|
||||||
|
"slot_02": { "slotCode": "slot_02", "slotType": "PORTRAIT", "width": 340, "height": 440,
|
||||||
|
"defaultActionType": "DETAIL", "mediaRequired": true, "posterField": "posterimg" }
|
||||||
|
// ... slot_03~07
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 列表 GET `/list?page=1&limit=10`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"page": {
|
||||||
|
"totalCount": 5, "pageSize": 10, "currPage": 1, "totalPage": 1,
|
||||||
|
"list": [
|
||||||
|
{ "id": 1, "name": "2026年10月推荐", "remark": "备注", "deleteFlag": "UNDEL",
|
||||||
|
"createdAt": "2026-09-26 15:00:00", "updatedAt": "2026-09-26 15:00:00" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 详情 GET `/info/{id}`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"recommendPage": { "id": 1, "name": "2026年10月推荐", "remark": "", "slots": "[...原始JSON字符串...]", "createdAt": "..." },
|
||||||
|
"slots": [
|
||||||
|
{
|
||||||
|
"slotCode": "slot_01",
|
||||||
|
"slotType": "MAIN",
|
||||||
|
"width": 700, "height": 400,
|
||||||
|
"contentcode": "23010012026061709581101832",
|
||||||
|
"mediaName": "影片名(后端从橘视厅同步)",
|
||||||
|
"posterUrl": "https://imgcms.jetsenx.huashi.tv/.../448x252_xxx.png",
|
||||||
|
"customPoster": false,
|
||||||
|
"actionType": "DETAIL",
|
||||||
|
"actionParam": "am start -n tv.huashi.jetsenx.jetsenfilx.jsttv.home/com.pukka.jiecheng.MainActivity --es media_id \"{contentcode}\"",
|
||||||
|
"rawMedia": { "...橘视厅完整元数据(posterimg/thumbimg/description等)..." }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"slotCode": "slot_07",
|
||||||
|
"slotType": "MORE",
|
||||||
|
"width": 340, "height": 300,
|
||||||
|
"contentcode": null,
|
||||||
|
"mediaName": "更多影片",
|
||||||
|
"posterUrl": "https://.../more.jpg",
|
||||||
|
"customPoster": true,
|
||||||
|
"actionType": "LAUNCH",
|
||||||
|
"actionParam": "am start -n tv.huashi.jetsenx.jetsenfilx.jsttv.home/com.pukka.jiecheng.MainActivity",
|
||||||
|
"rawMedia": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> 前端渲染用 `data.slots`(已解析),按 slotCode 匹配到线框位置。
|
||||||
|
|
||||||
|
### 3.4 保存 POST `/save`
|
||||||
|
|
||||||
|
**前端只需传必要字段,影片名/海报由后端从橘视厅自动同步:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": null,
|
||||||
|
"name": "2026年10月推荐",
|
||||||
|
"remark": "备注(可选)",
|
||||||
|
"slots": [
|
||||||
|
{ "slotCode": "slot_01", "contentcode": "23010012026061709581101832" },
|
||||||
|
{ "slotCode": "slot_02", "contentcode": "..." },
|
||||||
|
{ "slotCode": "slot_03", "contentcode": "..." },
|
||||||
|
{ "slotCode": "slot_04", "contentcode": "..." },
|
||||||
|
{ "slotCode": "slot_05", "contentcode": "..." },
|
||||||
|
{ "slotCode": "slot_06", "contentcode": "..." },
|
||||||
|
{ "slotCode": "slot_07", "posterUrl": "https://.../more.jpg" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**槽位可选字段:**
|
||||||
|
|
||||||
|
| 字段 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `posterUrl` | 自定义海报 URL。横图槽位(slot_01/05/06/07)默认用橘视厅横版图,效果不佳时运营可手动填横图 URL 覆盖 |
|
||||||
|
| `actionParam` | 自定义拉起命令(一般不填,用后端默认值) |
|
||||||
|
|
||||||
|
**后端校验规则(错误信息会原样返回 msg):**
|
||||||
|
- 期名 name 必填
|
||||||
|
- slots 必须 7 个、slotCode 齐全不重复
|
||||||
|
- slot_01~06 contentcode 必填;无效 contentcode 报错 `contentcode 无效: xxx`(保存时后端实时调橘视厅校验)
|
||||||
|
- slot_07 必填 posterUrl
|
||||||
|
- 影片无海报时报错 `影片 xxx 无可用海报`
|
||||||
|
|
||||||
|
成功返回:`{ code: 0, data: { recommendPage: { id: 1, ... } } }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 选片弹窗(复用已有媒资接口)
|
||||||
|
|
||||||
|
**搜索**:`GET /manage/jetsen-media/search?keyword=xxx&pageindex=1&pagesize=20`(已存在的接口)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"list": [
|
||||||
|
{ "contentcode": "23010022026090109192132084", "name": "后悔没有好好爱过你",
|
||||||
|
"posterimg": "https://...", "categoryname": "电影", "score": "8.0",
|
||||||
|
"releaseyear": "2026", "originalcountry": "中国大陆" }
|
||||||
|
],
|
||||||
|
"totalCount": 100, "currPage": 1, "pageSize": 20, "totalPage": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
弹窗交互:搜索框 → 结果列表(海报缩略图 + 片名 + contentcode)→ 选中回填槽位。
|
||||||
|
槽位预览图可直接用选中结果的 `posterimg`(保存后以 info 接口返回的 posterUrl 为准)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 页面结构
|
||||||
|
|
||||||
|
### 5.1 列表页 `/recommend`(路由建议)
|
||||||
|
|
||||||
|
| 列 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 期名 | name |
|
||||||
|
| 备注 | remark |
|
||||||
|
| 创建时间 | createdAt |
|
||||||
|
| 操作 | 编辑 / **复制新一期** / 导出zip / 删除 |
|
||||||
|
|
||||||
|
**复制新一期**:调 `info/{id}` 取 slots 预填新建表单(id 置 null),运营只改差异影片。
|
||||||
|
|
||||||
|
### 5.2 编辑页
|
||||||
|
|
||||||
|
- 顶部表单:期名(必填)、备注
|
||||||
|
- 槽位线框区(§2 尺寸 × 0.25 缩放)
|
||||||
|
- slot_01~06:点槽位 → 打开选片弹窗 → 回填 contentcode + 预览图
|
||||||
|
- 每个槽位可展开「自定义海报 URL」输入框(可选,横图槽位补横版图用)
|
||||||
|
- slot_07:仅海报 URL 输入(必填)+ 可选拉起参数
|
||||||
|
- 工具栏:保存、导出 zip(保存后才可导出,需有 id)
|
||||||
|
|
||||||
|
**前端预校验**(7 槽位齐、slot_01~06 contentcode 齐、slot_07 海报齐),后端保存时再校验一遍。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 导出下载(重要:不能用 window.open)
|
||||||
|
|
||||||
|
导出接口走 JWT 请求头,`window.open` / `<a href>` 无法带 token,**必须用 axios blob 方式**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
axios.get(`/manage/recommend-page/export/${id}`, {
|
||||||
|
responseType: 'blob',
|
||||||
|
headers: { token: getToken() }
|
||||||
|
}).then(res => {
|
||||||
|
const url = window.URL.createObjectURL(new Blob([res.data]))
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = `推荐页_${name}.zip`
|
||||||
|
link.click()
|
||||||
|
window.URL.revokeObjectURL(url)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:导出需实时下载 7 张海报并裁剪,耗时约 5~15 秒,建议按钮加 loading 态。失败时(如某海报下载失败)返回 JSON 错误而非 zip,前端可判断 `res.data.type === 'application/json'` 提示错误。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. zip 交付包结构(导出结果,无需前端处理,仅供了解)
|
||||||
|
|
||||||
|
```
|
||||||
|
推荐页_2026年10月推荐.zip
|
||||||
|
├── images/slot_01.png ~ slot_07.png # 已按槽位精确尺寸居中裁剪
|
||||||
|
├── recommend_config.json # contentcode + actionType + actionParam(占位符已替换真实值)
|
||||||
|
└── README.txt # 排版与拉起规则说明
|
||||||
|
```
|
||||||
16
db/recommend_page.sql
Normal file
16
db/recommend_page.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
-- 整屏推荐页配置(一期一条记录,7 固定槽位存 JSON,历史全保留)
|
||||||
|
CREATE TABLE IF NOT EXISTS recommend_page (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
name VARCHAR(128) NOT NULL COMMENT '期名,如:2026年10月推荐',
|
||||||
|
slots TEXT NOT NULL COMMENT '槽位配置JSON数组,7个元素',
|
||||||
|
remark VARCHAR(500) DEFAULT NULL COMMENT '备注',
|
||||||
|
delete_flag VARCHAR(8) NOT NULL DEFAULT 'UNDEL',
|
||||||
|
created_at DATETIME DEFAULT NULL,
|
||||||
|
updated_at DATETIME DEFAULT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_created_at (created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='整屏推荐页配置';
|
||||||
|
|
||||||
|
-- 菜单:挂「媒资管理」(id=116) 下
|
||||||
|
INSERT INTO sys_menu (id, parent_id, name, url, perms, type, icon, `show`, order_num, created_at, updated_at)
|
||||||
|
VALUES (126, 116, '推荐页配置', 'manage/recommend-page', '', 1, 'menu', '10', 2, NOW(), NOW());
|
||||||
5
pom.xml
5
pom.xml
@@ -64,6 +64,11 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>net.coobird</groupId>
|
||||||
|
<artifactId>thumbnailator</artifactId>
|
||||||
|
<version>0.4.20</version>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package io.as.modules.recommend.constants;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整屏推荐页常量:7 个固定槽位定义(1920×1080 两行网格)与拉起命令
|
||||||
|
*
|
||||||
|
* 排版:上行 主推(700×400) + 3 竖图(340×440),下行 2 横图(700×300) + 更多入口(340×300),
|
||||||
|
* 槽位间距 20px,左右边距 70px。
|
||||||
|
*/
|
||||||
|
public class RecommendConstants {
|
||||||
|
|
||||||
|
/** 橘视厅 App 包名/入口 Activity(release 实测拉起方式) */
|
||||||
|
public static final String LAUNCH_CMD_PREFIX =
|
||||||
|
"am start -n tv.huashi.jetsenx.jetsenfilx.jsttv.home/com.pukka.jiecheng.MainActivity";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DETAIL 槽位默认拉起命令:按节目编号(contentcode)精确拉起。
|
||||||
|
* 占位符 {contentcode} 导出时替换为真实值。
|
||||||
|
*/
|
||||||
|
public static final String ACTION_DETAIL_BY_ID = LAUNCH_CMD_PREFIX + " --es media_id \"{contentcode}\"";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DETAIL 槽位备选拉起命令:按影片名拉起(存在重名风险,供运营选用)。
|
||||||
|
* 占位符 {mediaName} 导出时替换为真实值。
|
||||||
|
*/
|
||||||
|
public static final String ACTION_DETAIL_BY_NAME = LAUNCH_CMD_PREFIX + " --es film_name \"{mediaName}\"";
|
||||||
|
|
||||||
|
/** LAUNCH 槽位默认拉起命令:直接拉起橘视厅首页 */
|
||||||
|
public static final String ACTION_LAUNCH_HOME = LAUNCH_CMD_PREFIX;
|
||||||
|
|
||||||
|
/** 槽位类型 */
|
||||||
|
public static final String TYPE_MAIN = "MAIN";
|
||||||
|
public static final String TYPE_PORTRAIT = "PORTRAIT";
|
||||||
|
public static final String TYPE_LANDSCAPE = "LANDSCAPE";
|
||||||
|
public static final String TYPE_MORE = "MORE";
|
||||||
|
|
||||||
|
/** 点击动作类型 */
|
||||||
|
public static final String ACTION_TYPE_DETAIL = "DETAIL";
|
||||||
|
public static final String ACTION_TYPE_LAUNCH = "LAUNCH";
|
||||||
|
|
||||||
|
private RecommendConstants() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package io.as.modules.recommend.constants;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整屏推荐页槽位枚举(固定 7 个,不允许扩展时改表结构)
|
||||||
|
*
|
||||||
|
* 海报默认字段策略:竖图槽(PORTRAIT)取橘视厅 posterimg(448×672 竖版),
|
||||||
|
* 横图槽(MAIN/LANDSCAPE/MORE)取 thumbimg(448×252 横版);运营可用自定义 URL 覆盖。
|
||||||
|
*/
|
||||||
|
public enum RecommendSlot {
|
||||||
|
|
||||||
|
SLOT_01("slot_01", RecommendConstants.TYPE_MAIN, 700, 400, RecommendConstants.ACTION_TYPE_DETAIL, true),
|
||||||
|
SLOT_02("slot_02", RecommendConstants.TYPE_PORTRAIT, 340, 440, RecommendConstants.ACTION_TYPE_DETAIL, true),
|
||||||
|
SLOT_03("slot_03", RecommendConstants.TYPE_PORTRAIT, 340, 440, RecommendConstants.ACTION_TYPE_DETAIL, true),
|
||||||
|
SLOT_04("slot_04", RecommendConstants.TYPE_PORTRAIT, 340, 440, RecommendConstants.ACTION_TYPE_DETAIL, true),
|
||||||
|
SLOT_05("slot_05", RecommendConstants.TYPE_LANDSCAPE, 700, 300, RecommendConstants.ACTION_TYPE_DETAIL, true),
|
||||||
|
SLOT_06("slot_06", RecommendConstants.TYPE_LANDSCAPE, 700, 300, RecommendConstants.ACTION_TYPE_DETAIL, true),
|
||||||
|
SLOT_07("slot_07", RecommendConstants.TYPE_MORE, 340, 300, RecommendConstants.ACTION_TYPE_LAUNCH, false);
|
||||||
|
|
||||||
|
/** 槽位编码 */
|
||||||
|
private final String code;
|
||||||
|
/** 槽位类型 */
|
||||||
|
private final String type;
|
||||||
|
/** 宽 px */
|
||||||
|
private final int width;
|
||||||
|
/** 高 px */
|
||||||
|
private final int height;
|
||||||
|
/** 默认点击动作 */
|
||||||
|
private final String defaultActionType;
|
||||||
|
/** 是否必须配置影片(contentcode) */
|
||||||
|
private final boolean mediaRequired;
|
||||||
|
|
||||||
|
RecommendSlot(String code, String type, int width, int height, String defaultActionType, boolean mediaRequired) {
|
||||||
|
this.code = code;
|
||||||
|
this.type = type;
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
this.defaultActionType = defaultActionType;
|
||||||
|
this.mediaRequired = mediaRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getCode() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getType() {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getWidth() {
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getHeight() {
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getDefaultActionType() {
|
||||||
|
return defaultActionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isMediaRequired() {
|
||||||
|
return mediaRequired;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否横图槽位(默认海报取 thumbimg 横版) */
|
||||||
|
public boolean isLandscape() {
|
||||||
|
return !RecommendConstants.TYPE_PORTRAIT.equals(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 slotCode 查找枚举,找不到返回 null */
|
||||||
|
public static RecommendSlot byCode(String code) {
|
||||||
|
for (RecommendSlot slot : values()) {
|
||||||
|
if (slot.code.equals(code)) {
|
||||||
|
return slot;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全部槽位定义(供前端渲染配置页线框) */
|
||||||
|
public static Map<String, Object> definition() {
|
||||||
|
Map<String, Object> def = new LinkedHashMap<>();
|
||||||
|
for (RecommendSlot slot : values()) {
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("slotCode", slot.code);
|
||||||
|
item.put("slotType", slot.type);
|
||||||
|
item.put("width", slot.width);
|
||||||
|
item.put("height", slot.height);
|
||||||
|
item.put("defaultActionType", slot.defaultActionType);
|
||||||
|
item.put("mediaRequired", slot.mediaRequired);
|
||||||
|
item.put("posterField", slot.isLandscape() ? "thumbimg" : "posterimg");
|
||||||
|
def.put(slot.code, item);
|
||||||
|
}
|
||||||
|
return def;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package io.as.modules.recommend.controller;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSON;
|
||||||
|
import com.alibaba.fastjson.JSONArray;
|
||||||
|
import io.as.common.annotation.AuditAction;
|
||||||
|
import io.as.common.exception.ASException;
|
||||||
|
import io.as.common.utils.PageUtils;
|
||||||
|
import io.as.common.utils.R;
|
||||||
|
import io.as.modules.recommend.constants.RecommendSlot;
|
||||||
|
import io.as.modules.recommend.entity.RecommendPageEntity;
|
||||||
|
import io.as.modules.recommend.form.RecommendPageSaveForm;
|
||||||
|
import io.as.modules.recommend.service.RecommendPageService;
|
||||||
|
import io.swagger.annotations.Api;
|
||||||
|
import io.swagger.annotations.ApiOperation;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整屏推荐页配置管理
|
||||||
|
*
|
||||||
|
* 设备厂商 1920×1080 整屏推荐页:运营选片配置 7 个固定槽位,一键导出 zip 交付包
|
||||||
|
* (按槽位尺寸裁好的图片 + 影片编号 + am start 拉起命令)。
|
||||||
|
*/
|
||||||
|
@Api(tags = "推荐页配置")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/manage/recommend-page")
|
||||||
|
public class RecommendPageController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private RecommendPageService recommendPageService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页查询推荐页列表
|
||||||
|
*/
|
||||||
|
@ApiOperation("分页查询推荐页列表")
|
||||||
|
@GetMapping("/list")
|
||||||
|
public R list(@RequestParam Map<String, Object> params) {
|
||||||
|
PageUtils page = recommendPageService.queryPage(params);
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("page", page);
|
||||||
|
return R.dataSuccess(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推荐页详情(slots JSON 解析后返回)
|
||||||
|
*/
|
||||||
|
@ApiOperation("推荐页详情")
|
||||||
|
@GetMapping("/info/{id}")
|
||||||
|
public R info(@PathVariable("id") Long id) {
|
||||||
|
RecommendPageEntity entity = recommendPageService.getById(id);
|
||||||
|
if (entity == null || "DEL".equals(entity.getDeleteFlag())) {
|
||||||
|
throw new ASException("推荐页不存在: " + id);
|
||||||
|
}
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("recommendPage", entity);
|
||||||
|
try {
|
||||||
|
data.put("slots", JSON.parseArray(entity.getSlots()));
|
||||||
|
} catch (Exception e) {
|
||||||
|
data.put("slots", new JSONArray());
|
||||||
|
}
|
||||||
|
return R.dataSuccess(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 槽位定义(前端渲染配置页线框用)
|
||||||
|
*/
|
||||||
|
@ApiOperation("槽位定义")
|
||||||
|
@GetMapping("/slot-definitions")
|
||||||
|
public R slotDefinitions() {
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("slots", RecommendSlot.definition());
|
||||||
|
return R.dataSuccess(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新建或更新推荐页(有 id 更新,无 id 新建)
|
||||||
|
*/
|
||||||
|
@ApiOperation("新建或更新推荐页")
|
||||||
|
@AuditAction(value = "RECOMMEND_PAGE_SAVE", description = "保存推荐页配置")
|
||||||
|
@PostMapping("/save")
|
||||||
|
public R save(@RequestBody RecommendPageSaveForm form) {
|
||||||
|
RecommendPageEntity entity = recommendPageService.savePage(form);
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("recommendPage", entity);
|
||||||
|
return R.dataSuccess(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除推荐页(软删除)
|
||||||
|
*/
|
||||||
|
@ApiOperation("删除推荐页")
|
||||||
|
@AuditAction(value = "RECOMMEND_PAGE_DELETE", description = "删除推荐页配置")
|
||||||
|
@PostMapping("/delete")
|
||||||
|
public R delete(@RequestBody Long[] ids) {
|
||||||
|
if (ids == null || ids.length == 0) {
|
||||||
|
throw new ASException("ids不能为空");
|
||||||
|
}
|
||||||
|
recommendPageService.deleteByIds(ids);
|
||||||
|
return R.dataSuccess(new HashMap<>());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一键导出 zip 交付包(浏览器下载)
|
||||||
|
*/
|
||||||
|
@ApiOperation("一键导出zip交付包")
|
||||||
|
@AuditAction(value = "RECOMMEND_PAGE_EXPORT", description = "导出推荐页交付包")
|
||||||
|
@GetMapping("/export/{id}")
|
||||||
|
public void export(@PathVariable("id") Long id, HttpServletResponse response) {
|
||||||
|
recommendPageService.exportZip(id, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package io.as.modules.recommend.dao;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import io.as.modules.recommend.entity.RecommendPageEntity;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整屏推荐页配置 Dao
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface RecommendPageDao extends BaseMapper<RecommendPageEntity> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package io.as.modules.recommend.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整屏推荐页配置表(一期一条记录,历史全保留)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("recommend_page")
|
||||||
|
public class RecommendPageEntity implements Serializable {
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
/** 期名,如:2026年10月推荐 */
|
||||||
|
private String name;
|
||||||
|
/** 槽位配置JSON数组,7个元素 */
|
||||||
|
private String slots;
|
||||||
|
/** 备注 */
|
||||||
|
private String remark;
|
||||||
|
/** 删除标记 */
|
||||||
|
private String deleteFlag;
|
||||||
|
/** 创建时间 */
|
||||||
|
private Date createdAt;
|
||||||
|
/** 更新时间 */
|
||||||
|
private Date updatedAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package io.as.modules.recommend.form;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 推荐页保存表单
|
||||||
|
* <p>slots 由后端校验 7 个槽位齐全,并从橘视厅同步影片元数据。</p>
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class RecommendPageSaveForm {
|
||||||
|
|
||||||
|
/** 有 id 更新,无 id 新建 */
|
||||||
|
private Long id;
|
||||||
|
/** 期名 */
|
||||||
|
private String name;
|
||||||
|
/** 备注 */
|
||||||
|
private String remark;
|
||||||
|
/** 槽位配置,7 个元素 */
|
||||||
|
private List<SlotForm> slots;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单槽位表单(前端只传必要字段,元数据由后端同步)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public static class SlotForm {
|
||||||
|
/** 槽位编码 slot_01 ~ slot_07 */
|
||||||
|
private String slotCode;
|
||||||
|
/** 影片 contentcode(slot_07 更多入口可为空) */
|
||||||
|
private String contentcode;
|
||||||
|
/** 影片名(冗余展示,后端以橘视厅同步为准) */
|
||||||
|
private String mediaName;
|
||||||
|
/** 自定义海报 URL(横图槽位补横版图用;slot_07 必填) */
|
||||||
|
private String posterUrl;
|
||||||
|
/** 自定义拉起命令(不传则按动作类型取默认值,支持 {contentcode}/{mediaName} 占位符) */
|
||||||
|
private String actionParam;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package io.as.modules.recommend.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
import io.as.common.utils.PageUtils;
|
||||||
|
import io.as.modules.recommend.entity.RecommendPageEntity;
|
||||||
|
import io.as.modules.recommend.form.RecommendPageSaveForm;
|
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整屏推荐页配置服务
|
||||||
|
*/
|
||||||
|
public interface RecommendPageService extends IService<RecommendPageEntity> {
|
||||||
|
|
||||||
|
/** 分页查询 */
|
||||||
|
PageUtils queryPage(Map<String, Object> params);
|
||||||
|
|
||||||
|
/** 新建或更新(校验槽位 + 橘视厅元数据同步) */
|
||||||
|
RecommendPageEntity savePage(RecommendPageSaveForm form);
|
||||||
|
|
||||||
|
/** 软删除 */
|
||||||
|
void deleteByIds(Long[] ids);
|
||||||
|
|
||||||
|
/** 一键导出 zip 交付包(图片裁剪 + config.json + README.txt) */
|
||||||
|
void exportZip(Long id, HttpServletResponse response);
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
package io.as.modules.recommend.service.impl;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSON;
|
||||||
|
import com.alibaba.fastjson.JSONArray;
|
||||||
|
import com.alibaba.fastjson.JSONObject;
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||||
|
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
|
import io.as.common.exception.ASException;
|
||||||
|
import io.as.common.utils.PageUtils;
|
||||||
|
import io.as.common.utils.Query;
|
||||||
|
import io.as.modules.media.client.JetsenMediaClient;
|
||||||
|
import io.as.modules.recommend.constants.RecommendConstants;
|
||||||
|
import io.as.modules.recommend.constants.RecommendSlot;
|
||||||
|
import io.as.modules.recommend.dao.RecommendPageDao;
|
||||||
|
import io.as.modules.recommend.entity.RecommendPageEntity;
|
||||||
|
import io.as.modules.recommend.form.RecommendPageSaveForm;
|
||||||
|
import io.as.modules.recommend.service.RecommendPageService;
|
||||||
|
import net.coobird.thumbnailator.Thumbnails;
|
||||||
|
import net.coobird.thumbnailator.geometry.Positions;
|
||||||
|
import org.apache.commons.lang.StringUtils;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import javax.servlet.http.HttpServletResponse;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.net.URLEncoder;
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import java.util.zip.ZipOutputStream;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 整屏推荐页配置服务实现
|
||||||
|
*
|
||||||
|
* 保存时从橘视厅同步影片元数据(getcontentlist.do 按 contentcode 直查,实测可靠),
|
||||||
|
* 导出时下载海报、居中裁剪到槽位精确尺寸后打包 zip 交付包。
|
||||||
|
*/
|
||||||
|
@Service("recommendPageService")
|
||||||
|
public class RecommendPageServiceImpl extends ServiceImpl<RecommendPageDao, RecommendPageEntity> implements RecommendPageService {
|
||||||
|
|
||||||
|
private static final Logger logger = LoggerFactory.getLogger(RecommendPageServiceImpl.class);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JetsenMediaClient jetsenMediaClient;
|
||||||
|
@Autowired
|
||||||
|
private RestTemplate restTemplate;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public PageUtils queryPage(Map<String, Object> params) {
|
||||||
|
String name = (String) params.get("name");
|
||||||
|
IPage<RecommendPageEntity> page = this.page(
|
||||||
|
new Query<RecommendPageEntity>().getPage(params),
|
||||||
|
new QueryWrapper<RecommendPageEntity>()
|
||||||
|
.like(StringUtils.isNotBlank(name), "name", name)
|
||||||
|
.eq("delete_flag", "UNDEL")
|
||||||
|
.orderByDesc("created_at")
|
||||||
|
);
|
||||||
|
return new PageUtils(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public RecommendPageEntity savePage(RecommendPageSaveForm form) {
|
||||||
|
if (form == null || StringUtils.isBlank(form.getName())) {
|
||||||
|
throw new ASException("期名不能为空");
|
||||||
|
}
|
||||||
|
if (form.getSlots() == null || form.getSlots().size() != RecommendSlot.values().length) {
|
||||||
|
throw new ASException("槽位配置必须为 " + RecommendSlot.values().length + " 个");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 slotCode 齐全且不重复
|
||||||
|
Set<String> codes = new HashSet<>();
|
||||||
|
for (RecommendPageSaveForm.SlotForm slot : form.getSlots()) {
|
||||||
|
RecommendSlot def = RecommendSlot.byCode(slot.getSlotCode());
|
||||||
|
if (def == null) {
|
||||||
|
throw new ASException("非法槽位编码: " + slot.getSlotCode());
|
||||||
|
}
|
||||||
|
if (!codes.add(slot.getSlotCode())) {
|
||||||
|
throw new ASException("槽位编码重复: " + slot.getSlotCode());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 逐槽位构建存储结构(橘视厅元数据同步 + 海报策略)
|
||||||
|
JSONArray slotArray = new JSONArray();
|
||||||
|
for (RecommendPageSaveForm.SlotForm slotForm : form.getSlots()) {
|
||||||
|
RecommendSlot def = RecommendSlot.byCode(slotForm.getSlotCode());
|
||||||
|
JSONObject slot = new JSONObject(true);
|
||||||
|
slot.put("slotCode", def.getCode());
|
||||||
|
slot.put("slotType", def.getType());
|
||||||
|
slot.put("width", def.getWidth());
|
||||||
|
slot.put("height", def.getHeight());
|
||||||
|
slot.put("actionType", def.getDefaultActionType());
|
||||||
|
|
||||||
|
String contentcode = StringUtils.trimToNull(slotForm.getContentcode());
|
||||||
|
if (def.isMediaRequired() && contentcode == null) {
|
||||||
|
throw new ASException("槽位 " + def.getCode() + " 必须配置影片 contentcode");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contentcode != null) {
|
||||||
|
// 按 contentcode 直查橘视厅(getcontentlist.do),同步元数据
|
||||||
|
JSONObject media = fetchMediaByCode(contentcode);
|
||||||
|
if (media == null) {
|
||||||
|
throw new ASException("contentcode 无效: " + contentcode);
|
||||||
|
}
|
||||||
|
slot.put("contentcode", contentcode);
|
||||||
|
slot.put("mediaName", media.getString("name"));
|
||||||
|
slot.put("rawMedia", media);
|
||||||
|
} else {
|
||||||
|
slot.put("contentcode", null);
|
||||||
|
slot.put("mediaName", StringUtils.defaultIfBlank(slotForm.getMediaName(), "更多影片"));
|
||||||
|
slot.put("rawMedia", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 海报策略:运营自定义 URL 优先;否则竖图槽取 posterimg、横图槽取 thumbimg(fallback posterimg)
|
||||||
|
boolean custom = StringUtils.isNotBlank(slotForm.getPosterUrl());
|
||||||
|
if (!def.isMediaRequired() && !custom) {
|
||||||
|
throw new ASException("槽位 " + def.getCode() + "(更多入口)必须配置海报 URL");
|
||||||
|
}
|
||||||
|
String posterUrl;
|
||||||
|
if (custom) {
|
||||||
|
posterUrl = slotForm.getPosterUrl().trim();
|
||||||
|
} else {
|
||||||
|
JSONObject raw = slot.getJSONObject("rawMedia");
|
||||||
|
String landscapeField = raw.getString("thumbimg");
|
||||||
|
String portraitField = raw.getString("posterimg");
|
||||||
|
if (def.isLandscape() && StringUtils.isNotBlank(landscapeField)) {
|
||||||
|
posterUrl = landscapeField;
|
||||||
|
} else {
|
||||||
|
posterUrl = portraitField;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank(posterUrl)) {
|
||||||
|
throw new ASException("影片 " + slot.getString("mediaName") + " 无可用海报");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slot.put("posterUrl", posterUrl);
|
||||||
|
slot.put("customPoster", custom);
|
||||||
|
|
||||||
|
// 拉起命令:自定义优先,否则按动作类型填默认
|
||||||
|
String actionParam;
|
||||||
|
if (StringUtils.isNotBlank(slotForm.getActionParam())) {
|
||||||
|
actionParam = slotForm.getActionParam().trim();
|
||||||
|
} else if (RecommendConstants.ACTION_TYPE_LAUNCH.equals(def.getDefaultActionType())) {
|
||||||
|
actionParam = RecommendConstants.ACTION_LAUNCH_HOME;
|
||||||
|
} else {
|
||||||
|
actionParam = RecommendConstants.ACTION_DETAIL_BY_ID;
|
||||||
|
}
|
||||||
|
slot.put("actionParam", actionParam);
|
||||||
|
|
||||||
|
slotArray.add(slot);
|
||||||
|
}
|
||||||
|
|
||||||
|
Date now = new Date();
|
||||||
|
RecommendPageEntity entity;
|
||||||
|
if (form.getId() != null) {
|
||||||
|
entity = this.getById(form.getId());
|
||||||
|
if (entity == null || "DEL".equals(entity.getDeleteFlag())) {
|
||||||
|
throw new ASException("推荐页不存在: " + form.getId());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
entity = new RecommendPageEntity();
|
||||||
|
entity.setDeleteFlag("UNDEL");
|
||||||
|
entity.setCreatedAt(now);
|
||||||
|
}
|
||||||
|
entity.setName(form.getName().trim());
|
||||||
|
entity.setRemark(form.getRemark());
|
||||||
|
entity.setSlots(JSON.toJSONString(slotArray));
|
||||||
|
entity.setUpdatedAt(now);
|
||||||
|
this.saveOrUpdate(entity);
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void deleteByIds(Long[] ids) {
|
||||||
|
for (Long id : ids) {
|
||||||
|
RecommendPageEntity entity = new RecommendPageEntity();
|
||||||
|
entity.setId(id);
|
||||||
|
entity.setDeleteFlag("DEL");
|
||||||
|
entity.setUpdatedAt(new Date());
|
||||||
|
this.updateById(entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void exportZip(Long id, HttpServletResponse response) {
|
||||||
|
RecommendPageEntity entity = this.getById(id);
|
||||||
|
if (entity == null || "DEL".equals(entity.getDeleteFlag())) {
|
||||||
|
throw new ASException("推荐页不存在: " + id);
|
||||||
|
}
|
||||||
|
JSONArray slots = JSON.parseArray(entity.getSlots());
|
||||||
|
|
||||||
|
// 先在内存构建完整 zip,全部成功后再写响应,避免中途失败返回损坏的 zip 流
|
||||||
|
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||||
|
try {
|
||||||
|
try (ZipOutputStream zos = new ZipOutputStream(buffer)) {
|
||||||
|
// 1. 槽位图片:下载海报 → 居中裁剪到精确尺寸 → PNG
|
||||||
|
for (int i = 0; i < slots.size(); i++) {
|
||||||
|
JSONObject slot = slots.getJSONObject(i);
|
||||||
|
String slotCode = slot.getString("slotCode");
|
||||||
|
int width = slot.getIntValue("width");
|
||||||
|
int height = slot.getIntValue("height");
|
||||||
|
byte[] poster = downloadPoster(slot.getString("posterUrl"), slotCode);
|
||||||
|
byte[] cropped = cropToSize(poster, width, height, slotCode);
|
||||||
|
zos.putNextEntry(new ZipEntry("images/" + slotCode + ".png"));
|
||||||
|
zos.write(cropped);
|
||||||
|
zos.closeEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. recommend_config.json
|
||||||
|
zos.putNextEntry(new ZipEntry("recommend_config.json"));
|
||||||
|
zos.write(buildConfigJson(entity, slots).getBytes("UTF-8"));
|
||||||
|
zos.closeEntry();
|
||||||
|
|
||||||
|
// 3. README.txt
|
||||||
|
zos.putNextEntry(new ZipEntry("README.txt"));
|
||||||
|
zos.write(buildReadme(entity, slots).getBytes("UTF-8"));
|
||||||
|
zos.closeEntry();
|
||||||
|
}
|
||||||
|
} catch (ASException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("推荐页导出失败: id={}", id, e);
|
||||||
|
throw new ASException("导出失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
// zip 构建成功,写响应
|
||||||
|
try {
|
||||||
|
String fileName = "推荐页_" + entity.getName() + ".zip";
|
||||||
|
response.setContentType("application/zip");
|
||||||
|
response.setHeader("Content-Disposition",
|
||||||
|
"attachment; filename*=UTF-8''" + URLEncoder.encode(fileName, "UTF-8"));
|
||||||
|
response.getOutputStream().write(buffer.toByteArray());
|
||||||
|
response.getOutputStream().flush();
|
||||||
|
} catch (Exception e) {
|
||||||
|
logger.error("推荐页导出响应写入失败: id={}", id, e);
|
||||||
|
throw new ASException("导出失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 私有方法 ====================
|
||||||
|
|
||||||
|
/** 按 contentcode 精确查询橘视厅内容(getcontentlist.do 传 contentcode,电影返回自身) */
|
||||||
|
private JSONObject fetchMediaByCode(String contentcode) {
|
||||||
|
JSONObject resp = jetsenMediaClient.getEpisodes(contentcode, 1, 10);
|
||||||
|
Object result = resp.get("result");
|
||||||
|
if (result instanceof JSONArray) {
|
||||||
|
for (Object o : (JSONArray) result) {
|
||||||
|
if (o instanceof JSONObject) {
|
||||||
|
JSONObject item = (JSONObject) o;
|
||||||
|
if (contentcode.equals(item.getString("contentcode"))
|
||||||
|
|| contentcode.equals(item.getString("code"))) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 下载海报,失败中止导出 */
|
||||||
|
private byte[] downloadPoster(String url, String slotCode) {
|
||||||
|
try {
|
||||||
|
byte[] data = restTemplate.getForObject(url, byte[].class);
|
||||||
|
if (data == null || data.length == 0) {
|
||||||
|
throw new ASException("槽位 " + slotCode + " 海报下载失败: 响应为空");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
} catch (ASException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new ASException("槽位 " + slotCode + " 海报下载失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 居中裁剪到槽位精确尺寸(cover 模式:短边贴齐、长边居中裁) */
|
||||||
|
private byte[] cropToSize(byte[] source, int width, int height, String slotCode) {
|
||||||
|
try {
|
||||||
|
BufferedImage cropped = Thumbnails.of(new ByteArrayInputStream(source))
|
||||||
|
.size(width, height)
|
||||||
|
.crop(Positions.CENTER)
|
||||||
|
.asBufferedImage();
|
||||||
|
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(cropped, "png", baos);
|
||||||
|
return baos.toByteArray();
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new ASException("槽位 " + slotCode + " 图片裁剪失败: " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建 recommend_config.json(占位符替换为真实值) */
|
||||||
|
private String buildConfigJson(RecommendPageEntity entity, JSONArray slots) {
|
||||||
|
Map<String, Object> config = new LinkedHashMap<>();
|
||||||
|
config.put("name", entity.getName());
|
||||||
|
config.put("layout", "FULL_SCREEN_7");
|
||||||
|
config.put("exportedAt", new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()));
|
||||||
|
|
||||||
|
List<Map<String, Object>> slotList = new ArrayList<>();
|
||||||
|
for (int i = 0; i < slots.size(); i++) {
|
||||||
|
JSONObject slot = slots.getJSONObject(i);
|
||||||
|
Map<String, Object> item = new LinkedHashMap<>();
|
||||||
|
item.put("slotCode", slot.getString("slotCode"));
|
||||||
|
item.put("slotType", slot.getString("slotType"));
|
||||||
|
item.put("width", slot.getIntValue("width"));
|
||||||
|
item.put("height", slot.getIntValue("height"));
|
||||||
|
item.put("image", "images/" + slot.getString("slotCode") + ".png");
|
||||||
|
item.put("actionType", slot.getString("actionType"));
|
||||||
|
item.put("contentcode", slot.getString("contentcode"));
|
||||||
|
item.put("mediaName", slot.getString("mediaName"));
|
||||||
|
item.put("actionParam", resolveActionParam(slot));
|
||||||
|
slotList.add(item);
|
||||||
|
}
|
||||||
|
config.put("slots", slotList);
|
||||||
|
return JSON.toJSONString(config, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 构建交付说明 README.txt */
|
||||||
|
private String buildReadme(RecommendPageEntity entity, JSONArray slots) {
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("整屏推荐页交付说明(期名:").append(entity.getName()).append(")\n\n");
|
||||||
|
sb.append("排版:1920×1080 两行网格,上行 主推(700×400) + 3 竖图(340×440),\n");
|
||||||
|
sb.append("下行 2 横图(700×300) + 更多入口(340×300),槽位间距 20px,左右边距 70px。\n\n");
|
||||||
|
sb.append("动作规则:\n");
|
||||||
|
sb.append("- actionType=DETAIL:按节目编号拉起影片,推荐使用 media_id 方式(编号精确,无重名风险):\n");
|
||||||
|
sb.append(" ").append(RecommendConstants.ACTION_DETAIL_BY_ID).append("\n");
|
||||||
|
sb.append(" 备选按影片名拉起(存在重名风险):\n");
|
||||||
|
sb.append(" ").append(RecommendConstants.ACTION_DETAIL_BY_NAME).append("\n");
|
||||||
|
sb.append("- actionType=LAUNCH:直接拉起橘视厅应用:\n");
|
||||||
|
sb.append(" ").append(RecommendConstants.ACTION_LAUNCH_HOME).append("\n");
|
||||||
|
sb.append("- actionParam 为完整可执行命令,已替换真实值,可直接使用\n\n");
|
||||||
|
sb.append("槽位清单:\n");
|
||||||
|
for (int i = 0; i < slots.size(); i++) {
|
||||||
|
JSONObject slot = slots.getJSONObject(i);
|
||||||
|
sb.append(slot.getString("slotCode")).append(" ")
|
||||||
|
.append(slot.getString("slotType")).append(" ");
|
||||||
|
if (slot.getString("contentcode") != null) {
|
||||||
|
sb.append("影片《").append(slot.getString("mediaName")).append("》 ")
|
||||||
|
.append("contentcode=").append(slot.getString("contentcode"));
|
||||||
|
} else {
|
||||||
|
sb.append("动作=拉起橘视厅");
|
||||||
|
}
|
||||||
|
sb.append(" 图片=images/").append(slot.getString("slotCode")).append(".png\n");
|
||||||
|
}
|
||||||
|
if (StringUtils.isNotBlank(entity.getRemark())) {
|
||||||
|
sb.append("\n备注:").append(entity.getRemark()).append("\n");
|
||||||
|
}
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 导出时替换拉起命令占位符 {contentcode} / {mediaName} */
|
||||||
|
private String resolveActionParam(JSONObject slot) {
|
||||||
|
String actionParam = slot.getString("actionParam");
|
||||||
|
if (actionParam == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String contentcode = slot.getString("contentcode");
|
||||||
|
String mediaName = slot.getString("mediaName");
|
||||||
|
return actionParam
|
||||||
|
.replace("{contentcode}", contentcode == null ? "" : contentcode)
|
||||||
|
.replace("{mediaName}", mediaName == null ? "" : mediaName);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user