Compare commits
3 Commits
03e5f6fbf2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b537168895 | ||
|
|
73c34ddefe | ||
|
|
d9546840d3 |
48
API_DOC.md
48
API_DOC.md
@@ -252,6 +252,54 @@
|
||||
| `/manage/partner-card/stock` | GET | partner库存查询 |
|
||||
| `/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签名鉴权)
|
||||
|
||||
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 # 排版与拉起规则说明
|
||||
```
|
||||
126
JST-INTERFACE-REFERENCE.md
Normal file
126
JST-INTERFACE-REFERENCE.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# 橘视厅接口参考手册(日常排查用)
|
||||
|
||||
> 2026-09-09 整理。来源:release App `jiecheng_business1.3.27` 抓包 + 后端实测 + 同事现场 curl 验证。
|
||||
|
||||
---
|
||||
|
||||
## 一、基础凭证(release App 写死,我方沿用)
|
||||
|
||||
```
|
||||
sptoken = 9006874392099a89506142c6df9e3314
|
||||
signKey = alltvjs
|
||||
portalcode = PCODE-JC
|
||||
channelid = 001
|
||||
```
|
||||
|
||||
签名算法:`sign = MD5(sptoken + timeshift + portalcode + signKey).toUpperCase()`
|
||||
|
||||
---
|
||||
|
||||
## 二、CDN 节点与数据差异(核心坑)
|
||||
|
||||
### 现网 servhub(DNS 同 IP 但媒资库版本不同!)
|
||||
|
||||
| 节点 | 完整 URL | release App 命中 | 说明 |
|
||||
|---|---|---|---|
|
||||
| **ep01** | `https://servhub-ep01.future.jetsentv.com/` | ✅ 实际走这个 | 最新片(8月中下旬上线的)、正确片名 |
|
||||
| **ep03** | `https://servhub-ep03.future.jetsentv.com/` | ❌ 已弃用 | 媒资滞后,新片缺失、片名被改写 |
|
||||
|
||||
### 实测对比(ep01 vs ep03)
|
||||
|
||||
| 片名 | contentcode | ep01 | ep03 |
|
||||
|---|---|---|---|
|
||||
| 后悔没有好好爱过你 | `23010022026090109192132084` | ✅ 同名 | ✅ 同名 |
|
||||
| 乌女山怪谈 | `23010022026090109192132085` | ✅ 同名 | ✅ 同名 |
|
||||
| 千门八将之群龙夺宝 | `23010022026082517202131945` | ✅ 同名 | ✅ 同名 |
|
||||
| 日出江花 | `23010022026082416335131944` | ✅ 同名 | ✅ 同名 |
|
||||
| 刺客的身份 | `23010022026080316521131798` | ✅ 同名 | ✅ 同名 |
|
||||
| 湘西异闻录 | `23010022026072809450131743` | ✅ 原名湘西异闻录 | ⚠️ **显示为血夜行** |
|
||||
| 海墓龙棺 | `23010022026081017241131881` | ✅ 有 | ❌ **完全查不到**(sizetotal=0) |
|
||||
|
||||
**结论**:后端媒资代理必须走 **ep01**,否则新片查不到、老片片名错位。
|
||||
|
||||
### 预发布 external
|
||||
|
||||
| 节点 | 完整 URL | 说明 |
|
||||
|---|---|---|
|
||||
| external-ep01 | `https://external-ep01.future.jetsentv.com:8010` | App 预发布主入口 |
|
||||
| external-ep02 | `https://external-ep02.future.jetsentv.com` | 预发布备用 |
|
||||
|
||||
---
|
||||
|
||||
## 三、API 主入口(登录/激活/支付,不走 servhub)
|
||||
|
||||
| 环境 | 完整 URL | 用途 |
|
||||
|---|---|---|
|
||||
| 现网 | `https://api.future.jetsentv.com/` | ums_api(login/logout/isLogin)、ccms_api(activateCard/queryUsable)、bms_api(queryOrderList/getPayResultCycle) |
|
||||
| 预发布 | `https://external.future.jetsentv.com/` | 同上 |
|
||||
|
||||
> **不受 ep01/ep03 影响**,API 统一网关。
|
||||
|
||||
---
|
||||
|
||||
## 四、图片 CDN
|
||||
|
||||
| 域名 | 用途 |
|
||||
|---|---|
|
||||
| `https://imgcms.jetsenx.huashi.tv` | **主**:新片海报(海墓龙棺、湘西异闻录等) |
|
||||
| `https://img01.jetsenx.huashi.tv` | 老片海报 |
|
||||
|
||||
---
|
||||
|
||||
## 五、华视网聚旧域名(已全部失效)
|
||||
|
||||
| 域名 | 当前状态 |
|
||||
|---|---|
|
||||
| `https://api.jetsenx.huashi.tv` | 502 Bad Gateway |
|
||||
| `https://external.jetsenx.huashi.tv` | 404 Not Found |
|
||||
|
||||
---
|
||||
|
||||
## 六、我方后端配置项
|
||||
|
||||
文件:`src/main/java/io/as/modules/media/config/JetsenMediaConfig.java`
|
||||
|
||||
```yaml
|
||||
jetsen:
|
||||
media:
|
||||
base-url: https://servhub-ep01.future.jetsentv.com/ # ← ep01,关键!
|
||||
path-prefix: /IPTVPortalInterface
|
||||
sp-token: 9006874392099a89506142c6df9e3314
|
||||
portal-code: PCODE-JC
|
||||
sign-key: alltvjs
|
||||
channel-id: "001"
|
||||
```
|
||||
|
||||
橘视厅 API(登录/激活)配置在数据库 `sys_config` 的 `JST_PLATFORM_CONFIG`:
|
||||
```json
|
||||
{
|
||||
"jstApiBaseUrl": "https://api.future.jetsentv.com",
|
||||
"accesser": "JSTASJD",
|
||||
"secret": "kMeWsBu0QsTXpnft",
|
||||
"agCode": "1100101010200492358120169",
|
||||
"serviceComboCode": "1100121010200494755850172",
|
||||
"serviceGroupCode": "1100122010201752209710182",
|
||||
"businessLine": "4100450011381755998420012"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、日常排查 Checklist
|
||||
|
||||
**媒资查不到 / 片名错位**
|
||||
1. 先确认 contentcode 对不对(App 埋点里取)
|
||||
2. 用 ep01 手工 curl 确认:`curl -X POST https://servhub-ep01.future.jetsentv.com/IPTVPortalInterface/content/getcontentlist.do -H "Content-Type: application/json" -d '{"channelid":"001","contentcode":"xxx","portalcode":"PCODE-JC","sign":"...","sptoken":"9006874392099a89506142c6df9e3314","timeshift":"..."}'`
|
||||
3. 如果 ep01 有、ep03 没有 → 后端 JetsenMediaConfig.baseUrl 被误改回 ep03 了
|
||||
4. 如果两边都没有 → contentcode 不是橘视厅原生的,可能是 App 本地别名/运营配置的
|
||||
|
||||
**SDK init 失败 / 播放鉴权失败**
|
||||
1. 确认橘视厅 API 走的是 `api.future.jetsentv.com`(不走 servhub)
|
||||
2. thirdCardNo 派生:`thirdCardNo + "_bul_" + businessLine`
|
||||
3. businessLine = `4100450011381755998420012`(代销模式用的)
|
||||
|
||||
**激活失败 "设备不存在"**
|
||||
1. 发卡后异步激活必须等事务提交再触发(TransactionSynchronization.afterCommit)
|
||||
2. 重试任务 `dayCardActivateRetryTask` 每 5 分钟执行,已覆盖伙伴代销卡(无 orderNo 的也能重激)
|
||||
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>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.coobird</groupId>
|
||||
<artifactId>thumbnailator</artifactId>
|
||||
<version>0.4.20</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
|
||||
@@ -51,7 +51,7 @@ public class RsaUtil {
|
||||
String encryptData = encrypt(publicKeyStr, plainData);
|
||||
System.out.println("加密后的encryptData为:" + encryptData);
|
||||
|
||||
String url = "https://external.future.jetsentv.com/ums_api/api/v1/third/login"
|
||||
String url = "https://api.future.jetsentv.com/ums_api/api/v1/third/login"
|
||||
+ "?timestamp=" + timestamp
|
||||
+ "&nonce=" + nonce
|
||||
+ "&serviceComboCode=1100121010200492753300170";
|
||||
|
||||
@@ -24,6 +24,7 @@ import io.as.modules.device.dto.DeviceOrgContext;
|
||||
import io.as.modules.device.entity.DeviceEntity;
|
||||
import io.as.modules.device.service.DeviceResolveService;
|
||||
import io.as.modules.openapi.sign.ApiDeviceSignService;
|
||||
import io.as.modules.openapi.service.PartnerCardPoolService;
|
||||
import io.as.modules.jst.service.JushitingApiService;
|
||||
import io.as.modules.jst.service.JstActivateCardResult;
|
||||
import io.as.modules.payment.service.WechatPayService;
|
||||
@@ -89,6 +90,9 @@ public class DayCardOrderServiceImpl extends ServiceImpl<DayCardOrderDao, DayCar
|
||||
@Autowired
|
||||
private SysOrgService sysOrgService;
|
||||
|
||||
@Autowired
|
||||
private PartnerCardPoolService partnerCardPoolService;
|
||||
|
||||
@Value("${renren.daycard.order-timeout-minutes:15}")
|
||||
private int orderTimeoutMinutes;
|
||||
|
||||
@@ -740,20 +744,21 @@ public class DayCardOrderServiceImpl extends ServiceImpl<DayCardOrderDao, DayCar
|
||||
int retried = 0;
|
||||
for (DayCardPoolEntity pool : failedPools) {
|
||||
try {
|
||||
// 查询关联订单
|
||||
if (StringUtils.isBlank(pool.getOrderNo())) {
|
||||
continue;
|
||||
if (StringUtils.isNotBlank(pool.getOrderNo())) {
|
||||
// 订单支付的卡:查询关联订单后走订单激活
|
||||
DayCardOrderEntity order = getByOrderNo(pool.getOrderNo());
|
||||
if (order == null) {
|
||||
continue;
|
||||
}
|
||||
// 重置激活状态为 PENDING
|
||||
updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_PENDING, null, null);
|
||||
// 通过 selfProxy 调用确保 @Async 生效
|
||||
selfProxy.asyncActivateCard(order);
|
||||
} else if (pool.getDeviceId() != null) {
|
||||
// 伙伴代销消耗的卡(无订单):走伙伴激活重试
|
||||
updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_PENDING, null, null);
|
||||
partnerCardPoolService.retryActivate(pool.getId(), pool.getDeviceId());
|
||||
}
|
||||
DayCardOrderEntity order = getByOrderNo(pool.getOrderNo());
|
||||
if (order == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 重置激活状态为 PENDING
|
||||
updateActivateStatus(pool.getId(), DayCardConstants.ACTIVATE_STATUS_PENDING, null, null);
|
||||
|
||||
// 通过 selfProxy 调用确保 @Async 生效
|
||||
selfProxy.asyncActivateCard(order);
|
||||
retried++;
|
||||
} catch (Exception e) {
|
||||
logger.error("重试激活天卡失败: poolId={}, orderNo={}", pool.getId(), pool.getOrderNo(), e);
|
||||
|
||||
@@ -16,7 +16,7 @@ import org.springframework.stereotype.Component;
|
||||
public class JetsenMediaConfig {
|
||||
|
||||
/** 媒资接口基础地址 */
|
||||
private String baseUrl = "https://servhub-ep03.future.jetsentv.com/";
|
||||
private String baseUrl = "https://servhub-ep01.future.jetsentv.com/";
|
||||
|
||||
/** 接口路径前缀 */
|
||||
private String pathPrefix = "/IPTVPortalInterface";
|
||||
|
||||
@@ -85,4 +85,12 @@ public interface PartnerCardPoolService extends IService<PartnerCardPoolEntity>
|
||||
* @return 实际回退数量
|
||||
*/
|
||||
int revert(String partnerId, int quantity);
|
||||
|
||||
/**
|
||||
* 重试激活伙伴消耗的天卡(无订单场景,供激活失败补偿任务调用)
|
||||
*
|
||||
* @param poolId 天卡池ID
|
||||
* @param deviceId 设备ID
|
||||
*/
|
||||
void retryActivate(Long poolId, Long deviceId);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.core.task.TaskExecutor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
@@ -243,9 +245,19 @@ public class PartnerCardPoolServiceImpl extends ServiceImpl<PartnerCardPoolDao,
|
||||
this.updateById(pcp);
|
||||
|
||||
// 7. 异步激活天卡(调橘视厅)
|
||||
// 注意:必须等事务提交后再触发,否则异步任务读不到未提交的设备/天卡记录(曾导致"设备不存在"激活失败)
|
||||
final Long poolId = pcp.getPoolId();
|
||||
final Long deviceId = device.getId();
|
||||
activateExecutor.execute(() -> activateCardAsync(poolId, deviceId));
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
activateExecutor.execute(() -> activateCardAsync(poolId, deviceId));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
activateExecutor.execute(() -> activateCardAsync(poolId, deviceId));
|
||||
}
|
||||
|
||||
// 8. 返回天卡信息(异步激活中,客户端通过checkBenefits轮询确认)
|
||||
// thirdCardNo不暴露给第三方,App通过init+checkBenefits自行获取播放凭证
|
||||
@@ -290,6 +302,17 @@ public class PartnerCardPoolServiceImpl extends ServiceImpl<PartnerCardPoolDao,
|
||||
return cards.size();
|
||||
}
|
||||
|
||||
// ==================== 重试激活 ====================
|
||||
|
||||
@Override
|
||||
public void retryActivate(Long poolId, Long deviceId) {
|
||||
if (poolId == null || deviceId == null) {
|
||||
return;
|
||||
}
|
||||
logger.info("重试激活伙伴天卡: poolId={}, deviceId={}", poolId, deviceId);
|
||||
activateExecutor.execute(() -> activateCardAsync(poolId, deviceId));
|
||||
}
|
||||
|
||||
/** 按设备标识查找已有设备,找不到返回新实体(标识优先级:mac > wifiMac > deviceUid > deviceSn) */
|
||||
private DeviceEntity findOrCreateDevice(String mac, String wifiMac, String deviceUid, String deviceSn) {
|
||||
DeviceEntity existing = null;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ renren:
|
||||
# 橘视厅媒资接口配置(对应 Android 端 ApiConfig.kt)
|
||||
jetsen:
|
||||
media:
|
||||
base-url: ${JETSEN_MEDIA_BASE_URL:https://servhub-ep03.future.jetsentv.com/}
|
||||
base-url: ${JETSEN_MEDIA_BASE_URL:https://servhub-ep01.future.jetsentv.com/}
|
||||
path-prefix: ${JETSEN_MEDIA_PATH_PREFIX:/IPTVPortalInterface}
|
||||
sp-token: ${JETSEN_MEDIA_SP_TOKEN:9006874392099a89506142c6df9e3314}
|
||||
portal-code: ${JETSEN_MEDIA_PORTAL_CODE:PCODE-JC}
|
||||
|
||||
52
start.sh
52
start.sh
@@ -1,52 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# ===========================================================
|
||||
# start.sh — daycard-openapi 后端一键启动脚本
|
||||
# 项目: Spring Boot 2.6.6 / Java 17 / 端口 8084
|
||||
#
|
||||
# 用法:
|
||||
# ./start.sh # clean 后启动(推荐)
|
||||
# ./start.sh --no-clean # 跳过 clean,加速重复启动
|
||||
# ./start.sh -Dspring-boot.run.jvmArguments="-Dserver.port=8090"
|
||||
# # 透传任意 maven / spring-boot 参数
|
||||
# ===========================================================
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# ---- 1. 选定 Java 17(找不到则回退 Java 8)----
|
||||
JDK17_PREF="/Users/zhaoguodong/Library/Java/JavaVirtualMachines/jbr-17.0.14/Contents/Home"
|
||||
if [[ -d "$JDK17_PREF" ]]; then
|
||||
export JAVA_HOME="$JDK17_PREF"
|
||||
elif JDK17_AUTO="$(/usr/libexec/java_home -v 17 2>/dev/null)"; then
|
||||
export JAVA_HOME="$JDK17_AUTO"
|
||||
elif JDK8_AUTO="$(/usr/libexec/java_home -v 1.8 2>/dev/null)"; then
|
||||
export JAVA_HOME="$JDK8_AUTO"
|
||||
echo "⚠️ 未找到 Java 17,回退到 Java 8" >&2
|
||||
else
|
||||
echo "❌ 找不到 Java 17 或 Java 8,请安装 JDK 或修改本脚本里的 JAVA_HOME" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "==> JAVA_HOME=$JAVA_HOME"
|
||||
"$JAVA_HOME/bin/java" -version
|
||||
|
||||
# ---- 2. 解析参数 ----
|
||||
DO_CLEAN=1
|
||||
PASSTHRU=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-clean) DO_CLEAN=0 ;;
|
||||
--clean) DO_CLEAN=1 ;;
|
||||
*) PASSTHRU+=("$arg") ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- 3. 启动 ----
|
||||
# clean 可清掉 target 中残留的 stale class(曾出现 AsApplication 2.class
|
||||
# 导致“找到两个 main class”无法启动的问题)。
|
||||
GOAL="spring-boot:run"
|
||||
if [[ "$DO_CLEAN" -eq 1 ]]; then
|
||||
GOAL="clean spring-boot:run"
|
||||
echo "==> mvn clean spring-boot:run"
|
||||
else
|
||||
echo "==> mvn spring-boot:run"
|
||||
fi
|
||||
mvn $GOAL "${PASSTHRU[@]+"${PASSTHRU[@]}"}"
|
||||
Reference in New Issue
Block a user