Compare commits
10 Commits
7a32f8a351
...
141424878c
| Author | SHA1 | Date | |
|---|---|---|---|
| 141424878c | |||
| 5f4f739f16 | |||
| df74da48bd | |||
| f4bfe2d609 | |||
| e4b7ee04cc | |||
| bdcd96a058 | |||
| dda4d2e1d6 | |||
| 2583b5f302 | |||
| 74b252550b | |||
| 172e5f791f |
@@ -0,0 +1,2 @@
|
||||
.idea
|
||||
log
|
||||
Generated
+6
-1
@@ -2,7 +2,12 @@
|
||||
<module type="WEB_MODULE" version="4">
|
||||
<component name="Go" enabled="true" />
|
||||
<component name="NewModuleRootManager">
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<content url="file://$MODULE_DIR$">
|
||||
<excludeFolder url="file://$MODULE_DIR$/log/2026-03-02" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/log/2026-03-03" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/log/2026-03-04" />
|
||||
<excludeFolder url="file://$MODULE_DIR$/log/2026-03-05" />
|
||||
</content>
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
《“早安电台”项目完整架构设计与开发计划书》
|
||||
一、 产品愿景与垂直领域选择
|
||||
|
||||
1. 垂直领域对比与 MVP 推荐
|
||||
文档中提出了四个垂直方向:【硬核职场】、【效率健康】、【极简生活】与【知识胶囊】 2-4。MVP 阶段(两周内)最推荐方向:【硬核职场】AI 商业机会与搞钱情报站 1。
|
||||
推荐理由:
|
||||
自动化实现成本最低:该方向的数据源(如 GitHub API、Product Hunt API)高度结构化,极易通过 Go 后端定时任务抓取并交给 LLM 处理 1, 5。
|
||||
目标用户契合度极高:数据显示,63.3%的中文长音频听众是21-35岁的年轻精英群体,且72.1%的用户订阅是因为内容的“专业性”或“干货多” 6。AI 商业情报完美契合这批高价值用户的痛点。
|
||||
2. 目标用户画像及其核心痛点
|
||||
用户画像:21-35岁的一线/新一线城市开发者、产品经理、渴望通过 AI 变现的职场精英 2, 6。
|
||||
核心痛点:
|
||||
信息差焦虑:AI工具爆发,缺乏时间筛选信息,需要在早餐、通勤等“脱手”场景下实现“零点击”的信息获取 2, 7。
|
||||
情绪过渡与陪伴:早安电台处于从睡眠向职业场景过渡的交汇点,需提供缓解早起焦虑的“情绪价值”与专业陪伴感 8。
|
||||
二、 数据抓取与 AI 场景感知流水线 (Data Pipeline)
|
||||
整个后台流转依托“专家灵魂 + AI 躯干”的自动化生成模式 9,核心链路如下:
|
||||
3. 自动化数据抓取 (Cron Job)
|
||||
设定 Go 后端定时任务,每 2 小时运行一次 10。
|
||||
GitHub API 调用 (补充架构细节):调用 GET /search/repositories。后端构建查询参数 q=topic:ai+created:>YYYY-MM-DD&sort=stars&order=desc,筛选近24小时星标增长最快的AI项目 5,提取前 3 个项目描述。
|
||||
Product Hunt API 调用 (补充架构细节):通过 POST 请求调用其 GraphQL 接口,获取 posts(first: 10, order: RANKING),提取每日 Upvote 前10的产品的 description 5。
|
||||
4. 数据清洗与 AI 提炼
|
||||
Goquery 清洗:抓取到的 README 或网页文本包含大量无效 HTML 标签,必须使用 Goquery 库进行剥离,仅保留核心文本,大幅降低 LLM Token 消耗 10。
|
||||
大模型介入:将清洗后的 10 个产品和 3 个开源项目发送给 DeepSeek/OpenAI 模型,通过 Prompt 强制要求:“提取出今日可落地的 3 个变现点或核心业务逻辑,并改写为适合口播的连贯短句” 2, 7。
|
||||
5. TTS 语音合成与 MinIO 私有化存储
|
||||
利用 TTS 技术,每日更新的音频边际成本几乎为零 9。
|
||||
腾讯云 TTS (建议配置):选择带有“睿智专业”风格的虚拟主播音色 9,指定输出为 aac 格式,采样率 16000Hz,以匹配微信最优音频规范 8。
|
||||
MinIO 落库 (补充架构细节):
|
||||
后端接收到 TTS 音频流后,通过 minio-go SDK 直接上传至自建 MinIO 的 sundynix-audios Bucket 中。
|
||||
文件命名:文件使用生成的唯一 UUID 命名(如 123e4567-xxx.aac),防止内容重复 10。
|
||||
网络合规与分发 (核心风险应对,补充细节):因微信小程序强制要求 HTTPS 且需白名单拦截,MinIO 前端必须部署 Nginx 反向代理并配置 SSL 证书。为应对早高峰并发,Nginx 域名上必须套用公有云 CDN 服务进行边缘缓存加速 8, 11。
|
||||
三、 核心架构设计与技术规范
|
||||
6. 数据建模 (核心表结构)
|
||||
表名必须以 sundynix_开头,主键统一为 UUID 字符串 10, 12。(Go 结构体转 JSON 时统一使用 camelCase)。
|
||||
表名 (Table),字段 (Column),类型,说明
|
||||
sundynix_audio_content,id (PK),String(UUID),唯一音频ID 10
|
||||
,title,String,音频标题,用于锁屏界面展示 8
|
||||
,audio_url,String,绑定 CDN 的 MinIO AAC 音频链接
|
||||
,epname,String,专辑名 (如:早安电台 - 硬核职场) 8
|
||||
sundynix_play_record,id (PK),String(UUID),播放记录ID (外部扩展)
|
||||
,audio_id,String(UUID),关联音频
|
||||
,progress_sec,Integer,播放进度(秒),用于 startTime 恢复 8
|
||||
sundynix_user_medal,id (PK),String(UUID),勋章记录ID (外部扩展)
|
||||
,medal_type,String,勋章标识 (如: EARLY_BIRD_7) 12
|
||||
7. 核心后端 API 接口设计 (camelCase 响应)
|
||||
API 1: 获取音频列表
|
||||
Route: GET /api/v1/audios?scene=morning&limit=5
|
||||
Response:
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": [
|
||||
{
|
||||
"id": "123e4567-e89b-12d3-a456-426614174000",
|
||||
"title": "今日 GitHub 热榜:3个 Agent 变现线索",
|
||||
"audioUrl": "https://cdn.sundynix.com/sundynix-audios/123e4567-e89b-12d3-a456-426614174000.aac",
|
||||
"epname": "早安电台 - 硬核职场",
|
||||
"duration": 180
|
||||
}
|
||||
]
|
||||
}
|
||||
API 2: 更新播放状态 (含勋章触发逻辑) (扩展功能 13)
|
||||
Route: POST /api/v1/play-status
|
||||
Body: {"audioId": "123e...", "progressSec": 180, "isCompleted": true}
|
||||
Response:
|
||||
{
|
||||
"code": 200,
|
||||
"data": { "saved": true, "newMedalUnlocked": "EARLY_BIRD_7" }
|
||||
}
|
||||
API 3: 获取用户勋章 (扩展功能 13)
|
||||
Route: GET /api/v1/users/{userId}/medals
|
||||
Response: 返回解锁的勋章数组及图标。
|
||||
8. 逾期逻辑处理 (NextRunTime 计算) (外部扩展设计 13)
|
||||
如果用户错过了“清晨任务”,在下午完成了收听,系统在计算 NextRunTime 时,绝对不能采用 当前时间 + 24小时。必须采用基于“自然日锚点”的对齐策略,将下一次触发重置为“第二天的清晨 05:00”,从而死守产品的“晨间唤醒与规律伴随”核心心智 6。
|
||||
四、 微信小程序端深度整合
|
||||
BackgroundAudioManager 是小程序的底层核心,必须妥善设计以保障弱网及锁屏体验 8:
|
||||
参数合规:获取 API 1 的响应后,必须将 audioUrl 赋值给 src。同时,必须配置 title、epname 和 coverImgUrl,这三者直接决定了系统锁屏播放控件的展示质量,是建立品牌心智的关键 8。
|
||||
断点续传设计:若用户播放被闹铃中断,前端需记录进度。下次恢复时,将 API 2 中存储的 progressSec 传入 startTime 属性,实现帧级别的无缝续播 8。
|
||||
五、 分阶段开发计划 (2周 MVP 周期)
|
||||
第 1-3 天:后端基础及数据爬取
|
||||
搭建 Go 后端,配置 sundynix_ 前缀数据库和 UUID 10, 14。
|
||||
编写定时任务(Cron Job),完成 GitHub 和 Product Hunt 数据抓取及 Goquery 标签清洗 5, 10。
|
||||
第 4-7 天:AI 接入与存储链路构建
|
||||
接入大模型 API 完成洗稿提炼 7。
|
||||
集成 TTS 服务,完成音频生成。
|
||||
搭建自建 MinIO 服务器,配置 Nginx HTTPS 反向代理与云端 CDN,使用 Go SDK 实现音频直推 MinIO 19, 补充。
|
||||
第 8-10 天:小程序端播放器攻坚
|
||||
搭建核心 UI,将 CDN 音频地址下发给小程序。
|
||||
深度整合 BackgroundAudioManager,完成 startTime 断点续传逻辑和锁屏封面展示配置 8。
|
||||
第 11-14 天:联调、扩展系统与合规审查
|
||||
完成后端 API 与小程序联调。实现勋章成就系统的前端触发动画 14。
|
||||
在显著位置增加“AIGC 生成内容”标识,完成政策合规 11。
|
||||
六、 风险评估与对策
|
||||
弱网延迟打断收听体验 11
|
||||
对策:除了依赖自建 MinIO 前置的 CDN 节点加速外,小程序前端必须利用 BackgroundAudioManager 的机制,提前请求下一段音频的 src,实现主动缓冲。若检测到网络极差,动态请求备用的 MP3 低码率链接 8, 11。
|
||||
系统级中断导致进度丢失 8
|
||||
对策:妥善监听后台音频管理器的 onPause、onStop 和 onEnded 回调,实时向后端 API 2 上报 progressSec 8。
|
||||
AIGC 政策合规风险 11
|
||||
对策:严格遵循《网络视听数据质量及 AIGC 内容管理要求》,在前端 UI 必须带有清晰的 AI 标识,确保平台审核顺利通过 11。
|
||||
@@ -1,181 +0,0 @@
|
||||
中国垂直领域早安电台微信小程序产业研究与全链路建设报告
|
||||
来源指南
|
||||
中国垂直领域早安电台微信小程序产业研究与全链路建设报告
|
||||
第一章:中国声音经济的宏观范式与长音频演进逻辑
|
||||
在数字化媒介高度饱和的当下,视觉注意力的争夺已进入存量博弈阶段,而以声音为载体的“听觉经济”正成为新的蓝海。中国声音经济的崛起并非偶然,而是技术演进、消费升级与用户心理变迁共同驱动的结果。根据最新产业研究数据,2024年中国声音经济产业市场规模已达到5688.2亿元人民币,展现出强劲的增长韧性,预计到2029年这一数字将突破7400亿元 [1]。在这一宏大的背景下,垂直领域的早安电台小程序作为一种高频、高粘性的应用形态,正处于爆发的前夜。
|
||||
1.1 长音频行业的增长驱动力与政策导向
|
||||
中国长音频行业正经历从流量中心向内容中心的回归。政策层面的规范化是行业可持续发展的基石。政府通过加强版权保护、规范人工智能生成内容(AIGC)的标识、提升网络视听数据质量以及出台一系列税收优惠政策,为长音频行业创造了稳健的发展环境 [1]。这种政策引导不仅清除了市场中的侵权行为,更通过数字化转型推动了产业的结构性升级。
|
||||
文娱消费的升级则从需求端重塑了市场。随着居民消费水平的提高,精神消费日益呈现出刚性化趋势。声音作为一种能够渗透生活间隙、构建深度陪伴价值的媒介,其不可替代性愈发凸显。对于垂直赛道的开发者而言,紧扣用户为优质内容和沉浸体验付费的意愿,是实现商业闭环的关键 [1]。
|
||||
1.2 垂直化与场景化的深度融合
|
||||
长音频行业的发展已进入“高质量与多元化”的新阶段。传统的全品类平台正面临内容同质化的挑战,而专注于特定领域、特定场景的垂直化策略则显示出极强的生命力。这种趋势不仅体现在内容的细分上,更体现在对用户特定时间段和特定心理需求的精准捕捉。
|
||||
行业核心驱动因素
|
||||
2024年市场表现
|
||||
2029年预期/趋势
|
||||
关键影响机制
|
||||
声音经济产业总规模
|
||||
5688.2 亿元 [1]
|
||||
7400 亿元以上
|
||||
居民精神消费刚性化驱动
|
||||
AIGC 产业规模
|
||||
471.7 亿元 [1]
|
||||
2767.4 亿元 (2028)
|
||||
降低内容生产边际成本 [1]
|
||||
播客听众规模
|
||||
1.34 亿人 [1]
|
||||
1.79 亿人 (2027)
|
||||
年轻精英群体知识获取需求 [1]
|
||||
长音频市场增长率
|
||||
14.8% (2024) [1]
|
||||
持续平稳增长
|
||||
垂类内容深化用户连接 [1]
|
||||
早安电台作为一种特定的场景化产品,其核心逻辑在于“唤醒”与“伴随”。它利用了用户清晨洗漱、早餐及通勤的碎片化时间,将资讯获取、情绪调节与职业学习有机结合。这种高频的场景契合度,使得微信小程序成为了承载这一功能的最佳容器。
|
||||
第二章:目标用户洞察与垂直赛道心理画像
|
||||
垂直领域的早安电台若要获得成功,必须深入理解其核心受众的画像特征。当前,长音频的主要受众群体已呈现出清晰的结构化特征,这为产品的定位提供了精准的数据支持。
|
||||
2.1 21-35岁年轻精英的消费逻辑
|
||||
长音频,尤其是播客类内容的受众群体具有显著的“高价值”特征。统计数据显示,2024年中文播客听众中,63.3%的用户为21-35岁的年轻消费者 [1]。这一群体通常受过良好教育,处于事业的上升期,对时间成本极为敏感。
|
||||
深入分析发现,72.1%的听众订阅节目的主要动机是由于节目的“专业性”或“干货多” [1]。这意味着对于早安电台而言,泛泛而谈的娱乐内容已失去吸引力,用户更倾向于在清晨获取能够启发思考、辅助职业决策或提升专业技能的垂直信息。
|
||||
2.2 情感陪伴与情绪价值的刚需
|
||||
尽管工具性需求(如知识获取)是用户收听的重要动因,但情感陪伴需求同样占据核心地位。数据表明,长音频在睡眠场景(39.36%)和日常放松场景中的使用率极高 [1]。早安电台恰好处于从“睡眠场景”向“职业场景”过渡的交汇点,用户期望从中获得不仅是信息,更是一种能够缓解早晨起床焦虑的“情绪价值”。
|
||||
这种心理诉求直接影响了电台的音色选择、背景音乐节奏以及内容编排逻辑。垂直领域的小程序开发者应当通过声音技术的革新,如高保真音效或更具拟人化质感的AI配音,来增强这种沉浸式的陪伴感。
|
||||
2.3 场景化收听时长分析
|
||||
用户对长音频的粘性正在稳步提升。约88.3%的音频用户每天收听时长超过30分钟,甚至有相当比例的用户会同时活跃在四个以上的音频平台上 [1]。这种“多栖收听”的行为模式暗示了垂直电台的机会:只要内容足够细分且具差异化,用户不介意在已有的音频习惯中增加一个新的垂直入口。
|
||||
用户核心收听场景
|
||||
占比分布
|
||||
核心心理诉求
|
||||
垂直电台策略建议
|
||||
睡眠/清晨醒来
|
||||
39.36% [1]
|
||||
情绪放松、温和过渡
|
||||
采用轻柔BGM,渐进式内容播报
|
||||
家务/洗漱
|
||||
34.63% [1]
|
||||
解放双手、背景伴随
|
||||
简短有力的短资讯或单点知识
|
||||
通勤/差旅
|
||||
34.63% [1]
|
||||
时间利用、资讯获取
|
||||
侧重职业动态、深度垂直行业快讯
|
||||
健身/运动
|
||||
约25% (估算)
|
||||
节奏激励、自我提升
|
||||
提供节奏感强的行业前沿思考
|
||||
第三章:微信小程序背景音频技术架构与实操规范
|
||||
在微信小程序生态中构建早安电台,技术实现的优劣直接决定了用户在弱网或锁屏状态下的体验。微信提供的 BackgroundAudioManager 接口是实现此类功能的核心武器。
|
||||
3.1 BackgroundAudioManager 的深度解构
|
||||
与普通的音频播放组件不同,BackgroundAudioManager 允许音频在小程序进入后台甚至手机锁屏后依然持续播放。这对于早安场景至关重要,因为用户在晨间洗漱或整理时,往往不会保持手机屏幕常亮。
|
||||
根据官方开发规范,当开发者通过该接口设置新的 src 属性时,系统会自动触发播放流程 [2]。这一机制虽然简化了操作,但也要求开发者在逻辑设计上必须严谨,以避免意外播放。目前,该接口支持的音频格式包括 m4a、aac、mp3 和 wav [2]。对于追求高质量垂直内容的电台,aac 格式在同等比特率下能提供更好的音质,而 mp3 则是兼容性最广的选择。
|
||||
3.2 播放控制与用户状态同步
|
||||
由于电台内容通常具有时效性,startTime 属性的使用变得尤为关键。它允许从小程序的业务逻辑层面控制音频从特定秒数开始播放,这在实现“断点续传”或“跳过前奏”等功能时非常有用 [2]。
|
||||
此外,由于背景音频由系统管理,开发者必须妥善处理多个回调函数,如 onPlay、onPause、onStop 和 onEnded。特别是在早安电台的场景下,若用户收到来电或闹钟响起,系统会自动暂停背景音频,开发者需要确保在这些中断结束后,音频状态能够正确恢复,以保证收听体验的连贯性。
|
||||
3.3 技术参数与兼容性一览
|
||||
在实际开发中,开发者需严格遵循以下参数规范,以确保在不同版本的微信客户端和不同操作系统的设备上达到最佳表现。
|
||||
属性名称
|
||||
类型
|
||||
必填
|
||||
功能描述及开发者注意事项
|
||||
src
|
||||
String
|
||||
是
|
||||
音频数据源。设置后自动开始播放 [2]。建议配合CDN加速。
|
||||
startTime
|
||||
Number
|
||||
否
|
||||
音频开始播放的位置(秒)。可用于保存并恢复用户进度 [2]。
|
||||
title
|
||||
String
|
||||
是
|
||||
必填。音频标题,将显示在锁屏界面及系统的播放控件中。
|
||||
singer
|
||||
String
|
||||
否
|
||||
歌手名/主播名。有助于建立垂直领域的主播个人品牌。
|
||||
epname
|
||||
String
|
||||
否
|
||||
专辑名。可用于区分不同垂直主题的电台合集。
|
||||
coverImgUrl
|
||||
String
|
||||
否
|
||||
封面图URL。视觉呈现是品牌建立的重要一环。
|
||||
第四章:AIGC 技术赋能:内容生产力的结构性变革
|
||||
对于垂直赛道的创业者而言,高频率的每日内容更新是极大的运营挑战。AIGC(人工智能生成内容)技术的介入,正在从根本上解决内容生产的效率与成本问题。
|
||||
4.1 从人力驱动向 AI 协同的转变
|
||||
中国 AIGC 产业正处于爆发期。2024年市场规模已达471.7亿元人民币,同比增长高达494.8% [1]。这一技术趋势对长音频行业的重构体现在三个维度:效率、多样性与个性化。
|
||||
传统的早安电台录制需要专业的录音间、主播及后期剪辑。而利用 AIGC 中的 TTS(文本转语音)技术,开发者只需输入垂直领域的资讯文本,即可生成具备自然语感、甚至带有特定情感色彩的音频内容。这使得每日更新的边际成本几乎降至为零。
|
||||
4.2 “一千个人,一千个早安电台”
|
||||
AI 技术的另一大价值在于实现高度的个性化匹配。基于多模态大模型,程序可以根据用户的个人职业背景、收听习惯甚至所在城市的天气,实时生成定制化的播报脚本。
|
||||
• 动态脚本生成:针对职场人群,AI 自动从海量行业动态中抓取最相关的三条资讯,并自动转化为适合口播的短语。
|
||||
• 虚拟主播矩阵:提供不同风格的 AI 虚拟主播供用户选择,从“温柔知性”到“睿智专业”,满足不同用户的审美偏好 [1]。
|
||||
• 交互式内容:结合 AI 语音识别技术,未来的早安电台可以实现简单的语音互动,例如用户可以对某条早报内容提问,AI 实时给予深度解读。
|
||||
4.3 生产模式的融合:PGC + AIGC
|
||||
尽管 AI 极大地提升了效率,但在垂直领域,专业性(PGC)依然是核心壁垒。72.1%的听众是因为内容的专业性而留存 [1]。因此,最优的内容策略是:由垂直领域的行业专家(或资深编辑)负责内容的价值筛选与观点把控,而由 AI 负责语音合成、背景音乐自动配乐及跨平台分发。这种“专家灵魂 + AI 躯干”的模式,将是未来垂直领域电台的主流形态。
|
||||
第五章:政策监管与微信生态下的准入壁垒分析
|
||||
在中国,视听内容及资讯类服务的运营受到严格的法律法规限制。在微信小程序平台,针对不同的垂直内容,其类目要求和资质门槛差异巨大,这是开发者在项目立项阶段必须攻克的首要难关。
|
||||
5.1 互联网新闻与视听节目资质
|
||||
如果早安电台的内容涉及“时政新闻”或“社会动态”,其资质要求极高。根据微信平台的规范,此类服务通常需要上传《互联网新闻信息服务许可证》,且许可证的服务名称必须与小程序名称一致 [3]。此外,由于电台属于长音频范畴,可能还涉及《信息网络传播视听节目许可证》或相关的备案要求。
|
||||
5.2 垂直行业特定资质:教育、汽车与心理
|
||||
微信小程序对特定行业的准入有着精细化的管理逻辑。若开发者选择的垂直赛道涉及专业性服务,必须提供相应的行业资质 [4]。
|
||||
• 教育与职业认证:如果电台定位于职业技能培训或资格考试辅导,门槛极高。例如,部分类目要求小程序主体在近90天内的教育培训类商品累计支付金额必须大于或等于50万元 [5]。同时,需提供《办学许可证》或实缴注册资金500万以上的《增值电信业务许可证》(ICP) [3, 5]。
|
||||
• 资讯与媒体平台:对于汽车媒体等资讯平台,需提供汽车品牌主机厂的授权文件、营业执照,甚至是线下4S店的实拍照片及承诺函 [5]。
|
||||
• 设计与装修:即便是在看似门槛较低的家装领域,也要求营业执照注册满2年,实缴资金100万以上,并提供住建部或装修协会颁发的资质证书 [3]。
|
||||
5.3 运营者个人资质与平台合规性
|
||||
除了机构资质,运营者的个人背景也可能成为审核的一部分。在某些教育或播音主持相关的垂直赛道,微信会核查运营者的学历背景(如影音游戏动画相关专业)、教师资格证书或播音员主持人证 [5]。
|
||||
垂直领域
|
||||
核心经营类目
|
||||
核心资质要求 (部分)
|
||||
准入关键点
|
||||
时政/社会新闻
|
||||
互联网内容资讯服务
|
||||
《互联网新闻信息服务许可证》 [3]
|
||||
需在稿源单位名单内
|
||||
IT/职业教育
|
||||
资格考试/IT资格认证
|
||||
《办学许可证》及销售额达标 [5]
|
||||
资金与合规双重门槛
|
||||
汽车行业资讯
|
||||
汽车媒体
|
||||
品牌授权书及《留资承诺函》 [5]
|
||||
定向招商与品牌背书
|
||||
设计/家装
|
||||
室内装饰/设计
|
||||
行业协会证书、2年注册期、百万实缴 [3]
|
||||
对主体存续期有要求
|
||||
播音主持培训
|
||||
常识兴趣/音乐
|
||||
专业学历证书、主持人证 [5]
|
||||
对运营人专业性核查
|
||||
第六章:商业逻辑演进:从内容分发到生态服务
|
||||
垂直领域的早安电台不应仅仅局限于内容输出,而应通过构建深度的用户价值,探索多元化的商业闭环。
|
||||
6.1 精准流量下的场景化营销
|
||||
早安场景具有极强的排他性,用户在洗漱或通勤时很难被其他媒介干扰。这种高纯度的注意力环境为品牌植入提供了绝佳机会。
|
||||
• 原生音频植入:与全平台泛滥的硬广不同,垂直电台可以进行内容深耕。例如,职场早安电台可以与办公软件或在线教育品牌合作,通过“职场小贴士”的形式自然带出产品。
|
||||
• 私域导流:利用小程序与微信群的天然关联,将高频听众引导至垂直赛道的社群中,实现从“听觉陪伴”到“社群交互”的转化。
|
||||
6.2 订阅制与知识付费的深耕
|
||||
鉴于 72.1% 的听众是因为内容的“专业性”而订阅,高质量内容的变现潜力巨大 [1]。
|
||||
• 阶梯订阅模式:每日晨报保持免费以获取流量,而深度的行业解析、专家访谈或特定专题(如“21天职场心态重塑”)则采取付费订阅制。
|
||||
• IP 价值延伸:长音频作为网络文学和垂直 IP 的重要表现形式,具有极强的长尾价值。开发者可以挖掘高价值的行业 IP 进行音频化转制,构建具有持久生命力的内容资产 [1]。
|
||||
6.3 声音技术的增值服务
|
||||
随着 AIGC 技术的发展,个性化定制正成为新的付费增长点。用户可能愿意为定制化的播报音色(如家人声音的克隆)、定制化的早晨唤醒语或专属的内容简报付费。这种基于 AI 的增值服务,不仅提升了用户的归属感,也增加了产品的竞争门槛。
|
||||
第七章:风险管理与未来趋势展望
|
||||
在快速增长的声音经济赛道中,垂直领域早安电台的开发者不仅要关注增长,更要警惕潜在的结构性风险。
|
||||
7.1 技术瓶颈与网络延迟管理
|
||||
尽管 5G 正在普及,但在地铁、电梯等清晨常见的收听场景中,网络波动依然是体验杀手。开发者必须在小程序前端实现精细化的缓存策略。利用 BackgroundAudioManager 的缓冲机制,提前预下载下一段音频片段,是保证流畅度的必要手段。同时,针对不同带宽的用户提供动态码率切换,也是成熟产品的标准配置。
|
||||
7.2 版权保护与 AI 伦理
|
||||
随着 AIGC 的广泛应用,版权归属及内容真实性问题愈发凸显。政策已明确要求规范 AI 生成内容的标识 [1]。在垂直电台运营中,开发者必须确保 AI 生成内容的素材来源合法,并在程序显著位置明确标识内容的 AI 生成属性,以符合监管要求并建立品牌信任。
|
||||
7.3 趋势预测:向“全天候智能伴侣”进化
|
||||
早安电台只是切口,未来的趋势是音频服务与物联网(IoT)的深度融合。随着智能家居的普及,微信小程序作为连接器,可以轻松将电台内容推送到智能音箱、车载系统或智能穿戴设备上。
|
||||
1. 多端流转:用户在家中通过智能音箱收听早安电台,出门后通过微信小程序无缝接续到车载音响或耳机。
|
||||
2. 情感计算:结合生物反馈技术,电台内容能根据用户的压力水平或心率,实时调整背景音乐的频率和内容的温和度。
|
||||
3. 声音经济规模化:到2029年,随着中国声音经济规模突破7400亿元,垂直领域的“小而美”产品将通过极高的用户忠诚度,在巨头林立的市场中占据不可替代的生态位 [1]。
|
||||
第八章:总结与实战策略建议
|
||||
综合上述研究,对于想要构建垂直领域早安电台微信小程序的开发者,本报告提出以下三点核心建议:
|
||||
第一,坚持“专业主义”的内容立身之本。在声音经济中,内容深度是抵抗 AIGC 同质化竞争的唯一防线。开发者应优先深耕自己擅长的特定垂直领域(如职业心理、行业资讯、特定技能学习),确保内容的不可替代性 [1]。
|
||||
第二,充分利用微信生态的技术杠杆。熟练掌握 BackgroundAudioManager 等核心 API,通过精细的技术实现解决后台播放与缓存痛点 [2]。同时,积极拥抱 AIGC 降本增效,将有限的资源投入到内容策略和用户交互设计中。
|
||||
第三,合规经营是项目长效发展的生命线。在立项之初就必须对照微信最新的类目资质要求(特别是涉及教育、新闻、视听等敏感领域),确保主体资格和相关许可证的完备,避免因监管原因导致的中途折戟 [3, 5]。
|
||||
中国长音频行业正处于从“规模增长”转向“价值挖掘”的关键节点,垂直化的早安电台小程序凭借其特有的场景仪式感和深度陪伴价值,必将在 5000 亿规模的声音经济版图中展现出惊人的商业张力。
|
||||
--------------------------------------------------------------------------------
|
||||
1. 2025年中国长音频市场竞争格局分析报告 - 行业资讯_央广传媒, http://www.cnrmg.cn/xwzx1/hyzx/20251119/t20251119_527434585.html
|
||||
2. uni.getBackgroundAudioManager() | uni-app官网 - DCloud, https://uniapp.dcloud.net.cn/api/media/background-audio-manager
|
||||
3. 微信视频号资质管理规范, https://support.weixin.qq.com/cgi-bin/mmsupportacctnodeweb-bin/pages/CgFuOjwO6qjjLeHW
|
||||
4. 微信视频号资质管理规范, https://support.weixin.qq.com/cgi-bin/mmsupportacctnodeweb-bin/pages/nnxkJtKmu2YJ5NDl
|
||||
5. 微信视频号留资服务类目资质要求, https://support.weixin.qq.com/cgi-bin/mmsupportacctnodeweb-bin/pages/owkyDPAa4XB1mGbx
|
||||
@@ -0,0 +1,197 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"sundynix-go/global"
|
||||
"sundynix-go/model/commom/response"
|
||||
"sundynix-go/model/radio/request"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type AnalyticsApi struct{}
|
||||
|
||||
// GetListeningTrend 获取收听趋势
|
||||
// @Tags 数据分析
|
||||
// @Summary 获取收听趋势(折线图)
|
||||
// @Produce application/json
|
||||
// @Param startDate query string false "开始日期 2026-01-01"
|
||||
// @Param endDate query string false "结束日期 2026-03-10"
|
||||
// @Param channelId query string false "频道ID(可选筛选)"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/analytics/listening-trend [get]
|
||||
func (a *AnalyticsApi) GetListeningTrend(c *gin.Context) {
|
||||
var req request.TrendQuery
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
response.FailWithMsg("参数错误: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
data, err := analyticsService.GetListeningTrend(req.StartDate, req.EndDate, req.ChannelId)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取收听趋势失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithData(data, c)
|
||||
}
|
||||
|
||||
// GetSubscriptionTrend 获取订阅趋势
|
||||
// @Tags 数据分析
|
||||
// @Summary 获取订阅趋势(折线图)
|
||||
// @Produce application/json
|
||||
// @Param startDate query string false "开始日期"
|
||||
// @Param endDate query string false "结束日期"
|
||||
// @Param channelId query string false "频道ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/analytics/subscription-trend [get]
|
||||
func (a *AnalyticsApi) GetSubscriptionTrend(c *gin.Context) {
|
||||
var req request.TrendQuery
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
response.FailWithMsg("参数错误: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
data, err := analyticsService.GetSubscriptionTrend(req.StartDate, req.EndDate, req.ChannelId)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取订阅趋势失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithData(data, c)
|
||||
}
|
||||
|
||||
// GetRenewalTrend 获取续费趋势
|
||||
// @Tags 数据分析
|
||||
// @Summary 获取续费趋势(折线图)
|
||||
// @Produce application/json
|
||||
// @Param startDate query string false "开始日期"
|
||||
// @Param endDate query string false "结束日期"
|
||||
// @Param channelId query string false "频道ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/analytics/renewal-trend [get]
|
||||
func (a *AnalyticsApi) GetRenewalTrend(c *gin.Context) {
|
||||
var req request.TrendQuery
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
response.FailWithMsg("参数错误: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
data, err := analyticsService.GetRenewalTrend(req.StartDate, req.EndDate, req.ChannelId)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取续费趋势失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithData(data, c)
|
||||
}
|
||||
|
||||
// GetSubscriberStats 获取订阅用户统计
|
||||
// @Tags 数据分析
|
||||
// @Summary 获取订阅用户统计(折线图 + 概览)
|
||||
// @Produce application/json
|
||||
// @Param startDate query string false "开始日期"
|
||||
// @Param endDate query string false "结束日期"
|
||||
// @Param channelId query string false "频道ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/analytics/subscriber-stats [get]
|
||||
func (a *AnalyticsApi) GetSubscriberStats(c *gin.Context) {
|
||||
var req request.TrendQuery
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
response.FailWithMsg("参数错误: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
data, err := analyticsService.GetSubscriberStats(req.StartDate, req.EndDate, req.ChannelId)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取订阅用户统计失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithData(data, c)
|
||||
}
|
||||
|
||||
// GetContentQuality 获取内容质量分析
|
||||
// @Tags 数据分析
|
||||
// @Summary 获取内容质量分析(完播率等)
|
||||
// @Param channelId query string false "频道ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/analytics/content-quality [get]
|
||||
func (a *AnalyticsApi) GetContentQuality(c *gin.Context) {
|
||||
channelId := c.Query("channelId")
|
||||
data, err := analyticsService.GetContentQuality(channelId)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取内容质量分析失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithData(data, c)
|
||||
}
|
||||
|
||||
// GetUserStickiness 获取用户留存分析
|
||||
// @Tags 数据分析
|
||||
// @Summary 获取用户留存分析 (Cohort)
|
||||
// @Param startDate query string false "开始日期"
|
||||
// @Param endDate query string false "结束日期"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/analytics/user-stickiness [get]
|
||||
func (a *AnalyticsApi) GetUserStickiness(c *gin.Context) {
|
||||
var req request.TrendQuery
|
||||
c.ShouldBindQuery(&req)
|
||||
data, err := analyticsService.GetUserStickiness(req.StartDate, req.EndDate)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取用户留存分析失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithData(data, c)
|
||||
}
|
||||
|
||||
// GetBusinessConversion 获取商业转化分析
|
||||
// @Tags 数据分析
|
||||
// @Summary 获取商业转化分析 (Funnel & LTV)
|
||||
// @Param startDate query string false "开始日期"
|
||||
// @Param endDate query string false "结束日期"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/analytics/business-conversion [get]
|
||||
func (a *AnalyticsApi) GetBusinessConversion(c *gin.Context) {
|
||||
var req request.TrendQuery
|
||||
c.ShouldBindQuery(&req)
|
||||
data, err := analyticsService.GetBusinessConversion(req.StartDate, req.EndDate)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取商业转化分析失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithData(data, c)
|
||||
}
|
||||
|
||||
// GetPreferenceAnalysis 获取品类偏好分析
|
||||
// @Tags 数据分析
|
||||
// @Summary 获取品类偏好分析
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/analytics/preference [get]
|
||||
func (a *AnalyticsApi) GetPreferenceAnalysis(c *gin.Context) {
|
||||
data, err := analyticsService.GetPreferenceAnalysis()
|
||||
if err != nil {
|
||||
global.Logger.Error("获取品类偏好分析失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithData(data, c)
|
||||
}
|
||||
|
||||
// GetVipStats 获取VIP统计数据
|
||||
// @Tags 数据分析
|
||||
// @Summary 获取VIP统计数据
|
||||
// @Param startDate query string false "开始日期"
|
||||
// @Param endDate query string false "结束日期"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/analytics/vip-stats [get]
|
||||
func (a *AnalyticsApi) GetVipStats(c *gin.Context) {
|
||||
var req request.TrendQuery
|
||||
c.ShouldBindQuery(&req)
|
||||
data, err := analyticsService.GetVipStats(req.StartDate, req.EndDate)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取VIP统计数据失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithData(data, c)
|
||||
}
|
||||
@@ -54,6 +54,10 @@ func (a *ChannelApi) GetFreeChannelList(c *gin.Context) {
|
||||
// @Router /radio/channel/list [post]
|
||||
func (a *ChannelApi) GetChannelList(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
var req request.GetChannelList
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
@@ -86,6 +90,10 @@ func (a *ChannelApi) GetChannelList(c *gin.Context) {
|
||||
// @Router /radio/channel/detail [get]
|
||||
func (a *ChannelApi) GetChannelDetail(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
response.FailWithMsg("参数错误: id不能为空", c)
|
||||
|
||||
@@ -9,6 +9,10 @@ type ApiGroup struct {
|
||||
SubscriptionApi
|
||||
InteractionApi
|
||||
PayApi
|
||||
VipApi
|
||||
AnalyticsApi
|
||||
UserApi
|
||||
VoiceApi
|
||||
}
|
||||
|
||||
var ApiGroupApp = new(ApiGroup)
|
||||
@@ -20,4 +24,8 @@ var (
|
||||
subscriptionService = service.GroupApp.RadioServiceGroup.SubscriptionService
|
||||
interactionService = service.GroupApp.RadioServiceGroup.InteractionService
|
||||
payService = service.GroupApp.RadioServiceGroup.PayService
|
||||
vipService = service.GroupApp.RadioServiceGroup.VipService
|
||||
analyticsService = service.GroupApp.RadioServiceGroup.AnalyticsService
|
||||
userService = service.GroupApp.RadioServiceGroup.UserService
|
||||
voiceService = service.GroupApp.RadioServiceGroup.VoiceService
|
||||
)
|
||||
|
||||
+176
-18
@@ -15,12 +15,16 @@ type InteractionApi struct{}
|
||||
// AddHistory 添加收听历史
|
||||
// @Tags 用户互动
|
||||
// @Summary 添加收听历史
|
||||
// @Produce json
|
||||
// @Produce application/json
|
||||
// @Param data body request.AddHistory true "收听信息"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/history/add [post]
|
||||
// @Router /history/add [post]
|
||||
func (a *InteractionApi) AddHistory(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
var req request.AddHistory
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
@@ -37,15 +41,68 @@ func (a *InteractionApi) AddHistory(c *gin.Context) {
|
||||
response.OkWithMsg("添加成功", c)
|
||||
}
|
||||
|
||||
// DeleteHistory 删除收听历史
|
||||
// @Tags 用户互动
|
||||
// @Summary 删除收听历史
|
||||
// @Produce application/json
|
||||
// @Param id query string true "节目ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /history/delete [post]
|
||||
func (a *InteractionApi) DeleteHistory(c *gin.Context) {
|
||||
var req request.RemoveHistory
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
response.FailWithMsg("参数错误: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
err = interactionService.DeleteHistory(userId, req.ProgramId)
|
||||
if err != nil {
|
||||
global.Logger.Error("删除收听历史失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMsg("删除成功", c)
|
||||
}
|
||||
|
||||
// DeleteAllHistory 删除所有收听历史
|
||||
// @Tags 用户互动
|
||||
// @Summary 删除所有收听历史
|
||||
// @Produce application/json
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /history/deleteAllHistory [get]
|
||||
func (a *InteractionApi) DeleteAllHistory(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
err := interactionService.DeleteAllHistory(userId)
|
||||
if err != nil {
|
||||
global.Logger.Error("删除所有收听历史失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMsg("删除成功", c)
|
||||
}
|
||||
|
||||
// GetHistoryList 获取收听历史列表
|
||||
// @Tags 用户互动
|
||||
// @Summary 获取收听历史列表
|
||||
// @Produce json
|
||||
// @Produce application/json
|
||||
// @Param data body request.GetHistoryList true "分页查询"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/history/list [post]
|
||||
// @Router /history/list [post]
|
||||
func (a *InteractionApi) GetHistoryList(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
var req request.GetHistoryList
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
@@ -70,12 +127,16 @@ func (a *InteractionApi) GetHistoryList(c *gin.Context) {
|
||||
// ToggleLike 切换点赞状态
|
||||
// @Tags 用户互动
|
||||
// @Summary 切换点赞状态
|
||||
// @Produce json
|
||||
// @Produce application/json
|
||||
// @Param data body request.ToggleLike true "节目ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/like/toggle [post]
|
||||
// @Router /like/toggle [post]
|
||||
func (a *InteractionApi) ToggleLike(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
var req request.ToggleLike
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
@@ -93,15 +154,74 @@ func (a *InteractionApi) ToggleLike(c *gin.Context) {
|
||||
response.OkWithData(isLiked, c)
|
||||
}
|
||||
|
||||
// GetLikeList 获取点赞列表
|
||||
// @Tags 用户互动
|
||||
// @Summary 获取收藏列表
|
||||
// @Produce application/json
|
||||
// @Param data body request.GetLikeList true "分页查询"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /like/list [post]
|
||||
func (a *InteractionApi) GetLikeList(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
var req request.GetLikeList
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
response.FailWithMsg("参数错误: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
list, total, err := interactionService.GetLikeList(userId, req)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取收藏列表失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithData(response.PageResult{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: req.Current,
|
||||
}, c)
|
||||
}
|
||||
|
||||
// RemoveAllLike 清空所有赞
|
||||
// @Tags 用户互动
|
||||
// @Summary 清空所有赞
|
||||
// @Produce application/json
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /like/removeAll [get]
|
||||
func (a *InteractionApi) RemoveAllLike(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
err := interactionService.RemoveAllLike(userId)
|
||||
if err != nil {
|
||||
global.Logger.Error("清空所有赞失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMsg("清空所有赞成功", c)
|
||||
}
|
||||
|
||||
// GetFavoriteList 获取收藏列表
|
||||
// @Tags 用户互动
|
||||
// @Summary 获取收藏列表
|
||||
// @Produce json
|
||||
// @Produce application/json
|
||||
// @Param data body request.GetFavoriteList true "分页查询"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/favorite/list [post]
|
||||
// @Router /favorite/list [post]
|
||||
func (a *InteractionApi) GetFavoriteList(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
var req request.GetFavoriteList
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
@@ -126,12 +246,16 @@ func (a *InteractionApi) GetFavoriteList(c *gin.Context) {
|
||||
// AddFavorite 添加收藏
|
||||
// @Tags 用户互动
|
||||
// @Summary 添加收藏
|
||||
// @Produce json
|
||||
// @Produce application/json
|
||||
// @Param data body request.AddFavorite true "节目ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/favorite/add [post]
|
||||
// @Router /favorite/add [post]
|
||||
func (a *InteractionApi) AddFavorite(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
var req request.AddFavorite
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
@@ -151,12 +275,16 @@ func (a *InteractionApi) AddFavorite(c *gin.Context) {
|
||||
// RemoveFavorite 取消收藏
|
||||
// @Tags 用户互动
|
||||
// @Summary 取消收藏
|
||||
// @Produce json
|
||||
// @Produce application/json
|
||||
// @Param data body request.RemoveFavorite true "节目ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/favorite/remove [post]
|
||||
// @Router /favorite/remove [post]
|
||||
func (a *InteractionApi) RemoveFavorite(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
var req request.RemoveFavorite
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
@@ -173,13 +301,35 @@ func (a *InteractionApi) RemoveFavorite(c *gin.Context) {
|
||||
response.OkWithMsg("取消收藏成功", c)
|
||||
}
|
||||
|
||||
// RemoveAllFavorite 清空所有收藏
|
||||
// @Tags 用户互动
|
||||
// @Summary 清空所有收藏
|
||||
// @Produce application/json
|
||||
// @Param data body request.RemoveFavorite true "节目ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /favorite/removeAll [get]
|
||||
func (a *InteractionApi) RemoveAllFavorite(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
err := interactionService.RemoveAllFavorite(userId)
|
||||
if err != nil {
|
||||
global.Logger.Error("清空所有收藏失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMsg("清空所有收藏成功", c)
|
||||
}
|
||||
|
||||
// GetCommentList 获取评论列表
|
||||
// @Tags 用户互动
|
||||
// @Summary 获取评论列表
|
||||
// @Produce json
|
||||
// @Produce application/json
|
||||
// @Param data body request.GetCommentList true "分页查询"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/comment/list [post]
|
||||
// @Router /comment/list [post]
|
||||
func (a *InteractionApi) GetCommentList(c *gin.Context) {
|
||||
var req request.GetCommentList
|
||||
err := c.ShouldBindJSON(&req)
|
||||
@@ -205,12 +355,16 @@ func (a *InteractionApi) GetCommentList(c *gin.Context) {
|
||||
// AddComment 添加评论
|
||||
// @Tags 用户互动
|
||||
// @Summary 添加评论
|
||||
// @Produce json
|
||||
// @Produce application/json
|
||||
// @Param data body request.AddComment true "评论信息"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/comment/add [post]
|
||||
// @Router /comment/add [post]
|
||||
func (a *InteractionApi) AddComment(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
var req request.AddComment
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
@@ -230,12 +384,16 @@ func (a *InteractionApi) AddComment(c *gin.Context) {
|
||||
// DeleteComment 删除评论
|
||||
// @Tags 用户互动
|
||||
// @Summary 删除评论
|
||||
// @Produce json
|
||||
// @Produce application/json
|
||||
// @Param data body request.DeleteComment true "评论ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/comment/delete [post]
|
||||
// @Router /comment/delete [post]
|
||||
func (a *InteractionApi) DeleteComment(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
var req request.DeleteComment
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
|
||||
+9
-1
@@ -23,6 +23,10 @@ type PayApi struct{}
|
||||
func (a *PayApi) PrePay(c *gin.Context) {
|
||||
orderId := c.Query("orderId")
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
res, err := payService.PrePay(orderId, userId)
|
||||
if err != nil {
|
||||
global.Logger.Error("支付失败", zap.Error(err))
|
||||
@@ -32,7 +36,7 @@ func (a *PayApi) PrePay(c *gin.Context) {
|
||||
response.OkWithData(res, c)
|
||||
}
|
||||
|
||||
// QueryPay 查询支付
|
||||
// QueryPay 查询订阅支付状态
|
||||
// @Tags 微信支付
|
||||
// @Summary 支付
|
||||
// @Security BasicAuth
|
||||
@@ -43,6 +47,10 @@ func (a *PayApi) PrePay(c *gin.Context) {
|
||||
func (a *PayApi) QueryPay(c *gin.Context) {
|
||||
outTradeNo := c.Query("outTradeNo")
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
res, err := payService.QueryPay(outTradeNo, userId)
|
||||
if err != nil {
|
||||
global.Logger.Error("支付失败", zap.Error(err))
|
||||
|
||||
+32
-1
@@ -5,6 +5,7 @@ import (
|
||||
common "sundynix-go/model/commom/request"
|
||||
"sundynix-go/model/commom/response"
|
||||
"sundynix-go/model/radio/request"
|
||||
"sundynix-go/utils/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
@@ -55,7 +56,12 @@ func (a *ProgramApi) GetProgramDetail(c *gin.Context) {
|
||||
response.FailWithMsg("参数错误: id不能为空", c)
|
||||
return
|
||||
}
|
||||
program, err := programService.GetProgramById(id)
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
program, err := programService.GetProgramById(id, userId)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取节目详情失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
@@ -138,3 +144,28 @@ func (a *ProgramApi) DeleteProgram(c *gin.Context) {
|
||||
|
||||
response.OkWithMsg("删除成功", c)
|
||||
}
|
||||
|
||||
// GenerateTTS 生成TTS语音
|
||||
// @Tags 节目管理
|
||||
// @Summary 生成TTS语音
|
||||
// @Produce json
|
||||
// @Param id query string true "节目ID"
|
||||
// @Param speaker query string false "音色(默认 zh_male_dayi_uranus_bigtts)"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/program/generate-tts [get]
|
||||
func (a *ProgramApi) GenerateTTS(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
response.FailWithMsg("参数错误: id不能为空", c)
|
||||
return
|
||||
}
|
||||
speaker := c.Query("speaker")
|
||||
|
||||
if err := programService.GenerateTTS(id, speaker); err != nil {
|
||||
global.Logger.Error("生成TTS语音失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithMsg("TTS生成成功", c)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,10 @@ type SubscriptionApi struct{}
|
||||
// @Router /radio/subscription/list [post]
|
||||
func (a *SubscriptionApi) GetSubscriptionList(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
var req common.PageInfo
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
@@ -60,6 +64,10 @@ func (a *SubscriptionApi) UnlockChannel(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
res, no, err := subscriptionService.UnlockChannel(userId, req)
|
||||
if err != nil {
|
||||
global.Logger.Error("解锁频道失败!", zap.Error(err))
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"sundynix-go/global"
|
||||
common "sundynix-go/model/commom/request"
|
||||
"sundynix-go/model/commom/response"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type UserApi struct{}
|
||||
|
||||
// GetRadioUserList 获取电台用户列表
|
||||
// @Tags 用户管理
|
||||
// @Summary 获取电台用户列表(含订阅/收听统计)
|
||||
// @Accept application/json
|
||||
// @Produce application/json
|
||||
// @Param current query int false "页码"
|
||||
// @Param pageSize query int false "每页大小"
|
||||
// @Param keyword query string false "搜索关键字"
|
||||
// @Param isVip query int false "VIP筛选: 0全部 1VIP 2非VIP"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/user/list [get]
|
||||
func (a *UserApi) GetRadioUserList(c *gin.Context) {
|
||||
var info common.PageInfo
|
||||
info.Keyword = c.Query("keyword")
|
||||
current, _ := strconv.Atoi(c.DefaultQuery("current", "1"))
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "10"))
|
||||
info.Current = current
|
||||
info.PageSize = pageSize
|
||||
|
||||
isVip, _ := strconv.Atoi(c.DefaultQuery("isVip", "0"))
|
||||
|
||||
list, total, err := userService.GetRadioUserList(info, isVip, info.Keyword)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取用户列表失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithData(response.PageResult{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: info.Current,
|
||||
PageSize: info.PageSize,
|
||||
}, c)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"sundynix-go/model/commom/response"
|
||||
"sundynix-go/model/radio/request"
|
||||
radioRes "sundynix-go/model/radio/response"
|
||||
"sundynix-go/utils/auth"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type VipApi struct{}
|
||||
|
||||
// UpdateVipConfig 更新VIP配置
|
||||
// @Tags VIP管理
|
||||
// @Summary 更新VIP配置
|
||||
// @Accept application/json
|
||||
// @Produce application/json
|
||||
// @Param data body request.UpdateVipConfig true "VIP配置信息"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /vip/config/update [post]
|
||||
func (a *VipApi) UpdateVipConfig(c *gin.Context) {
|
||||
var req request.UpdateVipConfig
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
response.FailWithMsg("参数错误: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
err = vipService.UpdateVipConfig(req)
|
||||
if err != nil {
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithMsg("更新成功", c)
|
||||
}
|
||||
|
||||
// VipConfigDetail 获取VIP配置详情
|
||||
// @Tags VIP管理
|
||||
// @Summary 获取VIP配置详情
|
||||
// @Produce application/json
|
||||
// @Param id query string true "id"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /vip/config/detail [post]
|
||||
func (a *VipApi) VipConfigDetail(c *gin.Context) {
|
||||
vipConfig, err := vipService.VipConfigDetail()
|
||||
if err != nil {
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithData(vipConfig, c)
|
||||
}
|
||||
|
||||
// VipVip 开通vip
|
||||
// @Tags VIP管理
|
||||
// @Summary 开通vip
|
||||
// @Produce application/json
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /vip/vip [post]
|
||||
func (a *VipApi) VipVip(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
res, no, err := vipService.VipVip(userId)
|
||||
if err != nil {
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithData(radioRes.PrePayResult{
|
||||
Payments: res,
|
||||
OutTradeNo: no,
|
||||
}, c)
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"sundynix-go/global"
|
||||
common "sundynix-go/model/commom/request"
|
||||
"sundynix-go/model/commom/response"
|
||||
"sundynix-go/model/radio/request"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type VoiceApi struct{}
|
||||
|
||||
// GetVoiceList 获取音色列表
|
||||
// @Tags 音色管理
|
||||
// @Summary 获取音色列表
|
||||
// @Produce application/json
|
||||
// @Param data body request.GetVoiceList true "分页查询"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/voice/list [post]
|
||||
func (a *VoiceApi) GetVoiceList(c *gin.Context) {
|
||||
var req request.GetVoiceList
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
response.FailWithMsg("参数错误: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
list, total, err := voiceService.GetVoiceList(req)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取音色列表失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithData(response.PageResult{
|
||||
List: list,
|
||||
Total: total,
|
||||
Page: req.Current,
|
||||
PageSize: req.PageSize,
|
||||
}, c)
|
||||
}
|
||||
|
||||
// GetVoiceDetail 获取音色详情
|
||||
// @Tags 音色管理
|
||||
// @Summary 获取音色详情
|
||||
// @Produce application/json
|
||||
// @Param id query string true "音色ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/voice/detail [get]
|
||||
func (a *VoiceApi) GetVoiceDetail(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
response.FailWithMsg("参数错误: id不能为空", c)
|
||||
return
|
||||
}
|
||||
|
||||
voice, err := voiceService.GetVoiceById(id)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取音色详情失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithData(voice, c)
|
||||
}
|
||||
|
||||
// GetVoiceOptions 获取音色选项列表(前端下拉选择用)
|
||||
// @Tags 音色管理
|
||||
// @Summary 获取音色选项列表
|
||||
// @Produce application/json
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/voice/options [get]
|
||||
func (a *VoiceApi) GetVoiceOptions(c *gin.Context) {
|
||||
list, err := voiceService.GetAllEnabledVoice()
|
||||
if err != nil {
|
||||
global.Logger.Error("获取音色列表失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
response.OkWithData(response.ListResult{
|
||||
List: list,
|
||||
}, c)
|
||||
}
|
||||
|
||||
// SaveVoice 保存音色
|
||||
// @Tags 音色管理
|
||||
// @Summary 保存音色
|
||||
// @Accept application/json
|
||||
// @Produce application/json
|
||||
// @Param data body request.SaveVoice true "音色信息"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/voice/save [post]
|
||||
func (a *VoiceApi) SaveVoice(c *gin.Context) {
|
||||
var req request.SaveVoice
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
response.FailWithMsg("参数错误: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
if err := voiceService.SaveVoice(req); err != nil {
|
||||
global.Logger.Error("保存音色失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithMsg("保存成功", c)
|
||||
}
|
||||
|
||||
// UpdateVoice 更新音色
|
||||
// @Tags 音色管理
|
||||
// @Summary 更新音色
|
||||
// @Accept application/json
|
||||
// @Produce application/json
|
||||
// @Param data body request.UpdateVoice true "音色信息"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/voice/update [post]
|
||||
func (a *VoiceApi) UpdateVoice(c *gin.Context) {
|
||||
var req request.UpdateVoice
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
response.FailWithMsg("参数错误: "+err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
// 如果修改了speakerId,检查是否与其他音色冲突
|
||||
if req.SpeakerId != "" {
|
||||
existing, _ := voiceService.GetVoiceBySpeakerId(req.SpeakerId)
|
||||
if existing != nil && existing.Id != req.Id {
|
||||
response.FailWithMsg("该音色ID已被其他音色使用", c)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := voiceService.UpdateVoice(req); err != nil {
|
||||
global.Logger.Error("更新音色失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithMsg("更新成功", c)
|
||||
}
|
||||
|
||||
// DeleteVoice 删除音色
|
||||
// @Tags 音色管理
|
||||
// @Summary 删除音色
|
||||
// @Produce json
|
||||
// @Param data body common.IdsReq true "音色ID列表"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/voice/delete [post]
|
||||
func (a *VoiceApi) DeleteVoice(c *gin.Context) {
|
||||
var req common.IdsReq
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
response.FailWithMsg("参数错误: id不能为空", c)
|
||||
return
|
||||
}
|
||||
|
||||
if err := voiceService.DeleteVoice(req.Ids); err != nil {
|
||||
global.Logger.Error("删除音色失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithMsg("删除成功", c)
|
||||
}
|
||||
|
||||
// GetDefaultVoice 获取默认音色
|
||||
// @Tags 音色管理
|
||||
// @Summary 获取默认音色
|
||||
// @Produce application/json
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/voice/default [get]
|
||||
func (a *VoiceApi) GetDefaultVoice(c *gin.Context) {
|
||||
voice, err := voiceService.GetDefaultVoice()
|
||||
if err != nil {
|
||||
global.Logger.Error("获取默认音色失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithData(voice, c)
|
||||
}
|
||||
|
||||
// SetDefaultVoice 设置默认音色
|
||||
// @Tags 音色管理
|
||||
// @Summary 设置默认音色
|
||||
// @Produce json
|
||||
// @Param id query string true "音色ID"
|
||||
// @Success 200 {object} response.Response
|
||||
// @Router /radio/voice/set-default [post]
|
||||
func (a *VoiceApi) SetDefaultVoice(c *gin.Context) {
|
||||
id := c.Query("id")
|
||||
if id == "" {
|
||||
response.FailWithMsg("参数错误: id不能为空", c)
|
||||
return
|
||||
}
|
||||
|
||||
if err := voiceService.SetDefaultVoice(id); err != nil {
|
||||
global.Logger.Error("设置默认音色失败!", zap.Error(err))
|
||||
response.FailWithMsg(err.Error(), c)
|
||||
return
|
||||
}
|
||||
|
||||
response.OkWithMsg("设置成功", c)
|
||||
}
|
||||
@@ -56,6 +56,10 @@ func (a *AuthApi) Login(c *gin.Context) {
|
||||
func (a *AuthApi) Logout(c *gin.Context) {
|
||||
token := auth.GetToken(c)
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
err := jwtService.PutBlacklist(userId, token)
|
||||
if err != nil {
|
||||
global.Logger.Error("登出失败!", zap.Error(err))
|
||||
@@ -118,7 +122,8 @@ func (a *AuthApi) GetToken(c *gin.Context, user system.User) {
|
||||
// @Router /auth/miniLogin [get]
|
||||
func (a *AuthApi) MiniLogin(c *gin.Context) {
|
||||
jsCode := c.Query("code")
|
||||
user, err := userService.MiniLogin(jsCode)
|
||||
ip := c.ClientIP()
|
||||
user, err := userService.MiniLogin(jsCode, ip)
|
||||
if err != nil {
|
||||
global.Logger.Error("登录失败!", zap.Error(err))
|
||||
response.FailWithMsg("登录失败", c)
|
||||
|
||||
@@ -138,6 +138,10 @@ func (m *MenuApi) GetAllMenuTree(c *gin.Context) {
|
||||
// @Router /menu/getUserMenuTree [get]
|
||||
func (m *MenuApi) GetUserMenuTree(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
routes, err := menuService.GetUserRoutes(userId)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取用户菜单失败!", zap.Error(err))
|
||||
@@ -156,6 +160,10 @@ func (m *MenuApi) GetUserMenuTree(c *gin.Context) {
|
||||
// @Router /menu/route [get]
|
||||
func (m *MenuApi) Route(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" || userId == "0" {
|
||||
response.FailWithMsg("用户未登录", c)
|
||||
return
|
||||
}
|
||||
routes, err := menuService.GetUserRoutes(userId)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取用户菜单失败!", zap.Error(err))
|
||||
|
||||
@@ -24,6 +24,9 @@ type UserApi struct {
|
||||
// @Router /user/info [get]
|
||||
func (u *UserApi) CurrentUser(c *gin.Context) {
|
||||
userId := auth.GetUserId(c)
|
||||
if userId == "" {
|
||||
|
||||
}
|
||||
user, err := userService.GetUserById(userId)
|
||||
if err != nil {
|
||||
global.Logger.Error("获取用户信息失败!", zap.Error(err))
|
||||
|
||||
+7
-1
@@ -22,6 +22,12 @@ mini-program:
|
||||
app-id: wx52dfc635739a9c19
|
||||
app-secret: 84c6ddab1f24d0222da57bedb681c81f
|
||||
|
||||
# 统一音频TTS服务(火山引擎)
|
||||
tts:
|
||||
app-id: "9604175735"
|
||||
resource-id: "seed-tts-2.0" # 火山引擎TTS服务资源ID (原cluster)
|
||||
access-key: "IMSrxDQgXWOwaJnuF-5G7uppRQutwBny" # 接口调用的Token
|
||||
|
||||
# 微信支付
|
||||
wechat-pay:
|
||||
mch-id: 1735188493 # 商户号
|
||||
@@ -75,7 +81,7 @@ redis:
|
||||
- 172.21.0.2:7002
|
||||
db: 1
|
||||
# name: ""
|
||||
# password: "sundynix"
|
||||
password: "sundynix"
|
||||
cluster: false
|
||||
|
||||
zap:
|
||||
|
||||
@@ -15,4 +15,5 @@ type Config struct {
|
||||
|
||||
MiniProgram MiniProgram `mapstructure:"mini-program" json:"mini-program" yaml:"mini-program"` //小程序
|
||||
WechatPay WechatPay `mapstructure:"wechat-pay" json:"wechat-pay" yaml:"wechat-pay"` //微信支付
|
||||
TTS TTS `mapstructure:"tts" json:"tts" yaml:"tts"` //统一TTS服务配置(火山引擎)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package config
|
||||
|
||||
// TTS 统一TTS配置 (目前对接火山引擎长文本异步合成)
|
||||
type TTS struct {
|
||||
AppId string `mapstructure:"app-id" json:"app-id" yaml:"app-id"` // 火山 AppID
|
||||
ResourceId string `mapstructure:"resource-id" json:"resource-id" yaml:"resource-id"` // 火山 Cluster/资源ID
|
||||
AccessKey string `mapstructure:"access-key" json:"access-key" yaml:"access-key"` // 火山 Token/SecretId
|
||||
}
|
||||
+1263
-296
File diff suppressed because it is too large
Load Diff
+1263
-296
File diff suppressed because it is too large
Load Diff
+823
-192
File diff suppressed because it is too large
Load Diff
@@ -97,6 +97,8 @@ require (
|
||||
github.com/spf13/cast v1.7.1 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.51 // indirect
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tts v1.3.43 // indirect
|
||||
github.com/tinylib/msgp v1.3.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
|
||||
@@ -205,7 +205,12 @@ github.com/swaggo/gin-swagger v1.6.1/go.mod h1:LQ+hJStHakCWRiK/YNYtJOu4mR2FP+pxL
|
||||
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
|
||||
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.43/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.51 h1:yuvTAokQAdxbCr06NGOdPJpgO3z46IDinINBM0r9R9I=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.51/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tts v1.3.43 h1:BE8/iU1JruJoxFyYqCoeTmD42TlDmShf6BSIOeBo8+c=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tts v1.3.43/go.mod h1:LospHTzrMXwZxzdu8rJi8ODtjGn64tVcwVRg6uMsGjc=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.70 h1:gkBkSfrDvUg4ZIjwYAfjbNCCclen9LCRNHhBNz+yjEQ=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.70/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg=
|
||||
github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw=
|
||||
|
||||
+8
-1
@@ -39,13 +39,20 @@ func MigrateTable() {
|
||||
system.SysOperationRecord{},
|
||||
system.Oss{},
|
||||
|
||||
radio.RadioVoice{},
|
||||
radio.Vip{},
|
||||
radio.RadioCategory{},
|
||||
radio.RadioChannel{},
|
||||
radio.RadioProgram{},
|
||||
radio.RadioSubscription{},
|
||||
radio.RadioUser{},
|
||||
radio.Order{},
|
||||
radio.PayNotify{},
|
||||
|
||||
radio.RadioComment{},
|
||||
radio.RadioHistory{},
|
||||
radio.RadioFavorite{},
|
||||
radio.RadioLike{},
|
||||
radio.RadioListenLog{},
|
||||
)
|
||||
if err != nil {
|
||||
global.Logger.Error("Migrate table failed,err:", zap.Error(err))
|
||||
|
||||
@@ -48,12 +48,16 @@ func Routers() {
|
||||
systemRouter.InitOssRouter(NeedAuthGroup) //OSS相关
|
||||
|
||||
// Radio模块路由
|
||||
radioRouter.InitVipRouter(NeedAuthGroup) //VIP相关
|
||||
radioRouter.InitCategoryRouter(NeedAuthGroup) //分类相关
|
||||
radioRouter.InitChannelRouter(NeedAuthGroup) //频道相关
|
||||
radioRouter.InitProgramRouter(NeedAuthGroup) //节目相关
|
||||
radioRouter.InitSubscriptionRouter(NeedAuthGroup) //订阅相关
|
||||
radioRouter.InitPayRouter(NeedAuthGroup, PublicGroup) //支付和回调
|
||||
radioRouter.InitInteractionRouter(NeedAuthGroup) //用户互动相关
|
||||
radioRouter.InitAnalyticsRouter(NeedAuthGroup) //数据分析相关
|
||||
radioRouter.InitUserRouter(NeedAuthGroup) //用户管理相关
|
||||
radioRouter.InitVoiceRouter(NeedAuthGroup) //音色管理相关
|
||||
}
|
||||
|
||||
address := fmt.Sprintf(":%d", global.Config.System.Addr)
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
[sundynix-radio-server]2026-02-28 15:39:45 [31merror[0m /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/redis.go:43 Redis connect ping failed,err: {"name": "", "error": "dial tcp 127.0.0.1:6379: connect: connection refused"}
|
||||
[sundynix-radio-server]2026-02-28 15:39:45 [31merror[0m /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/redis.go:17 Redis connect failed,err: {"error": "dial tcp 127.0.0.1:6379: connect: connection refused"}
|
||||
[sundynix-radio-server]2026-02-28 15:39:45 [31merror[0m /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/internal/gorm_logger_writer.go:29 [35m[/Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm.go:34 {{ 0001-01-01 00:00:00 +0000 UTC 0001-01-01 00:00:00 +0000 UTC {0001-01-01 00:00:00 +0000 UTC %!s(bool=false)} } %!s(*system.Oss=<nil>) %!s(*system.Oss=<nil>) %!s(int=0) %!s(int=0)} invalid field found for struct sundynix-go/model/radio.RadioCategory's field Cover: define a valid foreign key for relations or implement the Valuer/Scanner interface]
|
||||
[0m[31m[error] [0mfailed to parse value %!v(MISSING), got error %!v(MISSING)
|
||||
[sundynix-radio-server]2026-02-28 15:39:51 [31merror[0m /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/internal/gorm_logger_writer.go:29 [35m[/Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm.go:34 {{ 0001-01-01 00:00:00 +0000 UTC 0001-01-01 00:00:00 +0000 UTC {0001-01-01 00:00:00 +0000 UTC %!s(bool=false)} } %!s(*system.Oss=<nil>) %!s(*system.Oss=<nil>) %!s(int=0) %!s(int=0)} invalid field found for struct sundynix-go/model/radio.RadioCategory's field Cover: define a valid foreign key for relations or implement the Valuer/Scanner interface]
|
||||
[0m[31m[error] [0mfailed to parse value %!v(MISSING), got error %!v(MISSING)
|
||||
[sundynix-radio-server]2026-02-28 15:39:51 [31merror[0m /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm.go:49 Migrate table failed,err: {"error": "invalid field found for struct sundynix-go/model/radio.RadioCategory's field Cover: define a valid foreign key for relations or implement the Valuer/Scanner interface"}
|
||||
[sundynix-radio-server]2026-02-28 15:40:52 [31merror[0m /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/redis.go:43 Redis connect ping failed,err: {"name": "", "error": "dial tcp 127.0.0.1:6379: connect: connection refused"}
|
||||
[sundynix-radio-server]2026-02-28 15:40:52 [31merror[0m /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/redis.go:17 Redis connect failed,err: {"error": "dial tcp 127.0.0.1:6379: connect: connection refused"}
|
||||
@@ -1,3 +0,0 @@
|
||||
[sundynix-radio-server]2026-02-28 15:39:45 [34minfo[0m /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm_mysql.go:40 Mysql connect success
|
||||
[sundynix-radio-server]2026-02-28 15:40:52 [34minfo[0m /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm_mysql.go:40 Mysql connect success
|
||||
[sundynix-radio-server]2026-02-28 15:41:00 [34minfo[0m /Users/blizzard/sourceCode/GolandProjects/src/morning-radio/morning-radio-backend/initialize/gorm.go:52 Migrate table success
|
||||
@@ -47,9 +47,16 @@ func AuthMiddleware() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
c.Set("claims", claims)
|
||||
// 检查token是否即将过期,如果是则续签token
|
||||
if claims.ExpiresAt.Unix()-time.Now().Unix() < claims.BufferTime {
|
||||
dr, _ := timer.ParseDuration(global.Config.JWT.ExpiresTime)
|
||||
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(dr))
|
||||
// 生成新的token并返回给客户端
|
||||
newToken, err := j.CreateToken(*claims)
|
||||
if err == nil && newToken != "" {
|
||||
// 将新token写入响应头
|
||||
c.Header("Authorization", "Bearer "+newToken)
|
||||
}
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ type RadioFavorite struct {
|
||||
global.BaseModel
|
||||
UserId string `gorm:"size:50;index" json:"userId"` // 用户ID
|
||||
ProgramId string `gorm:"size:50;index" json:"programId"` // 节目ID
|
||||
RadioProgram *RadioProgram `gorm:"foreignKey:ProgramId" json:"program"`
|
||||
}
|
||||
|
||||
func (RadioFavorite) TableName() string {
|
||||
|
||||
@@ -9,6 +9,7 @@ type RadioLike struct {
|
||||
global.BaseModel
|
||||
UserId string `gorm:"size:50;index" json:"userId"` // 用户ID
|
||||
ProgramId string `gorm:"size:50;index" json:"programId"` // 节目ID
|
||||
RadioProgram *RadioProgram `gorm:"foreignKey:ProgramId" json:"program"`
|
||||
}
|
||||
|
||||
func (RadioLike) TableName() string {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"sundynix-go/global"
|
||||
)
|
||||
|
||||
// RadioListenLog 播放日志表(用于统计收听趋势,不可删除,不作为书签)
|
||||
type RadioListenLog struct {
|
||||
global.BaseModel
|
||||
UserId string `gorm:"size:50;index" json:"userId"` // 用户ID
|
||||
ProgramId string `gorm:"size:50;index" json:"programId"` // 节目ID
|
||||
ChannelId string `gorm:"size:50;index" json:"channelId"` // 频道ID(冗余,方便按频道统计)
|
||||
Progress int `gorm:"default:0" json:"progress"` // 当前播放进度
|
||||
Duration int `gorm:"default:0" json:"duration"` // 节目总时长 (由播放器实时反馈,用于修复元数据)
|
||||
}
|
||||
|
||||
func (RadioListenLog) TableName() string {
|
||||
return "sundynix_radio_listen_log"
|
||||
}
|
||||
@@ -13,7 +13,7 @@ type PayNotify struct {
|
||||
BankType string `json:"bank_type" gorm:"column:bank_type"`
|
||||
MchId string `json:"mchId" gorm:"column:mch_id"`
|
||||
OutTradeNo string `json:"out_trade_no" gorm:"column:out_trade_no"`
|
||||
Payer string `json:"payer" gorm:"column:out_trade_no"`
|
||||
Payer string `json:"payer" gorm:"column:payer"`
|
||||
SuccessTime string `json:"success_time" gorm:"column:success_time"`
|
||||
TradeState string `json:"trade_state" gorm:"column:trade_state"`
|
||||
TradeStateDesc string `json:"trade_state_desc" gorm:"column:trade_state_desc"`
|
||||
|
||||
@@ -15,12 +15,15 @@ type RadioProgram struct {
|
||||
Cover string `gorm:"size:100" json:"cover"` // 封面图emoji
|
||||
AudioId string `gorm:"size:50" json:"audioId"` // 音频OSS ID
|
||||
Audio *system.Oss `gorm:"foreignKey:AudioId" json:"audio"` // 音频OSS
|
||||
AudioStatus int `gorm:"default:0" json:"audioStatus"` // 音频生成状态 0:无音频 1:正在生成音频 2:音频就绪
|
||||
Duration int `gorm:"default:0" json:"duration"` // 时长(秒)
|
||||
Tags string `gorm:"size:255" json:"tags"` // 标签,逗号分隔
|
||||
PlayCount int `gorm:"default:0" json:"playCount"` // 播放次数
|
||||
LikeCount int `gorm:"default:0" json:"likeCount"` // 点赞次数
|
||||
Status int `gorm:"default:1" json:"status"` // 状态 0:下架 1:上架
|
||||
Channel *RadioChannel `gorm:"foreignKey:ChannelId" json:"channel"`
|
||||
HasLiked int `gorm:"-" json:"hasLiked"` // 是否点赞
|
||||
HasFavorite int `gorm:"-" json:"hasFavorite"` // 是否收藏
|
||||
}
|
||||
|
||||
func (RadioProgram) TableName() string {
|
||||
|
||||
@@ -15,5 +15,6 @@ type Order struct {
|
||||
Amount int `json:"amount" gorm:"column:amount;comment:金额分"`
|
||||
Status int `json:"status" gorm:"column:status;comment:订单状态"` // 0:待支付 1:已支付 2:已关闭
|
||||
PayStatus string `json:"payStatus" gorm:"column:pay_status;comment:支付状态"`
|
||||
Type int `json:"type" gorm:"column:type;default:1;comment:支付类型"` // 1.订阅支付(包含包月包季包年) 2:vip支付
|
||||
User *system.User `json:"user" gorm:"foreignKey:UserId"`
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"sundynix-go/global"
|
||||
"sundynix-go/model/system"
|
||||
)
|
||||
|
||||
// RadioUser 小程序用户信息表
|
||||
type RadioUser struct {
|
||||
global.BaseModel
|
||||
UserId string `gorm:"size:50;uniqueIndex" json:"userId"` // 关联system用户ID
|
||||
OpenId string `gorm:"size:80;uniqueIndex" json:"openId"` // 微信openid
|
||||
UnionId string `gorm:"size:80" json:"unionId"` // 微信unionid
|
||||
SessionKey string `gorm:"size:200" json:"sessionKey"` // 会话密钥
|
||||
NickName string `gorm:"size:50" json:"nickName"` // 昵称
|
||||
AvatarId string `gorm:"size:50" json:"avatarId"` // 头像OSS ID
|
||||
Avatar *system.Oss `gorm:"foreignKey:AvatarId" json:"avatar"` // 头像OSS
|
||||
Gender int `gorm:"default:0" json:"gender"` // 性别 0:未知 1:男 2:女
|
||||
Country string `gorm:"size:50" json:"country"` // 国家
|
||||
Province string `gorm:"size:50" json:"province"` // 省份
|
||||
City string `gorm:"size:50" json:"city"` // 城市
|
||||
Language string `gorm:"size:20" json:"language"` // 语言
|
||||
IsVip int `gorm:"default:0" json:"isVip"` // 是否VIP 0:否 1:是
|
||||
VipExpireAt *int64 `gorm:"type:bigint" json:"vipExpireAt"` // VIP过期时间
|
||||
}
|
||||
|
||||
func (RadioUser) TableName() string {
|
||||
return "sundynix_radio_user"
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"sundynix-go/global"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Vip struct {
|
||||
global.BaseModel
|
||||
Price int `gorm:"default:0;comment:价格,单位,分 " json:"price"` //vip价格 单位:分
|
||||
DiscountedPrice int `gorm:"default:0;comment:优惠价格,单位,分 " json:"discountedPrice"` // 优惠价格 单位:分
|
||||
ExpiredAt time.Time `gorm:"index;column:expired_at" json:"expiredAt"` //过期时间
|
||||
Remark string `gorm:"column:remark" json:"remark"` //备注
|
||||
}
|
||||
|
||||
func (Vip) TableName() string {
|
||||
return "sundynix_radio_vip_config"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"sundynix-go/global"
|
||||
"sundynix-go/model/system"
|
||||
)
|
||||
|
||||
// RadioVoice 音色管理表
|
||||
type RadioVoice struct {
|
||||
global.BaseModel
|
||||
SpeakerId string `gorm:"size:50;uniqueIndex" json:"speakerId"` // 音色ID (火山引擎的speaker值)
|
||||
Name string `gorm:"size:50" json:"name"` // 音色名称
|
||||
Description string `gorm:"size:255" json:"description"` // 音色描述
|
||||
Gender string `gorm:"size:10" json:"gender"` // 性别: male/female/neutral
|
||||
Icon string `gorm:"size:255" json:"icon"` // 音色图标URL
|
||||
AudioId string `gorm:"size:50" json:"audioId"` // 试听音频OSS ID
|
||||
Audio *system.Oss `gorm:"foreignKey:AudioId" json:"audio"` // 试听音频OSS
|
||||
Sort int `gorm:"default:0" json:"sort"` // 排序
|
||||
Status int `gorm:"default:1" json:"status"` // 状态 0:禁用 1:启用
|
||||
IsDefault int `gorm:"default:0" json:"isDefault"` // 是否默认音色 0:否 1:是
|
||||
UseCount int `gorm:"default:0" json:"useCount"` // 使用次数
|
||||
}
|
||||
|
||||
func (RadioVoice) TableName() string {
|
||||
return "sundynix_radio_voice"
|
||||
}
|
||||
@@ -19,6 +19,10 @@ type AddHistory struct {
|
||||
Duration int `json:"duration"` // 节目总时长(秒)
|
||||
}
|
||||
|
||||
type RemoveHistory struct {
|
||||
ProgramId string `json:"programId" binding:"required"` // 节目ID
|
||||
}
|
||||
|
||||
// ToggleLike 切换点赞请求
|
||||
type ToggleLike struct {
|
||||
ProgramId string `json:"programId" binding:"required"` // 节目ID
|
||||
@@ -51,6 +55,11 @@ type GetHistoryList struct {
|
||||
common.PageInfo
|
||||
}
|
||||
|
||||
// GetLikeList 获取点赞列表请求
|
||||
type GetLikeList struct {
|
||||
common.PageInfo
|
||||
}
|
||||
|
||||
// GetFavoriteList 获取收藏列表请求
|
||||
type GetFavoriteList struct {
|
||||
common.PageInfo
|
||||
@@ -66,3 +75,10 @@ type GetCommentList struct {
|
||||
type GetSubscriptionList struct {
|
||||
common.PageInfo
|
||||
}
|
||||
|
||||
// TrendQuery 趋势查询请求(复用于所有趋势分析接口)
|
||||
type TrendQuery struct {
|
||||
StartDate string `json:"startDate" form:"startDate"` // 开始日期 "2026-01-01"
|
||||
EndDate string `json:"endDate" form:"endDate"` // 结束日期 "2026-03-10"
|
||||
ChannelId string `json:"channelId" form:"channelId"` // 可选,按频道筛选
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import common "sundynix-go/model/commom/request"
|
||||
// GetProgramList 获取节目列表请求
|
||||
type GetProgramList struct {
|
||||
common.PageInfo
|
||||
ChannelId string `json:"channelId" binding:"required" form:"channelId"` // 频道ID
|
||||
ChannelId string `json:"channelId" form:"channelId"` // 频道ID
|
||||
Title string `json:"title" form:"title"` // 节目标题
|
||||
Status int `json:"status" form:"status"` // 状态
|
||||
}
|
||||
@@ -36,3 +36,16 @@ type UpdateProgram struct {
|
||||
Tags string `json:"tags"` // 标签
|
||||
Status int `json:"status"` // 状态
|
||||
}
|
||||
|
||||
// VolcengineTTSRequest 火山引擎语音合成请求
|
||||
type VolcengineTTSRequest struct {
|
||||
ProgramId string `json:"programId" binding:"required"` // 节目ID
|
||||
Text string `json:"text" binding:"required"` // 要合成的文本
|
||||
VoiceType string `json:"voiceType"` // 声音类型
|
||||
Speed int `json:"speed"` // 语速 -6到6
|
||||
Pitch int `json:"pitch"` // 音调 -8到8
|
||||
Volume int `json:"volume"` // 音量 0到10
|
||||
}
|
||||
|
||||
// GetVoiceTypeListRequest 获取声音类型列表请求
|
||||
type GetVoiceTypeListRequest struct{}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package request
|
||||
|
||||
type UpdateVipConfig struct {
|
||||
Id string `json:"id" binding:"required"`
|
||||
Price int `json:"price" binding:"required"`
|
||||
DiscountedPrice int `json:"discountedPrice"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package request
|
||||
|
||||
import common "sundynix-go/model/commom/request"
|
||||
|
||||
// GetVoiceList 获取音色列表请求
|
||||
type GetVoiceList struct {
|
||||
common.PageInfo
|
||||
Name string `json:"name" form:"name"` // 音色名称
|
||||
Status int `json:"status" form:"status"` // 状态
|
||||
}
|
||||
|
||||
// SaveVoice 保存音色请求
|
||||
type SaveVoice struct {
|
||||
SpeakerId string `json:"speakerId" binding:"required"` // 音色ID
|
||||
Name string `json:"name" binding:"required"` // 音色名称
|
||||
Description string `json:"description"` // 音色描述
|
||||
Gender string `json:"gender"` // 性别: male/female/neutral
|
||||
Icon string `json:"icon"` // 音色图标URL
|
||||
AudioId string `json:"audioId"` // 试听音频OSS ID
|
||||
Sort int `json:"sort"` // 排序
|
||||
Status int `json:"status"` // 状态
|
||||
IsDefault int `json:"isDefault"` // 是否默认音色
|
||||
}
|
||||
|
||||
// UpdateVoice 更新音色请求
|
||||
type UpdateVoice struct {
|
||||
Id string `json:"id" binding:"required"` // 音色ID
|
||||
SpeakerId string `json:"speakerId"` // 音色ID
|
||||
Name string `json:"name"` // 音色名称
|
||||
Description string `json:"description"` // 音色描述
|
||||
Gender string `json:"gender"` // 性别
|
||||
Icon string `json:"icon"` // 音色图标URL
|
||||
AudioId string `json:"audioId"` // 试听音频OSS ID
|
||||
Sort int `json:"sort"` // 排序
|
||||
Status int `json:"status"` // 状态
|
||||
IsDefault int `json:"isDefault"` // 是否默认音色
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package response
|
||||
|
||||
// TrendPoint 折线图通用数据点(日期 + 数量)
|
||||
type TrendPoint struct {
|
||||
Date string `json:"date"` // 日期 "2026-03-01"
|
||||
Count int64 `json:"count"` // 数量
|
||||
}
|
||||
|
||||
// ListeningTrendResponse 收听趋势响应
|
||||
type ListeningTrendResponse struct {
|
||||
Trend []TrendPoint `json:"trend"` // 每日收听人次趋势
|
||||
TotalCount int64 `json:"totalCount"` // 期间总收听次数
|
||||
}
|
||||
|
||||
// SubscriptionTrendResponse 订阅趋势响应
|
||||
type SubscriptionTrendResponse struct {
|
||||
Trend []TrendPoint `json:"trend"` // 每日新增订阅数趋势
|
||||
TotalNewSubs int64 `json:"totalNewSubs"` // 期间新增订阅总数
|
||||
}
|
||||
|
||||
// RenewalTrendResponse 续费趋势响应
|
||||
type RenewalTrendResponse struct {
|
||||
Trend []TrendPoint `json:"trend"` // 每日续费订单数趋势
|
||||
TotalRenewals int64 `json:"totalRenewals"` // 期间续费总数
|
||||
}
|
||||
|
||||
// SubscriberStatsResponse 订阅用户统计响应
|
||||
type SubscriberStatsResponse struct {
|
||||
ActiveSubscribers int64 `json:"activeSubscribers"` // 当前有效订阅用户数
|
||||
ExpiredSubscribers int64 `json:"expiredSubscribers"` // 已过期订阅用户数
|
||||
TotalSubscribers int64 `json:"totalSubscribers"` // 历史总订阅用户数(去重)
|
||||
ActiveTrend []TrendPoint `json:"activeTrend"` // 每日有效订阅用户数趋势
|
||||
}
|
||||
|
||||
// CompletionRateResponse 内容质量:完播率
|
||||
type CompletionRateResponse struct {
|
||||
ProgramId string `json:"programId"`
|
||||
Title string `json:"title"`
|
||||
AvgCompletion float64 `json:"avgCompletion"` // 0.0 - 1.0 平均完播进度
|
||||
PlayCount int64 `json:"playCount"` // 总播放样本数
|
||||
}
|
||||
|
||||
// RetentionResponse 用户黏性:留存分析
|
||||
type RetentionResponse struct {
|
||||
Date string `json:"date"` // 初始日期
|
||||
NewUsers int64 `json:"newUsers"` // 该日新增活跃用户数
|
||||
Retention []float64 `json:"retention"` // [次日留存, 3日留存, 7日留存, 30日留存]
|
||||
}
|
||||
|
||||
// FunnelResponse 商业转化:漏斗分析
|
||||
type FunnelResponse struct {
|
||||
ListenUsers int64 `json:"listenUsers"` // 活跃收听用户数
|
||||
OrderUsers int64 `json:"orderUsers"` // 尝试下单用户数
|
||||
PayUsers int64 `json:"payUsers"` // 支付成功用户数
|
||||
LTV float64 `json:"ltv"` // 人均生命周期价值 (Revenue / ListenUsers)
|
||||
}
|
||||
|
||||
// CategoryContribution 偏好探测:品类贡献点
|
||||
type CategoryContribution struct {
|
||||
CategoryId string `json:"categoryId"`
|
||||
CategoryName string `json:"categoryName"`
|
||||
ListenCount int64 `json:"listenCount"` // 播放量
|
||||
Revenue int64 `json:"revenue"` // 营收(分)
|
||||
Share float64 `json:"share"` // 营收占比 (0.0 - 1.0)
|
||||
}
|
||||
|
||||
// PreferenceAnalysisResponse 品类偏好分析响应
|
||||
type PreferenceAnalysisResponse struct {
|
||||
List []CategoryContribution `json:"list"`
|
||||
}
|
||||
|
||||
// VipStatsResponse VIP统计数据响应
|
||||
type VipStatsResponse struct {
|
||||
ActiveVipUsers int64 `json:"activeVipUsers"`
|
||||
VipRevenue int64 `json:"vipRevenue"`
|
||||
NewVipOrders int64 `json:"newVipOrders"`
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package response
|
||||
|
||||
import "time"
|
||||
|
||||
// RadioUserItem 电台用户列表项
|
||||
type RadioUserItem struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
NickName string `json:"nickName"`
|
||||
Account string `json:"account"`
|
||||
Phone string `json:"phone"`
|
||||
AvatarUrl string `json:"avatarUrl"`
|
||||
Gender int `json:"gender"` // 0:未知 1:男 2:女
|
||||
IsVip int `json:"isVip"` // 0:否 1:是
|
||||
VipExpireAt *time.Time `json:"vipExpireAt"` // VIP过期时间
|
||||
LastLoginAt *time.Time `json:"lastLoginAt"` // 最后登录时间
|
||||
LastLoginIp string `json:"lastLoginIp"` // 最后登录IP
|
||||
CreatedAt time.Time `json:"createdAt"` // 注册时间
|
||||
SubscribeCount int64 `json:"subscribeCount"` // 订阅频道数
|
||||
ListenCount int64 `json:"listenCount"` // 收听次数
|
||||
FavoriteCount int64 `json:"favoriteCount"` // 收藏数
|
||||
TotalSpent int64 `json:"totalSpent"` // 累计消费(分)
|
||||
OrderCount int64 `json:"orderCount"` // 订单数
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type GetUserList struct {
|
||||
common.PageInfo
|
||||
Account string `json:"account" form:"account"`
|
||||
Phone string `json:"phone" form:"phone"`
|
||||
IsVip *int `json:"isVip" form:"isVip"`
|
||||
}
|
||||
|
||||
type ChangePwd struct {
|
||||
|
||||
@@ -2,6 +2,7 @@ package system
|
||||
|
||||
import (
|
||||
"sundynix-go/global"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Login interface {
|
||||
@@ -13,17 +14,25 @@ type User struct {
|
||||
global.BaseModel
|
||||
TenantId string `gorm:"size:20;" json:"tenantId" form:"tenantId"`
|
||||
ClientId string `gorm:"size:20;" json:"clientId"`
|
||||
Name string `gorm:"size:20" json:"name" form:"name"`
|
||||
Name string `gorm:"size:100" json:"name" form:"name"`
|
||||
Account string `gorm:"size:11;" json:"account" form:"account"`
|
||||
Password string `gorm:"size:100;" json:"-" form:"password"`
|
||||
NickName string `gorm:"size:20;column:nick_name" json:"nickName" form:"nickName"`
|
||||
NickName string `gorm:"size:100;column:nick_name" json:"nickName" form:"nickName"`
|
||||
Phone string `gorm:"size:20;column:phone" json:"phone" form:"phone"`
|
||||
SessionKey string `gorm:"size:80;column:session_key" json:"sessionKey" form:"sessionKey"`
|
||||
UnionId string `gorm:"size:80;column:union_id" json:"unionId"`
|
||||
MiniOpenId string `gorm:"size:80;column:mini_open_id" json:"miniOpenId" form:"miniOpenId"`
|
||||
SaOpenId string `gorm:"size:80;column:sa_open_id" json:"saOpenId"`
|
||||
OpenId string `gorm:"size:80;column:open_id" json:"openId" form:"openId"`
|
||||
AvatarId string `gorm:"size:50;column:avatar_id" json:"avatarId"`
|
||||
Avatar *Oss `gorm:"foreignKey:AvatarId" json:"avatar"`
|
||||
Gender int `gorm:"default:0" json:"gender"` // 性别 0:未知 1:男 2:女
|
||||
Country string `gorm:"size:50" json:"country"` // 国家
|
||||
Province string `gorm:"size:50" json:"province"` // 省份
|
||||
City string `gorm:"size:50" json:"city"` // 城市
|
||||
Language string `gorm:"size:20" json:"language"` // 语言
|
||||
IsVip int `gorm:"default:0" json:"isVip"` // 是否VIP 0:否 1:是
|
||||
VipExpireAt *time.Time `gorm:"column:vip_expire_at" json:"vipExpireAt"` // VIP过期时间
|
||||
LastLoginIp string `gorm:"size:20;column:last_login_ip" json:"lastLoginIp"` // 最后登录IP
|
||||
LastLoginAt *time.Time `gorm:"column:last_login_at" json:"lastLoginAt"` // 最后登录时间
|
||||
}
|
||||
|
||||
func (u *User) GetAccount() string {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type AnalyticsRouter struct{}
|
||||
|
||||
func (r *AnalyticsRouter) InitAnalyticsRouter(Router *gin.RouterGroup) {
|
||||
analyticsRouter := Router.Group("radio/analytics")
|
||||
{
|
||||
analyticsRouter.GET("listening-trend", analyticsApi.GetListeningTrend)
|
||||
analyticsRouter.GET("subscription-trend", analyticsApi.GetSubscriptionTrend)
|
||||
analyticsRouter.GET("renewal-trend", analyticsApi.GetRenewalTrend)
|
||||
analyticsRouter.GET("subscriber-stats", analyticsApi.GetSubscriberStats)
|
||||
analyticsRouter.GET("content-quality", analyticsApi.GetContentQuality)
|
||||
analyticsRouter.GET("user-stickiness", analyticsApi.GetUserStickiness)
|
||||
analyticsRouter.GET("business-conversion", analyticsApi.GetBusinessConversion)
|
||||
analyticsRouter.GET("preference", analyticsApi.GetPreferenceAnalysis)
|
||||
analyticsRouter.GET("vip-stats", analyticsApi.GetVipStats)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ type RadioRouterGroup struct {
|
||||
SubscriptionRouter
|
||||
InteractionRouter
|
||||
PayRouter
|
||||
VipRouter
|
||||
AnalyticsRouter
|
||||
UserRouter
|
||||
VoiceRouter
|
||||
}
|
||||
|
||||
var GroupApp = new(RadioRouterGroup)
|
||||
@@ -20,4 +24,8 @@ var (
|
||||
subscriptionApi = v1.ApiGroupApp.RadioApiGroup.SubscriptionApi
|
||||
interactionApi = v1.ApiGroupApp.RadioApiGroup.InteractionApi
|
||||
payApi = v1.ApiGroupApp.RadioApiGroup.PayApi
|
||||
vipApi = v1.ApiGroupApp.RadioApiGroup.VipApi
|
||||
analyticsApi = v1.ApiGroupApp.RadioApiGroup.AnalyticsApi
|
||||
userApi = v1.ApiGroupApp.RadioApiGroup.UserApi
|
||||
voiceApi = v1.ApiGroupApp.RadioApiGroup.VoiceApi
|
||||
)
|
||||
|
||||
@@ -12,12 +12,16 @@ func (r *InteractionRouter) InitInteractionRouter(Router *gin.RouterGroup) {
|
||||
{
|
||||
historyRouter.POST("list", interactionApi.GetHistoryList)
|
||||
historyRouter.POST("add", interactionApi.AddHistory)
|
||||
historyRouter.POST("delete", interactionApi.DeleteHistory)
|
||||
historyRouter.GET("deleteAll", interactionApi.DeleteAllHistory)
|
||||
}
|
||||
|
||||
// 点赞
|
||||
likeRouter := Router.Group("like")
|
||||
{
|
||||
likeRouter.POST("toggle", interactionApi.ToggleLike)
|
||||
likeRouter.POST("list", interactionApi.GetLikeList)
|
||||
likeRouter.GET("removeAll", interactionApi.RemoveAllLike)
|
||||
}
|
||||
|
||||
// 收藏
|
||||
@@ -26,6 +30,7 @@ func (r *InteractionRouter) InitInteractionRouter(Router *gin.RouterGroup) {
|
||||
favoriteRouter.POST("list", interactionApi.GetFavoriteList)
|
||||
favoriteRouter.POST("add", interactionApi.AddFavorite)
|
||||
favoriteRouter.POST("remove", interactionApi.RemoveFavorite)
|
||||
favoriteRouter.GET("removeAll", interactionApi.RemoveAllFavorite)
|
||||
}
|
||||
|
||||
// 评论
|
||||
|
||||
@@ -14,5 +14,6 @@ func (r *ProgramRouter) InitProgramRouter(Router *gin.RouterGroup) {
|
||||
programRouter.POST("save", programApi.SaveProgram)
|
||||
programRouter.POST("update", programApi.UpdateProgram)
|
||||
programRouter.POST("delete", programApi.DeleteProgram)
|
||||
programRouter.GET("generate-tts", programApi.GenerateTTS)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type UserRouter struct{}
|
||||
|
||||
func (r *UserRouter) InitUserRouter(Router *gin.RouterGroup) {
|
||||
userRouter := Router.Group("/radio/user")
|
||||
{
|
||||
userRouter.GET("list", userApi.GetRadioUserList)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package radio
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
type VipRouter struct{}
|
||||
|
||||
func (r *VipRouter) InitVipRouter(Router *gin.RouterGroup) {
|
||||
vipRouter := Router.Group("/vip")
|
||||
{
|
||||
vipRouter.POST("config/update", vipApi.UpdateVipConfig)
|
||||
vipRouter.POST("config/detail", vipApi.VipConfigDetail)
|
||||
// 开通vip
|
||||
vipRouter.POST("vip", vipApi.VipVip)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type VoiceRouter struct{}
|
||||
|
||||
func (r *VoiceRouter) InitVoiceRouter(Router *gin.RouterGroup) {
|
||||
voiceRouter := Router.Group("/radio/voice")
|
||||
{
|
||||
voiceRouter.POST("list", voiceApi.GetVoiceList)
|
||||
voiceRouter.GET("detail", voiceApi.GetVoiceDetail)
|
||||
voiceRouter.GET("options", voiceApi.GetVoiceOptions)
|
||||
voiceRouter.POST("save", voiceApi.SaveVoice)
|
||||
voiceRouter.POST("update", voiceApi.UpdateVoice)
|
||||
voiceRouter.POST("delete", voiceApi.DeleteVoice)
|
||||
voiceRouter.GET("default", voiceApi.GetDefaultVoice)
|
||||
voiceRouter.POST("set-default", voiceApi.SetDefaultVoice)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"sundynix-go/global"
|
||||
"sundynix-go/model/radio/response"
|
||||
"sundynix-go/utils/timer"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AnalyticsService struct{}
|
||||
|
||||
// GetListeningTrend 获取收听趋势 (使用持久化的 ListenLog)
|
||||
func (s *AnalyticsService) GetListeningTrend(startDate, endDate, channelId string) (response.ListeningTrendResponse, error) {
|
||||
var resp response.ListeningTrendResponse
|
||||
start, end := timer.ParseDateRange(startDate, endDate)
|
||||
|
||||
db := global.DB.Table("sundynix_radio_listen_log")
|
||||
if channelId != "" {
|
||||
db = db.Where("channel_id = ?", channelId)
|
||||
}
|
||||
|
||||
// 按天聚合收听次数 (即便用户删除了 history,日志依然存在)
|
||||
err := db.Select("DATE(created_at) AS date, COUNT(*) AS count").
|
||||
Where("created_at BETWEEN ? AND ?", start, end).
|
||||
Where("deleted_at IS NULL").
|
||||
Group("DATE(created_at)").
|
||||
Order("date ASC").
|
||||
Scan(&resp.Trend).Error
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
for _, p := range resp.Trend {
|
||||
resp.TotalCount += p.Count
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetSubscriptionTrend 获取新增订阅趋势 (使用永久 Order 记录)
|
||||
func (s *AnalyticsService) GetSubscriptionTrend(startDate, endDate, channelId string) (response.SubscriptionTrendResponse, error) {
|
||||
var resp response.SubscriptionTrendResponse
|
||||
start, end := timer.ParseDateRange(startDate, endDate)
|
||||
|
||||
// 订阅趋势 = 首次购买该频道成功的订单
|
||||
// 通过子查询找到每个 (user_id, channel_id) 的最小成功订单日期
|
||||
subQuery := global.DB.Table("sundynix_order").
|
||||
Select("MIN(updated_at) as first_pay").
|
||||
Where("type = 1 AND status = 1 AND deleted_at IS NULL").
|
||||
Group("user_id, channel_id")
|
||||
|
||||
db := global.DB.Table("(?) as first_orders", subQuery).
|
||||
Where("first_pay BETWEEN ? AND ?", start, end)
|
||||
|
||||
err := db.Select("DATE(first_pay) AS date, COUNT(*) AS count").
|
||||
Group("DATE(first_pay)").
|
||||
Order("date ASC").
|
||||
Scan(&resp.Trend).Error
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
for _, p := range resp.Trend {
|
||||
resp.TotalNewSubs += p.Count
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetRenewalTrend 获取续费趋势 (使用永久 Order 记录)
|
||||
func (s *AnalyticsService) GetRenewalTrend(startDate, endDate, channelId string) (response.RenewalTrendResponse, error) {
|
||||
var resp response.RenewalTrendResponse
|
||||
start, end := timer.ParseDateRange(startDate, endDate)
|
||||
|
||||
// 续费 = 成功支付的订阅订单,且不是该用户对该频道的首笔订单
|
||||
db := global.DB.Table("sundynix_order AS o").
|
||||
Where("o.type = 1 AND o.status = 1 AND o.deleted_at IS NULL").
|
||||
Where("o.updated_at BETWEEN ? AND ?", start, end).
|
||||
Where("EXISTS (SELECT 1 FROM sundynix_order AS o2 WHERE o2.user_id = o.user_id AND o2.channel_id = o.channel_id AND o2.updated_at < o.updated_at AND o2.status = 1)")
|
||||
|
||||
if channelId != "" {
|
||||
db = db.Where("o.channel_id = ?", channelId)
|
||||
}
|
||||
|
||||
err := db.Select("DATE(o.updated_at) AS date, COUNT(*) AS count").
|
||||
Group("DATE(o.updated_at)").
|
||||
Order("date ASC").
|
||||
Scan(&resp.Trend).Error
|
||||
if err != nil {
|
||||
return resp, err
|
||||
}
|
||||
|
||||
for _, p := range resp.Trend {
|
||||
resp.TotalRenewals += p.Count
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetSubscriberStats 获取订阅用户统计 (混合实时 Subscription 与历史 Order)
|
||||
func (s *AnalyticsService) GetSubscriberStats(startDate, endDate, channelId string) (response.SubscriberStatsResponse, error) {
|
||||
var resp response.SubscriberStatsResponse
|
||||
start, end := timer.ParseDateRange(startDate, endDate)
|
||||
now := time.Now()
|
||||
|
||||
// 1. 当前有效订阅用户数 (实时表)
|
||||
activeQuery := global.DB.Table("sundynix_radio_subscription").
|
||||
Where("deleted_at IS NULL AND status = 1 AND expired_at > ?", now)
|
||||
if channelId != "" {
|
||||
activeQuery = activeQuery.Where("channel_id = ?", channelId)
|
||||
}
|
||||
activeQuery.Select("COUNT(DISTINCT user_id)").Scan(&resp.ActiveSubscribers)
|
||||
|
||||
// 2. 累积总订阅人数 (从历史 Order 表统计全量真实去重用户)
|
||||
totalUserQuery := global.DB.Table("sundynix_order").
|
||||
Where("type = 1 AND status = 1 AND deleted_at IS NULL")
|
||||
if channelId != "" {
|
||||
totalUserQuery = totalUserQuery.Where("channel_id = ?", channelId)
|
||||
}
|
||||
totalUserQuery.Select("COUNT(DISTINCT user_id)").Scan(&resp.TotalSubscribers)
|
||||
|
||||
// 3. 已流失/过期用户 = 历史总计 - 当前有效
|
||||
resp.ExpiredSubscribers = resp.TotalSubscribers - resp.ActiveSubscribers
|
||||
if resp.ExpiredSubscribers < 0 {
|
||||
resp.ExpiredSubscribers = 0
|
||||
}
|
||||
|
||||
// 4. 每日新增转化用户趋势 (从 Order 表提取)
|
||||
trendQuery := global.DB.Table("sundynix_order").
|
||||
Where("type = 1 AND status = 1 AND updated_at BETWEEN ? AND ? AND deleted_at IS NULL", start, end)
|
||||
if channelId != "" {
|
||||
trendQuery = trendQuery.Where("channel_id = ?", channelId)
|
||||
}
|
||||
|
||||
err := trendQuery.Select("DATE(updated_at) AS date, COUNT(DISTINCT user_id) AS count").
|
||||
Group("DATE(updated_at)").
|
||||
Order("date ASC").
|
||||
Scan(&resp.ActiveTrend).Error
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// GetContentQuality 内容质量分析:完播率
|
||||
func (s *AnalyticsService) GetContentQuality(channelId string) ([]response.CompletionRateResponse, error) {
|
||||
var results []response.CompletionRateResponse
|
||||
|
||||
// 使用更具韧性的 SQL 计算完播率:
|
||||
// 1. 优先使用 program 表中的 duration (得益于“贪婪学习”,它会越来越准)
|
||||
// 2. 如果 program.duration 为 0,则动态使用该节目在日志中的 MAX(progress) 作为推定时长
|
||||
// 3. 过滤掉完全没有任何播放深度记录的异常数据
|
||||
|
||||
baseQuery := global.DB.Table("sundynix_radio_program AS p").
|
||||
Select("p.id as program_id, p.title, " +
|
||||
"AVG(CAST(h.progress AS DECIMAL) / " +
|
||||
"NULLIF(COALESCE(NULLIF(p.duration, 0), (SELECT MAX(progress) FROM sundynix_radio_listen_log WHERE program_id = p.id)), 0)) as avg_completion, " +
|
||||
"COUNT(DISTINCT h.user_id) as play_count").
|
||||
Joins("INNER JOIN sundynix_radio_history AS h ON h.program_id = p.id")
|
||||
|
||||
if channelId != "" {
|
||||
baseQuery = baseQuery.Where("p.channel_id = ?", channelId)
|
||||
}
|
||||
|
||||
err := baseQuery.Group("p.id, p.title").
|
||||
Having("avg_completion >= 0").
|
||||
Order("avg_completion DESC").
|
||||
Limit(20).
|
||||
Scan(&results).Error
|
||||
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetUserStickiness 用户黏性分析:留存分析 (Cohort Analysis)
|
||||
func (s *AnalyticsService) GetUserStickiness(startDate, endDate string) ([]response.RetentionResponse, error) {
|
||||
var list []response.RetentionResponse
|
||||
start, end := timer.ParseDateRange(startDate, endDate)
|
||||
|
||||
// 获取时间范围内的每日新增活跃用户
|
||||
var dailyNewUsers []struct {
|
||||
Date string
|
||||
Count int64
|
||||
}
|
||||
global.DB.Table("sundynix_radio_listen_log").
|
||||
Select("DATE(created_at) as date, COUNT(DISTINCT user_id) as count").
|
||||
Where("created_at BETWEEN ? AND ?", start, end).
|
||||
Group("DATE(created_at)").
|
||||
Scan(&dailyNewUsers)
|
||||
|
||||
for _, day := range dailyNewUsers {
|
||||
dayTime, _ := time.Parse("2006-01-02", day.Date)
|
||||
res := response.RetentionResponse{
|
||||
Date: day.Date,
|
||||
NewUsers: day.Count,
|
||||
}
|
||||
|
||||
// 计算 1, 3, 7, 30 天后的留存率
|
||||
intervals := []int{1, 3, 7, 30}
|
||||
for _, dayDelta := range intervals {
|
||||
checkDayStart := dayTime.AddDate(0, 0, dayDelta)
|
||||
checkDayEnd := checkDayStart.AddDate(0, 0, 1)
|
||||
|
||||
var retainedCount int64
|
||||
// 统计在 day.Date 活跃过的用户中,有多少在 checkDay 再次出现了
|
||||
global.DB.Table("sundynix_radio_listen_log").
|
||||
Where("user_id IN (SELECT DISTINCT user_id FROM sundynix_radio_listen_log WHERE DATE(created_at) = ?)", day.Date).
|
||||
Where("created_at BETWEEN ? AND ?", checkDayStart, checkDayEnd).
|
||||
Distinct("user_id").
|
||||
Count(&retainedCount)
|
||||
|
||||
rate := 0.0
|
||||
if day.Count > 0 {
|
||||
rate = float64(retainedCount) / float64(day.Count)
|
||||
}
|
||||
res.Retention = append(res.Retention, rate)
|
||||
}
|
||||
list = append(list, res)
|
||||
}
|
||||
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// GetBusinessConversion 商业转化分析:漏斗与 LTV
|
||||
func (s *AnalyticsService) GetBusinessConversion(startDate, endDate string) (response.FunnelResponse, error) {
|
||||
var resp response.FunnelResponse
|
||||
start, end := timer.ParseDateRange(startDate, endDate)
|
||||
|
||||
// 1. 活跃收听用户数 (Top of Funnel)
|
||||
global.DB.Table("sundynix_radio_listen_log").
|
||||
Where("created_at BETWEEN ? AND ?", start, end).
|
||||
Distinct("user_id").Count(&resp.ListenUsers)
|
||||
|
||||
// 2. 尝试下单用户数 (Middle of Funnel) - 只要创建过订单就算
|
||||
global.DB.Table("sundynix_order").
|
||||
Where("created_at BETWEEN ? AND ?", start, end).
|
||||
Distinct("user_id").Count(&resp.OrderUsers)
|
||||
|
||||
// 3. 支付成功用户数 (Bottom of Funnel)
|
||||
global.DB.Table("sundynix_order").
|
||||
Where("updated_at BETWEEN ? AND ? AND status = 1", start, end).
|
||||
Distinct("user_id").Count(&resp.PayUsers)
|
||||
|
||||
// 4. LTV 计算 (活跃期内总营收 / 总活跃用户数)
|
||||
var totalRevenue int64
|
||||
global.DB.Table("sundynix_order").
|
||||
Where("updated_at BETWEEN ? AND ? AND status = 1", start, end).
|
||||
Select("SUM(amount)").Scan(&totalRevenue)
|
||||
|
||||
if resp.ListenUsers > 0 {
|
||||
resp.LTV = float64(totalRevenue) / float64(resp.ListenUsers)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// GetPreferenceAnalysis 品类偏好分析
|
||||
func (s *AnalyticsService) GetPreferenceAnalysis() (response.PreferenceAnalysisResponse, error) {
|
||||
var resp response.PreferenceAnalysisResponse
|
||||
var totalRevenue int64
|
||||
|
||||
// 获取总营收用于计算占比
|
||||
global.DB.Table("sundynix_order").Where("status = 1").Select("SUM(amount)").Scan(&totalRevenue)
|
||||
|
||||
// 按分类聚合播放量与营收
|
||||
err := global.DB.Table("sundynix_radio_category AS cat").
|
||||
Select("cat.id as category_id, cat.name as category_name, " +
|
||||
"COUNT(DISTINCT l.id) as listen_count, " +
|
||||
"COALESCE(SUM(DISTINCT o.amount), 0) as revenue").
|
||||
Joins("LEFT JOIN sundynix_radio_channel AS ch ON ch.category_id = cat.id").
|
||||
Joins("LEFT JOIN sundynix_radio_listen_log AS l ON l.channel_id = ch.id").
|
||||
Joins("LEFT JOIN sundynix_order AS o ON o.channel_id = ch.id AND o.status = 1").
|
||||
Group("cat.id, cat.name").
|
||||
Order("revenue DESC").
|
||||
Scan(&resp.List).Error
|
||||
|
||||
if totalRevenue > 0 {
|
||||
for i := range resp.List {
|
||||
resp.List[i].Share = float64(resp.List[i].Revenue) / float64(totalRevenue)
|
||||
}
|
||||
}
|
||||
|
||||
return resp, err
|
||||
}
|
||||
|
||||
// GetVipStats 获取VIP用户和营收统计
|
||||
func (s *AnalyticsService) GetVipStats(startDate, endDate string) (response.VipStatsResponse, error) {
|
||||
var resp response.VipStatsResponse
|
||||
start, end := timer.ParseDateRange(startDate, endDate)
|
||||
now := time.Now()
|
||||
|
||||
// 1. 获取当前有效VIP用户数
|
||||
global.DB.Table("sundynix_user").
|
||||
Where("is_vip = 1 AND vip_expire_at >= ?", now).
|
||||
Count(&resp.ActiveVipUsers)
|
||||
|
||||
// 2. 获取期间内VIP总营收 (Type = 2 为 VIP 支付)
|
||||
global.DB.Table("sundynix_order").
|
||||
Where("type = 2 AND status = 1 AND deleted_at IS NULL").
|
||||
Where("updated_at BETWEEN ? AND ?", start, end).
|
||||
Select("COALESCE(SUM(amount), 0)").
|
||||
Scan(&resp.VipRevenue)
|
||||
|
||||
// 3. 获取期间内新增VIP订单数
|
||||
global.DB.Table("sundynix_order").
|
||||
Where("type = 2 AND status = 1 AND deleted_at IS NULL").
|
||||
Where("updated_at BETWEEN ? AND ?", start, end).
|
||||
Count(&resp.NewVipOrders)
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -13,7 +13,7 @@ type CategoryService struct{}
|
||||
|
||||
// GetCategoryList 获取分类列表
|
||||
func (s *CategoryService) GetCategoryList(info radioReq.GetCategoryList) ([]radio.RadioCategory, int64, error) {
|
||||
db := global.DB.Model(&radio.RadioCategory{}).Preload("Icon").Preload("Cover")
|
||||
db := global.DB.Model(&radio.RadioCategory{}).Where("status = ?", 1)
|
||||
var list []radio.RadioCategory
|
||||
var total int64
|
||||
|
||||
@@ -43,9 +43,6 @@ func (s *CategoryService) GetCategoryTree() ([]radio.RadioCategory, error) {
|
||||
// Preload("Icon") 和 Preload("Cover") 用于加载 OSS 信息
|
||||
err := global.DB.Model(&radio.RadioCategory{}).
|
||||
Preload("Channels", "status = ?", 1). // 只加载启用的频道
|
||||
Preload("Channels.Cover"). // 级联加载频道的封面
|
||||
Preload("Icon").
|
||||
Preload("Cover").
|
||||
Order("sort desc").
|
||||
Find(&res).Error
|
||||
return res, err
|
||||
@@ -53,14 +50,14 @@ func (s *CategoryService) GetCategoryTree() ([]radio.RadioCategory, error) {
|
||||
|
||||
func (s *CategoryService) GetAllCategory() ([]radio.RadioCategory, error) {
|
||||
var res []radio.RadioCategory
|
||||
err := global.DB.Find(&res).Preload("Icon").Preload("Cover").Error
|
||||
err := global.DB.Where("status = ?", 1).Find(&res).Error
|
||||
return res, err
|
||||
}
|
||||
|
||||
// GetCategoryById 获取分类详情
|
||||
func (s *CategoryService) GetCategoryById(id string) (*radio.RadioCategory, error) {
|
||||
var category radio.RadioCategory
|
||||
err := global.DB.Where("id = ?", id).Preload("Icon").Preload("Cover").First(&category).Error
|
||||
err := global.DB.Where("id = ?", id).First(&category).Error
|
||||
return &category, err
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
type ChannelService struct{}
|
||||
|
||||
func (s *ChannelService) GetFreeChannelList(req common.PageInfo) ([]radio.RadioChannel, int64, error) {
|
||||
db := global.DB.Model(&radio.RadioChannel{}).Where("is_free = 1")
|
||||
db := global.DB.Model(&radio.RadioChannel{}).Where("is_free = 1").Where("status = ?", 1)
|
||||
var list []radio.RadioChannel
|
||||
var total int64
|
||||
err := db.Count(&total).Error
|
||||
@@ -33,7 +33,7 @@ func (s *ChannelService) GetFreeChannelList(req common.PageInfo) ([]radio.RadioC
|
||||
|
||||
// GetChannelList 获取频道列表
|
||||
func (s *ChannelService) GetChannelList(userId string, info radioReq.GetChannelList) ([]radio.RadioChannel, int64, error) {
|
||||
db := global.DB.Model(&radio.RadioChannel{})
|
||||
db := global.DB.Model(&radio.RadioChannel{}).Where("status = ?", 1)
|
||||
var list []radio.RadioChannel
|
||||
var total int64
|
||||
|
||||
@@ -91,7 +91,7 @@ func (s *ChannelService) GetChannelById(userId, id string) (radio.RadioChannel,
|
||||
return channel, nil
|
||||
}
|
||||
channel.HasSubscribed = 0
|
||||
if userId != "" {
|
||||
if userId != "" && userId != "0" {
|
||||
var sub radio.RadioSubscription
|
||||
err = global.DB.Model(&radio.RadioSubscription{}).
|
||||
Where("user_id = ?", userId).
|
||||
@@ -136,6 +136,7 @@ func (s *ChannelService) UpdateChannel(req radioReq.UpdateChannel) error {
|
||||
"description": req.Description,
|
||||
"cover": req.Cover,
|
||||
"tags": req.Tags,
|
||||
"is_free": req.IsFree,
|
||||
"is_vip_only": req.IsVipOnly,
|
||||
"monthly_price": req.MonthlyPrice,
|
||||
"quarterly_price": req.QuarterlyPrice,
|
||||
|
||||
@@ -8,6 +8,11 @@ type ServiceGroup struct {
|
||||
InteractionService
|
||||
PayService
|
||||
OrderService
|
||||
VipService
|
||||
TTSService
|
||||
AnalyticsService
|
||||
UserService
|
||||
VoiceService
|
||||
}
|
||||
|
||||
var GroupApp = new(ServiceGroup)
|
||||
|
||||
@@ -11,32 +11,59 @@ import (
|
||||
|
||||
type InteractionService struct{}
|
||||
|
||||
var InteractionServiceApp = new(InteractionService)
|
||||
|
||||
// AddHistory 添加收听历史
|
||||
func (s *InteractionService) AddHistory(userId string, req radioReq.AddHistory) error {
|
||||
// 先查找是否已存在记录
|
||||
// 1. 获取节目信息以拿到 ChannelId (用于日志冗余方便统计)
|
||||
var program radio.RadioProgram
|
||||
if err := global.DB.Select("id, channel_id").Where("id = ?", req.ProgramId).First(&program).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 写入/更新用户书签 (RadioHistory)
|
||||
var history radio.RadioHistory
|
||||
err := global.DB.Where("user_id = ? AND program_id = ?", userId, req.ProgramId).First(&history).Error
|
||||
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 不存在,创建新记录
|
||||
history = radio.RadioHistory{
|
||||
UserId: userId,
|
||||
ProgramId: req.ProgramId,
|
||||
Progress: req.Progress,
|
||||
Duration: req.Duration,
|
||||
}
|
||||
return global.DB.Create(&history).Error
|
||||
if err := global.DB.Create(&history).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
} else if err == nil {
|
||||
if err := global.DB.Model(&history).Updates(map[string]interface{}{
|
||||
"progress": req.Progress,
|
||||
"duration": req.Duration,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return err
|
||||
}
|
||||
|
||||
// 存在,更新进度
|
||||
return global.DB.Model(&history).Updates(map[string]interface{}{
|
||||
"progress": req.Progress,
|
||||
"duration": req.Duration,
|
||||
}).Error
|
||||
// 3. 贪婪学习:如果节目表时长为0,且前端传回了有效时长,则自动补全元数据
|
||||
if req.Duration > 0 && program.Duration == 0 {
|
||||
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", req.ProgramId).Update("duration", req.Duration)
|
||||
}
|
||||
|
||||
// 4. 异步写入不可删除的日志表 (RadioListenLog) 用于趋势统计
|
||||
go func() {
|
||||
listenLog := radio.RadioListenLog{
|
||||
UserId: userId,
|
||||
ProgramId: req.ProgramId,
|
||||
ChannelId: program.ChannelId,
|
||||
Progress: req.Progress,
|
||||
Duration: req.Duration,
|
||||
}
|
||||
global.DB.Create(&listenLog)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetHistoryList 获取收听历史列表
|
||||
@@ -89,6 +116,22 @@ func (s *InteractionService) ToggleLike(userId, programId string) (bool, error)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// GetLikeList 获取点赞列表
|
||||
func (s *InteractionService) GetLikeList(userId string, req radioReq.GetLikeList) ([]radio.RadioLike, int64, error) {
|
||||
db := global.DB.Model(&radio.RadioLike{}).Where("user_id = ?", userId).Preload("RadioProgram")
|
||||
var list []radio.RadioLike
|
||||
var total int64
|
||||
|
||||
err := db.Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (req.Current - 1) * req.PageSize
|
||||
err = db.Offset(offset).Limit(req.PageSize).Order("created_at DESC").Find(&list).Error
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
// IsLiked 检查是否已点赞
|
||||
func (s *InteractionService) IsLiked(userId, programId string) (bool, error) {
|
||||
var count int64
|
||||
@@ -122,7 +165,7 @@ func (s *InteractionService) RemoveFavorite(userId, programId string) error {
|
||||
|
||||
// GetFavoriteList 获取收藏列表
|
||||
func (s *InteractionService) GetFavoriteList(userId string, info radioReq.GetFavoriteList) ([]radio.RadioFavorite, int64, error) {
|
||||
db := global.DB.Model(&radio.RadioFavorite{}).Where("user_id = ?", userId)
|
||||
db := global.DB.Model(&radio.RadioFavorite{}).Where("user_id = ?", userId).Preload("RadioProgram")
|
||||
var list []radio.RadioFavorite
|
||||
var total int64
|
||||
|
||||
@@ -174,3 +217,19 @@ func (s *InteractionService) GetCommentList(programId string, info radioReq.GetC
|
||||
err = db.Offset(offset).Limit(info.PageSize).Order("created_at DESC").Find(&list).Error
|
||||
return list, total, err
|
||||
}
|
||||
|
||||
func (s *InteractionService) DeleteHistory(userId, programId string) error {
|
||||
return global.DB.Where("user_id = ? AND program_id = ?", userId, programId).Delete(&radio.RadioHistory{}).Error
|
||||
}
|
||||
|
||||
func (s *InteractionService) DeleteAllHistory(userId string) error {
|
||||
return global.DB.Where("user_id = ?", userId).Delete(&radio.RadioHistory{}).Error
|
||||
}
|
||||
|
||||
func (s *InteractionService) RemoveAllFavorite(userId string) error {
|
||||
return global.DB.Where("user_id = ?", userId).Delete(&radio.RadioFavorite{}).Error
|
||||
}
|
||||
|
||||
func (s *InteractionService) RemoveAllLike(userId string) error {
|
||||
return global.DB.Where("user_id = ?", userId).Delete(&radio.RadioLike{}).Error
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package radio
|
||||
import (
|
||||
"errors"
|
||||
"sundynix-go/model/radio"
|
||||
"sundynix-go/model/system"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
@@ -12,7 +13,7 @@ type OrderService struct{}
|
||||
|
||||
var OrderServiceApp = new(OrderService)
|
||||
|
||||
// ExecuteOrderUnlock 核心原子操作:解锁权限
|
||||
// ExecuteOrderUnlock 订阅/开通vip 核心原子操作:解锁权限
|
||||
func (s *OrderService) ExecuteOrderUnlock(tx *gorm.DB, outTradeNo string) error {
|
||||
var order radio.Order
|
||||
// 1. 锁住订单行,防止回调和主动查询并发导致时长翻倍
|
||||
@@ -33,8 +34,14 @@ func (s *OrderService) ExecuteOrderUnlock(tx *gorm.DB, outTradeNo string) error
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 4. 根据订单中的 sub_type 决定增加几个月
|
||||
if order.Type == 2 {
|
||||
//4.开通vip 过期时间为2099 年
|
||||
return tx.Model(&system.User{}).Where("id = ?", order.UserId).Updates(map[string]interface{}{
|
||||
"is_vip": 1,
|
||||
"vip_expire_at": time.Date(2099, 12, 31, 23, 59, 59, 999, time.Local),
|
||||
}).Error
|
||||
} else if order.Type == 1 {
|
||||
// 4. 订阅 根据订单中的 sub_type 决定增加几个月
|
||||
var months int
|
||||
switch order.SubscriptionType {
|
||||
case "1":
|
||||
@@ -70,3 +77,5 @@ func (s *OrderService) ExecuteOrderUnlock(tx *gorm.DB, outTradeNo string) error
|
||||
}).Error
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ func (s *PayService) PrePay(orderId, userId string) (resp *jsapi.PrepayWithReque
|
||||
Total: core.Int64(int64(order.Amount)),
|
||||
},
|
||||
Payer: &jsapi.Payer{
|
||||
Openid: core.String(user.MiniOpenId),
|
||||
Openid: core.String(user.OpenId),
|
||||
},
|
||||
//Detail: &jsapi.Detail{
|
||||
// CostPrice: core.Int64(608800),
|
||||
@@ -147,16 +147,13 @@ func (s *PayService) PayCallback(c *gin.Context) error {
|
||||
TradeType: *transaction.TradeType,
|
||||
TransactionId: *transaction.TransactionId,
|
||||
}
|
||||
err = global.DB.Create(&payNotify).Error
|
||||
if err != nil {
|
||||
if err := tx.Create(&payNotify).Error; err != nil {
|
||||
global.Logger.Error("wxPay回调-存储数据异常:", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
if payNotify.TradeState == "SUCCESS" {
|
||||
return OrderServiceApp.ExecuteOrderUnlock(tx, *transaction.OutTradeNo)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sundynix-go/global"
|
||||
"sundynix-go/model/radio"
|
||||
radioReq "sundynix-go/model/radio/request"
|
||||
@@ -12,7 +13,7 @@ type ProgramService struct{}
|
||||
|
||||
// GetProgramList 获取节目列表
|
||||
func (s *ProgramService) GetProgramList(info radioReq.GetProgramList) ([]radio.RadioProgram, int64, error) {
|
||||
db := global.DB.Model(&radio.RadioProgram{}).Preload("Audio")
|
||||
db := global.DB.Model(&radio.RadioProgram{}).Where("status = ?", 1).Preload("Audio")
|
||||
var list []radio.RadioProgram
|
||||
var total int64
|
||||
|
||||
@@ -37,9 +38,20 @@ func (s *ProgramService) GetProgramList(info radioReq.GetProgramList) ([]radio.R
|
||||
}
|
||||
|
||||
// GetProgramById 获取节目详情
|
||||
func (s *ProgramService) GetProgramById(id string) (*radio.RadioProgram, error) {
|
||||
func (s *ProgramService) GetProgramById(id, userId string) (*radio.RadioProgram, error) {
|
||||
var program radio.RadioProgram
|
||||
err := global.DB.Where("id = ?", id).Preload("Audio").First(&program).Error
|
||||
program.HasLiked = 0
|
||||
program.HasFavorite = 0
|
||||
liked, err := InteractionServiceApp.IsLiked(userId, id)
|
||||
if liked {
|
||||
program.HasLiked = 1
|
||||
}
|
||||
favorite, err := InteractionServiceApp.IsFavorited(userId, id)
|
||||
if favorite {
|
||||
program.HasFavorite = 1
|
||||
}
|
||||
|
||||
return &program, err
|
||||
}
|
||||
|
||||
@@ -100,3 +112,38 @@ func (s *ProgramService) IncrementPlayCount(id string) error {
|
||||
return global.DB.Model(&radio.RadioProgram{}).Where("id = ?", id).
|
||||
UpdateColumn("play_count", gorm.Expr("play_count + ?", 1)).Error
|
||||
}
|
||||
|
||||
// GenerateTTS 生成TTS语音并更新节目 (异步)
|
||||
func (s *ProgramService) GenerateTTS(programId string, speaker string) error {
|
||||
// 1. 获取节目内容
|
||||
var program radio.RadioProgram
|
||||
if err := global.DB.Where("id = ?", programId).First(&program).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if program.Content == "" {
|
||||
return fmt.Errorf("节目内容为空")
|
||||
}
|
||||
|
||||
// 提前将状态标记为: 1正在生成音频
|
||||
if err := global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 1).Error; err != nil {
|
||||
global.Logger.Error(fmt.Sprintf("更新节目[%s]音频状态为正在生成失败", programId))
|
||||
// 容错处理: 虽然状态更新失败,但可以继续生成流程
|
||||
}
|
||||
|
||||
// 2. 调用TTS提交任务 (异步,后台处理)
|
||||
ttsReq := TTSRequest{
|
||||
Text: program.Content,
|
||||
Speaker: speaker, // 如果为空,底层接口会默认赋予 zh_male_dayi_uranus_bigtts
|
||||
ProgramId: programId,
|
||||
}
|
||||
_, err := TTSServiceApp.SubmitTTSTask(ttsReq)
|
||||
if err != nil {
|
||||
// 提交失败,恢复原本状态 0:无音频
|
||||
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0)
|
||||
return err
|
||||
}
|
||||
|
||||
// 任务已提交,异步处理中
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
common "sundynix-go/model/commom/request"
|
||||
"sundynix-go/model/radio"
|
||||
"sundynix-go/model/radio/request"
|
||||
"sundynix-go/model/system"
|
||||
"sundynix-go/utils/uniqueid"
|
||||
"sundynix-go/utils/wechat"
|
||||
|
||||
@@ -64,6 +65,11 @@ func (s *SubscriptionService) UnlockChannel(userId string, req request.UnlockCha
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
var user system.User
|
||||
err = global.DB.Where("id = ?", userId).First(&user).Error
|
||||
if err != nil || user.OpenId == "" {
|
||||
return nil, "", err
|
||||
}
|
||||
//2.创建一个订单 根据eventType 创建不同的订单
|
||||
var price int
|
||||
var orderName string
|
||||
@@ -81,6 +87,7 @@ func (s *SubscriptionService) UnlockChannel(userId string, req request.UnlockCha
|
||||
return nil, "", errors.New("无效的订阅类型")
|
||||
}
|
||||
order := radio.Order{
|
||||
Type: 1, //很重要 1订阅 2开通vip
|
||||
UserId: userId,
|
||||
OutTradeNo: uniqueid.GenOrderNo(),
|
||||
ChannelId: req.ChannelId,
|
||||
@@ -93,11 +100,6 @@ func (s *SubscriptionService) UnlockChannel(userId string, req request.UnlockCha
|
||||
return nil, "", err
|
||||
}
|
||||
//4.调用微信api 拉起支付
|
||||
var user radio.RadioUser
|
||||
err = global.DB.Where("user_id = ?", userId).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
payClient, err := wechat.GetWxPayClient()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"sundynix-go/global"
|
||||
"sundynix-go/model/radio"
|
||||
"sundynix-go/model/system"
|
||||
"sundynix-go/pkg/httpclient"
|
||||
"sundynix-go/utils/upload"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// TTSRequest TTS请求参数
|
||||
type TTSRequest struct {
|
||||
Text string // 要转换的文本
|
||||
Speaker string // 声音类型
|
||||
ProgramId string // 节目ID
|
||||
}
|
||||
|
||||
// SubmitTTSTask 提交长文本TTS任务 (异步)
|
||||
func (t *TTSService) SubmitTTSTask(req TTSRequest) (string, error) {
|
||||
if req.Text == "" {
|
||||
return "", fmt.Errorf("文本内容不能为空")
|
||||
}
|
||||
|
||||
taskId, err := t.doSubmitTTSTask(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("提交TTS任务失败: %v", err)
|
||||
}
|
||||
|
||||
go t.asyncProcessResult(req.ProgramId, taskId)
|
||||
return taskId, nil
|
||||
}
|
||||
|
||||
// doSubmitTTSTask 提交任务
|
||||
func (t *TTSService) doSubmitTTSTask(req TTSRequest) (string, error) {
|
||||
url := "https://openspeech.bytedance.com/api/v3/tts/submit"
|
||||
appID := global.Config.TTS.AppId
|
||||
accessKey := global.Config.TTS.AccessKey
|
||||
resourceID := global.Config.TTS.ResourceId
|
||||
if resourceID == "" {
|
||||
resourceID = "seed-tts-2.0"
|
||||
}
|
||||
speaker := req.Speaker
|
||||
if speaker == "" {
|
||||
speaker = "zh_male_dayi_uranus_bigtts"
|
||||
}
|
||||
|
||||
bodyData := map[string]interface{}{
|
||||
"user": map[string]interface{}{"uid": "123123"},
|
||||
"unique_id": uuid.New().String(),
|
||||
"req_params": map[string]interface{}{
|
||||
"text": req.Text,
|
||||
"speaker": speaker,
|
||||
"audio_params": map[string]interface{}{
|
||||
"format": "mp3",
|
||||
"sample_rate": 24000,
|
||||
"enable_timestamp": true,
|
||||
},
|
||||
"additions": "{}",
|
||||
},
|
||||
}
|
||||
jsonBody, _ := json.Marshal(bodyData)
|
||||
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
httpReq.Header.Set("X-Api-App-Id", appID)
|
||||
httpReq.Header.Set("X-Api-Access-Key", accessKey)
|
||||
httpReq.Header.Set("X-Api-Resource-Id", resourceID)
|
||||
httpReq.Header.Set("X-Api-Request-Id", uuid.New().String())
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Connection", "keep-alive")
|
||||
|
||||
resp, err := httpclient.GetClient().Do(httpReq)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBytes, _ := io.ReadAll(resp.Body)
|
||||
return "", fmt.Errorf("请求失败, 状态码: %d, 返回: %s", resp.StatusCode, string(respBytes))
|
||||
}
|
||||
|
||||
var result struct {
|
||||
Data struct {
|
||||
TaskId string `json:"task_id"`
|
||||
} `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if result.Data.TaskId == "" {
|
||||
return "", fmt.Errorf("未能获取任务ID: %s", result.Message)
|
||||
}
|
||||
return result.Data.TaskId, nil
|
||||
}
|
||||
|
||||
// asyncProcessResult 异步处理TTS结果
|
||||
func (t *TTSService) asyncProcessResult(programId, taskId string) {
|
||||
resultUrl, err := t.waitForResult(taskId)
|
||||
if err != nil {
|
||||
global.Logger.Error(fmt.Sprintf("TTS任务失败, TaskId: %s, Error: %v", taskId, err))
|
||||
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0)
|
||||
return
|
||||
}
|
||||
|
||||
audioData, err := t.downloadAudio(resultUrl)
|
||||
if err != nil {
|
||||
global.Logger.Error(fmt.Sprintf("下载音频失败, TaskId: %s, Error: %v", taskId, err))
|
||||
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0)
|
||||
return
|
||||
}
|
||||
|
||||
audioId, err := t.uploadToOSS(audioData, programId)
|
||||
if err != nil {
|
||||
global.Logger.Error(fmt.Sprintf("上传OSS失败, TaskId: %s, Error: %v", taskId, err))
|
||||
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0)
|
||||
return
|
||||
}
|
||||
|
||||
if err := global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).
|
||||
Updates(map[string]interface{}{
|
||||
"audio_id": audioId,
|
||||
"audio_status": 2, // 音频就绪
|
||||
}).Error; err != nil {
|
||||
global.Logger.Error(fmt.Sprintf("更新节目音频ID失败, TaskId: %s, Error: %v", taskId, err))
|
||||
global.DB.Model(&radio.RadioProgram{}).Where("id = ?", programId).Update("audio_status", 0)
|
||||
return
|
||||
}
|
||||
|
||||
global.Logger.Info(fmt.Sprintf("TTS任务完成, TaskId: %s, ProgramId: %s, AudioId: %s", taskId, programId, audioId))
|
||||
}
|
||||
|
||||
// waitForResult 轮询查询任务结果,返回音频下载URL
|
||||
func (t *TTSService) waitForResult(taskId string) (string, error) {
|
||||
maxRetries := 10
|
||||
interval := 30 * time.Second
|
||||
|
||||
for i := 0; i < maxRetries; i++ {
|
||||
time.Sleep(interval)
|
||||
resultUrl, status, statusMsg, err := t.queryTaskResult(taskId)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch status {
|
||||
case 1:
|
||||
return resultUrl, nil
|
||||
case 2:
|
||||
return "", fmt.Errorf("TTS合成失败: %s", statusMsg)
|
||||
default:
|
||||
global.Logger.Debug(fmt.Sprintf("TTS任务处理中, TaskId: %s, 重试次数: %d", taskId, i+1))
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("TTS任务超时, TaskId: %s", taskId)
|
||||
}
|
||||
|
||||
// queryTaskResult 查询任务状态
|
||||
func (t *TTSService) queryTaskResult(taskId string) (string, int, string, error) {
|
||||
url := "https://openspeech.bytedance.com/api/v3/tts/query"
|
||||
appID := global.Config.TTS.AppId
|
||||
accessKey := global.Config.TTS.AccessKey
|
||||
resourceID := global.Config.TTS.ResourceId
|
||||
if resourceID == "" {
|
||||
resourceID = "seed-tts-2.0"
|
||||
}
|
||||
|
||||
bodyData := map[string]interface{}{"task_id": taskId}
|
||||
jsonBody, _ := json.Marshal(bodyData)
|
||||
httpReq, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBody))
|
||||
if err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
|
||||
httpReq.Header.Set("X-Api-App-Id", appID)
|
||||
httpReq.Header.Set("X-Api-Access-Key", accessKey)
|
||||
httpReq.Header.Set("X-Api-Resource-Id", resourceID)
|
||||
httpReq.Header.Set("X-Api-Request-Id", uuid.New().String())
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
httpReq.Header.Set("Connection", "keep-alive")
|
||||
|
||||
resp, err := httpclient.GetClient().Do(httpReq)
|
||||
if err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
respBytes, _ := io.ReadAll(resp.Body)
|
||||
return "", 0, "", fmt.Errorf("查询任务失败, 状态码: %d, 返回: %s", resp.StatusCode, string(respBytes))
|
||||
}
|
||||
|
||||
respBytes, _ := io.ReadAll(resp.Body)
|
||||
global.Logger.Info(fmt.Sprintf("火山查询原始结果: %s", string(respBytes)))
|
||||
|
||||
var result struct {
|
||||
Data map[string]interface{} `json:"data"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(respBytes, &result); err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
|
||||
status := 0
|
||||
audioUrl := ""
|
||||
|
||||
if result.Data != nil {
|
||||
// 官方文档定义:
|
||||
// 1: Running (正在处理)
|
||||
// 2: Success (处理成功)
|
||||
// 3: Failure (处理失败)
|
||||
|
||||
volcStatus := 0
|
||||
if statusVal, ok := result.Data["task_status"].(float64); ok {
|
||||
volcStatus = int(statusVal)
|
||||
} else if statusStr, ok := result.Data["task_status"].(string); ok {
|
||||
if statusStr == "1" {
|
||||
volcStatus = 1
|
||||
} else if statusStr == "2" || statusStr == "success" || statusStr == "done" {
|
||||
volcStatus = 2
|
||||
} else if statusStr == "3" || statusStr == "failed" || statusStr == "error" {
|
||||
volcStatus = 3
|
||||
}
|
||||
}
|
||||
|
||||
// 映射到内部状态: 0: 处理中, 1: 成功, 2: 失败
|
||||
if volcStatus == 1 {
|
||||
status = 0 // Running
|
||||
} else if volcStatus == 2 {
|
||||
status = 1 // Success
|
||||
} else if volcStatus == 3 {
|
||||
status = 2 // Failure
|
||||
}
|
||||
|
||||
if val, ok := result.Data["audio_url"].(string); ok {
|
||||
audioUrl = val
|
||||
} else if val, ok := result.Data["audio"].(string); ok {
|
||||
audioUrl = val
|
||||
}
|
||||
}
|
||||
|
||||
if audioUrl != "" {
|
||||
status = 1
|
||||
}
|
||||
|
||||
if status == 1 && audioUrl == "" {
|
||||
// 任务状态为1时如果没有url,继续等待(轮询)避免直接返回空URL使下载失败
|
||||
status = 0
|
||||
}
|
||||
|
||||
return audioUrl, status, result.Message, nil
|
||||
}
|
||||
|
||||
// TTSService TTS服务
|
||||
type TTSService struct{}
|
||||
|
||||
var TTSServiceApp = new(TTSService)
|
||||
|
||||
// downloadAudio 从URL下载音频数据
|
||||
func (t *TTSService) downloadAudio(audioUrl string) ([]byte, error) {
|
||||
resp, err := httpclient.GetClient().Get(audioUrl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("下载音频失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("下载音频HTTP错误: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
audioData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取音频数据失败: %v", err)
|
||||
}
|
||||
|
||||
return audioData, nil
|
||||
}
|
||||
|
||||
// uploadToOSS 上传音频到OSS并保存到数据库
|
||||
func (t *TTSService) uploadToOSS(audioData []byte, programId string) (string, error) {
|
||||
instance := upload.OssInstance()
|
||||
minioClient, ok := instance.(*upload.Minio)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("获取MinIO客户端失败")
|
||||
}
|
||||
timestamp := time.Now().UnixMicro()
|
||||
timestr := strconv.FormatInt(timestamp, 10)
|
||||
key := fmt.Sprintf("audio/%s/%s.mp3", time.Now().Format("2006-01-02"), programId+"-"+timestr)
|
||||
filename := fmt.Sprintf("program-%s.mp3", programId)
|
||||
|
||||
fileURL, err := minioClient.UploadBytes(audioData, key, "audio/mpeg")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("上传文件到OSS失败: %v", err)
|
||||
}
|
||||
|
||||
hashStr := fmt.Sprintf("%x", md5.Sum(audioData))
|
||||
|
||||
oss := system.Oss{
|
||||
Name: filename,
|
||||
Url: fileURL,
|
||||
Key: key,
|
||||
Suffix: "mp3",
|
||||
Tag: "mp3",
|
||||
MD5: hashStr,
|
||||
}
|
||||
if err := global.DB.Create(&oss).Error; err != nil {
|
||||
return "", fmt.Errorf("保存文件记录失败: %v", err)
|
||||
}
|
||||
|
||||
return oss.Id, nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"sundynix-go/global"
|
||||
common "sundynix-go/model/commom/request"
|
||||
"sundynix-go/model/radio/response"
|
||||
"sundynix-go/model/system"
|
||||
)
|
||||
|
||||
type UserService struct{}
|
||||
|
||||
// GetRadioUserList 获取电台用户列表(带订阅/收听统计)
|
||||
func (s *UserService) GetRadioUserList(info common.PageInfo, isVip int, keyword string) ([]response.RadioUserItem, int64, error) {
|
||||
var total int64
|
||||
var users []system.User
|
||||
|
||||
db := global.DB.Model(&system.User{})
|
||||
|
||||
// 关键字搜索
|
||||
if keyword != "" {
|
||||
db = db.Where("nick_name LIKE ? OR phone LIKE ? OR account LIKE ?",
|
||||
"%"+keyword+"%", "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
|
||||
// VIP 筛选: 1=VIP, 2=非VIP, 0或其他=全部
|
||||
if isVip == 1 {
|
||||
db = db.Where("is_vip = 1")
|
||||
} else if isVip == 2 {
|
||||
db = db.Where("is_vip = 0")
|
||||
}
|
||||
|
||||
// 统计总数
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 分页查询用户列表
|
||||
if err := db.Preload("Avatar").
|
||||
Scopes(info.Paginate()).
|
||||
Order("created_at DESC").
|
||||
Find(&users).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 批量获取用户ID
|
||||
userIds := make([]string, len(users))
|
||||
for i, u := range users {
|
||||
userIds[i] = u.Id
|
||||
}
|
||||
|
||||
if len(userIds) == 0 {
|
||||
return []response.RadioUserItem{}, total, nil
|
||||
}
|
||||
|
||||
// 批量查询订阅数
|
||||
type UserCount struct {
|
||||
UserId string
|
||||
Count int64
|
||||
}
|
||||
var subCounts []UserCount
|
||||
global.DB.Table("sundynix_radio_subscription").
|
||||
Select("user_id, COUNT(*) as count").
|
||||
Where("user_id IN ? AND status = 1 AND deleted_at IS NULL", userIds).
|
||||
Group("user_id").
|
||||
Scan(&subCounts)
|
||||
subMap := make(map[string]int64)
|
||||
for _, sc := range subCounts {
|
||||
subMap[sc.UserId] = sc.Count
|
||||
}
|
||||
|
||||
// 批量查询收听次数
|
||||
var listenCounts []UserCount
|
||||
global.DB.Table("sundynix_radio_listen_log").
|
||||
Select("user_id, COUNT(*) as count").
|
||||
Where("user_id IN ? AND deleted_at IS NULL", userIds).
|
||||
Group("user_id").
|
||||
Scan(&listenCounts)
|
||||
listenMap := make(map[string]int64)
|
||||
for _, lc := range listenCounts {
|
||||
listenMap[lc.UserId] = lc.Count
|
||||
}
|
||||
|
||||
// 批量查询收藏数
|
||||
var favCounts []UserCount
|
||||
global.DB.Table("sundynix_radio_favorite").
|
||||
Select("user_id, COUNT(*) as count").
|
||||
Where("user_id IN ? AND deleted_at IS NULL", userIds).
|
||||
Group("user_id").
|
||||
Scan(&favCounts)
|
||||
favMap := make(map[string]int64)
|
||||
for _, fc := range favCounts {
|
||||
favMap[fc.UserId] = fc.Count
|
||||
}
|
||||
|
||||
// 批量查询订单总额(分)
|
||||
type UserAmount struct {
|
||||
UserId string
|
||||
TotalAmount int64
|
||||
OrderCount int64
|
||||
}
|
||||
var orderStats []UserAmount
|
||||
global.DB.Table("sundynix_order").
|
||||
Select("user_id, SUM(amount) as total_amount, COUNT(*) as order_count").
|
||||
Where("user_id IN ? AND status = 1 AND deleted_at IS NULL", userIds).
|
||||
Group("user_id").
|
||||
Scan(&orderStats)
|
||||
amountMap := make(map[string]int64)
|
||||
orderCountMap := make(map[string]int64)
|
||||
for _, os := range orderStats {
|
||||
amountMap[os.UserId] = os.TotalAmount
|
||||
orderCountMap[os.UserId] = os.OrderCount
|
||||
}
|
||||
|
||||
// 组装结果
|
||||
result := make([]response.RadioUserItem, len(users))
|
||||
for i, u := range users {
|
||||
avatarUrl := ""
|
||||
if u.Avatar != nil {
|
||||
avatarUrl = u.Avatar.Url
|
||||
}
|
||||
result[i] = response.RadioUserItem{
|
||||
Id: u.Id,
|
||||
Name: u.Name,
|
||||
NickName: u.NickName,
|
||||
Account: u.Account,
|
||||
Phone: u.Phone,
|
||||
AvatarUrl: avatarUrl,
|
||||
Gender: u.Gender,
|
||||
IsVip: u.IsVip,
|
||||
VipExpireAt: u.VipExpireAt,
|
||||
LastLoginAt: u.LastLoginAt,
|
||||
LastLoginIp: u.LastLoginIp,
|
||||
CreatedAt: u.CreatedAt,
|
||||
SubscribeCount: subMap[u.Id],
|
||||
ListenCount: listenMap[u.Id],
|
||||
FavoriteCount: favMap[u.Id],
|
||||
TotalSpent: amountMap[u.Id],
|
||||
OrderCount: orderCountMap[u.Id],
|
||||
}
|
||||
}
|
||||
|
||||
return result, total, nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sundynix-go/global"
|
||||
"sundynix-go/model/radio"
|
||||
"sundynix-go/model/radio/request"
|
||||
"sundynix-go/model/system"
|
||||
"sundynix-go/utils/uniqueid"
|
||||
"sundynix-go/utils/wechat"
|
||||
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/core"
|
||||
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
|
||||
)
|
||||
|
||||
type VipService struct{}
|
||||
|
||||
func (s *VipService) UpdateVipConfig(req request.UpdateVipConfig) error {
|
||||
updateData := map[string]interface{}{
|
||||
"price": req.Price,
|
||||
"discounted_price": req.DiscountedPrice,
|
||||
"remark": req.Remark,
|
||||
}
|
||||
err := global.DB.Model(&radio.Vip{}).Where("id = ?", req.Id).Updates(updateData).Error
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *VipService) VipConfigDetail() (radio.Vip, error) {
|
||||
var vip radio.Vip
|
||||
err := global.DB.Model(&radio.Vip{}).First(&vip).Error
|
||||
return vip, err
|
||||
}
|
||||
|
||||
// VipVip 开通vip
|
||||
func (s *VipService) VipVip(userId string) (*jsapi.PrepayWithRequestPaymentResponse, string, error) {
|
||||
//1.查询vip配置
|
||||
var config radio.Vip
|
||||
err := global.DB.Model(&radio.Vip{}).First(&config).Error
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
//2.创建订单
|
||||
order := radio.Order{
|
||||
Type: 2, //很重要 1订阅 2开通vip
|
||||
UserId: userId,
|
||||
OutTradeNo: uniqueid.GenOrderNo(),
|
||||
SubscriptionType: "vip sub",
|
||||
Amount: config.DiscountedPrice,
|
||||
Name: "vip sub",
|
||||
}
|
||||
err = global.DB.Create(&order).Error
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
//3.调用微信api 拉起支付
|
||||
var user system.User
|
||||
err = global.DB.Where("id = ?", userId).First(&user).Error
|
||||
if err != nil || user.OpenId == "" {
|
||||
return nil, "", err
|
||||
}
|
||||
payClient, err := wechat.GetWxPayClient()
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
svc := jsapi.JsapiApiService{Client: payClient}
|
||||
result, _, err := svc.PrepayWithRequestPayment(context.Background(),
|
||||
jsapi.PrepayRequest{
|
||||
Appid: core.String(global.Config.MiniProgram.AppId),
|
||||
Mchid: core.String(global.Config.WechatPay.MchId),
|
||||
Description: core.String(order.Name),
|
||||
OutTradeNo: core.String(order.OutTradeNo),
|
||||
//TimeExpire: core.Time(time.Now()), //选填
|
||||
//Attach: core.String("自定义数据说明"), //选填
|
||||
NotifyUrl: core.String(global.Config.WechatPay.NotifyUrl),
|
||||
//GoodsTag: core.String("WXG"), //选填
|
||||
//SupportFapiao: core.Bool(false), //选填
|
||||
Amount: &jsapi.Amount{
|
||||
Currency: core.String("CNY"),
|
||||
Total: core.Int64(int64(config.DiscountedPrice)),
|
||||
},
|
||||
Payer: &jsapi.Payer{
|
||||
Openid: core.String(user.OpenId),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
return result, order.OutTradeNo, nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package radio
|
||||
|
||||
import (
|
||||
"sundynix-go/global"
|
||||
"sundynix-go/model/radio"
|
||||
radioReq "sundynix-go/model/radio/request"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type VoiceService struct{}
|
||||
|
||||
// GetVoiceList 获取音色列表
|
||||
func (s *VoiceService) GetVoiceList(req radioReq.GetVoiceList) ([]radio.RadioVoice, int64, error) {
|
||||
db := global.DB.Model(&radio.RadioVoice{}).Preload("Audio")
|
||||
var list []radio.RadioVoice
|
||||
var total int64
|
||||
|
||||
if req.Name != "" {
|
||||
db = db.Where("name LIKE ?", "%"+req.Name+"%")
|
||||
}
|
||||
if req.Status > 0 {
|
||||
db = db.Where("status = ?", req.Status)
|
||||
}
|
||||
|
||||
err := db.Count(&total).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (req.Current - 1) * req.PageSize
|
||||
err = db.Offset(offset).Limit(req.PageSize).Order("sort ASC").Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, total, nil
|
||||
}
|
||||
|
||||
// GetVoiceById 获取音色详情
|
||||
func (s *VoiceService) GetVoiceById(id string) (*radio.RadioVoice, error) {
|
||||
var voice radio.RadioVoice
|
||||
err := global.DB.Preload("Audio").Where("id = ?", id).First(&voice).Error
|
||||
return &voice, err
|
||||
}
|
||||
|
||||
// GetVoiceBySpeakerId 根据SpeakerId获取音色
|
||||
func (s *VoiceService) GetVoiceBySpeakerId(speakerId string) (*radio.RadioVoice, error) {
|
||||
var voice radio.RadioVoice
|
||||
err := global.DB.Where("speaker_id = ? AND status = ?", speakerId, 1).First(&voice).Error
|
||||
return &voice, err
|
||||
}
|
||||
|
||||
// GetDefaultVoice 获取默认音色
|
||||
func (s *VoiceService) GetDefaultVoice() (*radio.RadioVoice, error) {
|
||||
var voice radio.RadioVoice
|
||||
err := global.DB.Where("is_default = ? AND status = ?", 1, 1).First(&voice).Error
|
||||
if err != nil {
|
||||
// 如果没有默认音色,返回第一个启用的音色
|
||||
err = global.DB.Where("status = ?", 1).Order("sort ASC").First(&voice).Error
|
||||
}
|
||||
return &voice, err
|
||||
}
|
||||
|
||||
// GetAllEnabledVoice 获取所有启用的音色(前端选择用)
|
||||
func (s *VoiceService) GetAllEnabledVoice() ([]radio.RadioVoice, error) {
|
||||
var list []radio.RadioVoice
|
||||
err := global.DB.Where("status = ?", 1).Order("sort ASC").Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// SaveVoice 保存音色
|
||||
func (s *VoiceService) SaveVoice(req radioReq.SaveVoice) error {
|
||||
return global.DB.Transaction(func(tx *gorm.DB) error {
|
||||
|
||||
// 如果设置为默认音色,先取消其他默认
|
||||
if req.IsDefault == 1 {
|
||||
if err := tx.Model(&radio.RadioVoice{}).Where("is_default = ?", 1).Update("is_default", 0).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
voice := radio.RadioVoice{
|
||||
SpeakerId: req.SpeakerId,
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
Gender: req.Gender,
|
||||
Icon: req.Icon,
|
||||
AudioId: req.AudioId,
|
||||
Sort: req.Sort,
|
||||
Status: req.Status,
|
||||
IsDefault: req.IsDefault,
|
||||
}
|
||||
return tx.Create(&voice).Error
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateVoice 更新音色
|
||||
func (s *VoiceService) UpdateVoice(req radioReq.UpdateVoice) error {
|
||||
return global.DB.Transaction(func(tx *gorm.DB) error {
|
||||
updates := map[string]interface{}{
|
||||
"speaker_id": req.SpeakerId,
|
||||
"name": req.Name,
|
||||
"description": req.Description,
|
||||
"gender": req.Gender,
|
||||
"icon": req.Icon,
|
||||
"audio_id": req.AudioId,
|
||||
"sort": req.Sort,
|
||||
"status": req.Status,
|
||||
}
|
||||
|
||||
// 如果设置为默认音色,先取消其他默认
|
||||
if req.IsDefault == 1 {
|
||||
if err := tx.Model(&radio.RadioVoice{}).Where("is_default = ? AND id != ?", 1, req.Id).Update("is_default", 0).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
updates["is_default"] = 1
|
||||
}
|
||||
|
||||
return tx.Model(&radio.RadioVoice{}).Where("id = ?", req.Id).Updates(updates).Error
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteVoice 删除音色
|
||||
func (s *VoiceService) DeleteVoice(ids []string) error {
|
||||
return global.DB.Where("id IN ?", ids).Delete(&radio.RadioVoice{}).Error
|
||||
}
|
||||
|
||||
// IncrementUseCount 增加使用次数
|
||||
func (s *VoiceService) IncrementUseCount(speakerId string) error {
|
||||
return global.DB.Model(&radio.RadioVoice{}).Where("speaker_id = ?", speakerId).UpdateColumn("use_count", gorm.Expr("use_count + 1")).Error
|
||||
}
|
||||
|
||||
// SetDefaultVoice 设置默认音色
|
||||
func (s *VoiceService) SetDefaultVoice(id string) error {
|
||||
return global.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 先清除其他默认
|
||||
if err := tx.Model(&radio.RadioVoice{}).Where("is_default = ?", 1).Update("is_default", 0).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 设置当前为默认
|
||||
if err := tx.Model(&radio.RadioVoice{}).Where("id = ?", id).Update("is_default", 1).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
var VoiceServiceApp = new(VoiceService)
|
||||
+18
-17
@@ -12,7 +12,6 @@ import (
|
||||
"strconv"
|
||||
"sundynix-go/global"
|
||||
common "sundynix-go/model/commom/request"
|
||||
"sundynix-go/model/radio"
|
||||
"sundynix-go/model/system"
|
||||
systemReq "sundynix-go/model/system/request"
|
||||
systemResp "sundynix-go/model/system/response"
|
||||
@@ -21,6 +20,7 @@ import (
|
||||
location "sundynix-go/utils/location"
|
||||
"sundynix-go/utils/uniqueid"
|
||||
"sundynix-go/utils/wechat"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
@@ -73,6 +73,9 @@ func (userService *UserService) GetUserList(info systemReq.GetUserList) (list in
|
||||
db := global.DB.Model(&system.User{})
|
||||
var userList []system.User
|
||||
|
||||
if info.IsVip != nil {
|
||||
db = db.Where("is_vip = ?", *info.IsVip)
|
||||
}
|
||||
if info.Account != "" {
|
||||
db = db.Where("account LIKE ?", "%"+info.Account+"%")
|
||||
}
|
||||
@@ -83,7 +86,7 @@ func (userService *UserService) GetUserList(info systemReq.GetUserList) (list in
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
err = db.Limit(limit).Offset(offset).Find(&userList).Error
|
||||
err = db.Limit(limit).Offset(offset).Order("created_at desc").Find(&userList).Error
|
||||
return userList, total, err
|
||||
}
|
||||
|
||||
@@ -101,7 +104,7 @@ func (userService *UserService) ChangePassword(id string, pwd string) (err error
|
||||
return global.DB.Model(&system.User{}).Where("id = ?", id).Update("password", utils.BcryptHash(pwd)).Error
|
||||
}
|
||||
|
||||
func (userService *UserService) MiniLogin(code string) (result *system.User, err error) {
|
||||
func (userService *UserService) MiniLogin(code, ip string) (result *system.User, err error) {
|
||||
//构建参数
|
||||
params := url2.Values{}
|
||||
params.Set("appid", global.Config.MiniProgram.AppId)
|
||||
@@ -145,29 +148,22 @@ func (userService *UserService) MiniLogin(code string) (result *system.User, err
|
||||
|
||||
// 7. 根据openid查询用户 存在--> 更新session_key 返回数据
|
||||
var user system.User
|
||||
err = global.DB.Where("mini_open_id = ?", wxResp.Openid).Preload("Avatar").First(&user).Error
|
||||
now := time.Now()
|
||||
err = global.DB.Where("open_id = ?", wxResp.Openid).Preload("Avatar").First(&user).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 8. 使用 Transaction 闭包管理事务
|
||||
err = global.DB.Transaction(func(tx *gorm.DB) error {
|
||||
// 创建新用户
|
||||
newUser := system.User{
|
||||
Name: uniqueid.GenerateName(),
|
||||
MiniOpenId: wxResp.Openid,
|
||||
Name: uniqueid.GenerateRadioUsername(),
|
||||
OpenId: wxResp.Openid,
|
||||
SessionKey: wxResp.SessionKey,
|
||||
LastLoginIp: ip,
|
||||
LastLoginAt: &now,
|
||||
}
|
||||
if err := tx.Create(&newUser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 创建小程序用户
|
||||
mpUser := radio.RadioUser{
|
||||
UserId: newUser.Id,
|
||||
OpenId: wxResp.Openid,
|
||||
UnionId: wxResp.Unionid,
|
||||
SessionKey: wxResp.SessionKey,
|
||||
}
|
||||
if err := tx.Create(&mpUser).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 赋值给外部变量以便返回
|
||||
user = newUser
|
||||
@@ -181,7 +177,12 @@ func (userService *UserService) MiniLogin(code string) (result *system.User, err
|
||||
}
|
||||
if err == nil && user.Id != "" {
|
||||
// UpdateColumn:只更新字段,不触发模型钩子,比Update更高效
|
||||
if err = global.DB.Model(&user).UpdateColumn("session_key", wxResp.SessionKey).Error; err != nil {
|
||||
updateData := map[string]interface{}{
|
||||
"session_key": wxResp.SessionKey,
|
||||
"last_login_ip": ip,
|
||||
"last_login_at": &now,
|
||||
}
|
||||
if err = global.DB.Model(&user).Updates(updateData).Error; err != nil {
|
||||
global.Logger.Error("更新session_key失败", zap.Error(err))
|
||||
return nil, fmt.Errorf("更新session_key失败: %w", err)
|
||||
}
|
||||
|
||||
@@ -109,3 +109,8 @@ func TestGetZeroTime(t *testing.T) {
|
||||
fmt.Printf("当天零点: %v\n", zeroTime)
|
||||
fmt.Printf("时间戳(秒): %v\n", zeroTime.Unix())
|
||||
}
|
||||
|
||||
func TestGenName(t *testing.T) {
|
||||
username := uniqueid.GenerateRadioUsername()
|
||||
fmt.Println(username)
|
||||
}
|
||||
|
||||
@@ -54,3 +54,25 @@ func GetMaxTime() time.Time {
|
||||
maxTime := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 999999999, time.Local)
|
||||
return maxTime
|
||||
}
|
||||
|
||||
// ParseDateRange 解析日期范围,未传则默认最近30天
|
||||
func ParseDateRange(startDate, endDate string) (time.Time, time.Time) {
|
||||
now := time.Now()
|
||||
layout := "2006-01-02"
|
||||
|
||||
end, err := time.Parse(layout, endDate)
|
||||
if err != nil {
|
||||
end = now
|
||||
}
|
||||
// 结束日期取当天 23:59:59
|
||||
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 0, time.Local)
|
||||
|
||||
start, err := time.Parse(layout, startDate)
|
||||
if err != nil {
|
||||
start = end.AddDate(0, 0, -29) // 默认30天
|
||||
}
|
||||
// 开始日期取当天 00:00:00
|
||||
start = time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, time.Local)
|
||||
|
||||
return start, end
|
||||
}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
package uniqueid
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strings"
|
||||
"math/rand"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
@@ -17,25 +16,30 @@ func GenerateId() string {
|
||||
return uuidV1.String()
|
||||
}
|
||||
|
||||
func GenerateName() string {
|
||||
str := uuid.New().String()
|
||||
//生成一个用户名 比如花友u278bb 中文后的字符随机生成 不可重复 取str的前六位
|
||||
return "花友" + str[6:12]
|
||||
// GenerateRadioUsername 生成具有电台氛围的用户名称
|
||||
func GenerateRadioUsername() string {
|
||||
// 1. 文艺词库
|
||||
adjectives := []string{"虚构", "私奔", "落日", "低空", "巡航", "无声", "迷失", "告白", "极光", "霓虹"}
|
||||
nouns := []string{"调频", "电波", "磁带", "频率", "回声", "岛屿", "信箱", "航站", "独白", "碎片"}
|
||||
|
||||
// 2. 初始化随机种子 (使用纳秒级时间戳)
|
||||
r := rand.New(rand.NewSource(time.Now().UnixNano()))
|
||||
|
||||
// 3. 随机抽取词库
|
||||
adj := adjectives[r.Intn(len(adjectives))]
|
||||
noun := nouns[r.Intn(len(nouns))]
|
||||
|
||||
// 4. 获取当前时间的微秒/纳秒部分作为“身份码”
|
||||
// 取纳秒的最后5位,既能体现随机性,又不会像日期那样冗长
|
||||
timeSuffix := time.Now().UnixNano() % 100000
|
||||
|
||||
// 5. 混合生成:采用不同的模板增加随机感
|
||||
templates := []string{
|
||||
"%s%s_%05d", // 如:落日电波_12345
|
||||
"Hz.%d-%s%s", // 如:Hz.67890-虚构独白
|
||||
"%s%s-%d-FM", // 如:迷失频率-54321-FM
|
||||
}
|
||||
|
||||
// GenerateRandomCode 生成邀请码
|
||||
func GenerateRandomCode(length int) string {
|
||||
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
var sb strings.Builder
|
||||
for i := 0; i < length; i++ {
|
||||
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(charset))))
|
||||
sb.WriteByte(charset[n.Int64()])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// GenCodeKey 生成邀请码的key
|
||||
func GenCodeKey(userId string) string {
|
||||
return fmt.Sprintf("code:%s", userId)
|
||||
selectedTemplate := templates[r.Intn(len(templates))]
|
||||
return fmt.Sprintf(selectedTemplate, adj, noun, timeSuffix)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"path/filepath"
|
||||
@@ -104,3 +105,19 @@ func (m *Minio) DeleteFile(key string) error {
|
||||
err := m.Client.RemoveObject(ctx, m.bucket, key, minio.RemoveObjectOptions{})
|
||||
return err
|
||||
}
|
||||
|
||||
// UploadBytes 上传字节数据
|
||||
func (m *Minio) UploadBytes(data []byte, key, contentType string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute*10)
|
||||
defer cancel()
|
||||
|
||||
buffer := bytes.NewReader(data)
|
||||
info, err := m.Client.PutObject(ctx, m.bucket, key, buffer, int64(len(data)), minio.PutObjectOptions{
|
||||
ContentType: contentType,
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("上传文件到minio失败: %v", err)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s", global.Config.Minio.BucketUrl, info.Key), nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user