From 79a5c1f3edc6e5c870d818866795fa2b9e427bed Mon Sep 17 00:00:00 2001 From: shenaowei <450702724@qq.com> Date: Wed, 25 Feb 2026 21:30:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=85=8D=E9=A2=9D=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 79 --- frontend/app/web-gold/src/api/modelConfig.js | 61 +++ .../app/web-gold/src/components/PointsTag.vue | 100 ++++ .../src/components/agents/ChatDrawer.vue | 19 +- .../app/web-gold/src/composables/useUpload.js | 18 + .../app/web-gold/src/stores/pointsConfig.js | 162 +++++++ .../components/BenchmarkForm.vue | 18 +- frontend/app/web-gold/src/views/dh/Video.vue | 21 +- .../src/views/material/MaterialListNew.vue | 69 ++- .../web-gold/src/views/trends/Forecast.vue | 15 +- openspec/plans/points-oss-quota-system.md | 455 ++++++++++++++++++ .../file/controller/AppTikTestController.java | 61 +-- .../tik/file/dal/mysql/TikUserFileMapper.java | 10 + .../file/service/TikUserFileServiceImpl.java | 12 +- .../mq/consumer/MemberUserCreateConsumer.java | 11 +- .../AiModelConfigController.java | 7 + .../AppAiModelConfigController.java | 36 ++ .../mapper/AiModelConfigMapper.java | 9 + .../service/AiModelConfigService.java | 18 + .../service/AiModelConfigServiceImpl.java | 14 + .../job/OssQuotaReconcileJob.java | 113 +++++ .../mapper/MemberUserProfileMapper.java | 26 + .../service/MemberUserProfileService.java | 29 ++ .../service/MemberUserProfileServiceImpl.java | 42 ++ .../quota/dal/dataobject/TikUserQuotaDO.java | 55 --- .../quota/dal/mysql/TikUserQuotaMapper.java | 20 - .../quota/service/TikUserQuotaService.java | 71 --- .../service/TikUserQuotaServiceImpl.java | 120 ----- .../quota/vo/app/AppTikUserQuotaRespVO.java | 43 -- 29 files changed, 1225 insertions(+), 489 deletions(-) create mode 100644 frontend/app/web-gold/src/api/modelConfig.js create mode 100644 frontend/app/web-gold/src/components/PointsTag.vue create mode 100644 frontend/app/web-gold/src/stores/pointsConfig.js create mode 100644 openspec/plans/points-oss-quota-system.md create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/AppAiModelConfigController.java create mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/job/OssQuotaReconcileJob.java delete mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/dal/dataobject/TikUserQuotaDO.java delete mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/dal/mysql/TikUserQuotaMapper.java delete mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaService.java delete mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaServiceImpl.java delete mode 100644 yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/vo/app/AppTikUserQuotaRespVO.java diff --git a/AGENTS.md b/AGENTS.md index 9c2767c925..320edb055a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,82 +17,3 @@ Keep this managed block so 'openspec update' can refresh the instructions. - - -## Available Skills - - - -When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. - -How to use skills: -- Invoke: Bash("openskills read ") -- The skill content will load with detailed instructions on how to complete the task -- Base directory provided in output for resolving bundled resources (references/, scripts/, assets/) - -Usage notes: -- Only use skills listed in below -- Do not invoke a skill that is already loaded in your context -- Each skill invocation is stateless - - - - - -algorithmic-art -Creating algorithmic art using p5.js with seeded randomness and interactive parameter exploration. Use this when users request creating art using code, generative art, algorithmic art, flow fields, or particle systems. Create original algorithmic art rather than copying existing artists' work to avoid copyright violations. -project - - - -brand-guidelines -Applies Anthropic's official brand colors and typography to any sort of artifact that may benefit from having Anthropic's look-and-feel. Use it when brand colors or style guidelines, visual formatting, or company design standards apply. -project - - - -docx -"Comprehensive document creation, editing, and analysis with support for tracked changes, comments, formatting preservation, and text extraction. When Claude needs to work with professional documents (.docx files) for: (1) Creating new documents, (2) Modifying or editing content, (3) Working with tracked changes, (4) Adding comments, or any other document tasks" -project - - - -frontend-design -Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics. -project - - - -mcp-builder -Guide for creating high-quality MCP (Model Context Protocol) servers that enable LLMs to interact with external services through well-designed tools. Use when building MCP servers to integrate external APIs or services, whether in Python (FastMCP) or Node/TypeScript (MCP SDK). -project - - - -skill-creator -Guide for creating effective skills. This skill should be used when users want to create a new skill (or update an existing skill) that extends Claude's capabilities with specialized knowledge, workflows, or tool integrations. -project - - - -slack-gif-creator -Knowledge and utilities for creating animated GIFs optimized for Slack. Provides constraints, validation tools, and animation concepts. Use when users request animated GIFs for Slack like "make me a GIF of X doing Y for Slack." -project - - - -theme-factory -Toolkit for styling artifacts with a theme. These artifacts can be slides, docs, reportings, HTML landing pages, etc. There are 10 pre-set themes with colors/fonts that you can apply to any artifact that has been creating, or can generate a new theme on-the-fly. -project - - - -xlsx -"Comprehensive spreadsheet creation, editing, and analysis with support for formulas, formatting, data analysis, and visualization. When Claude needs to work with spreadsheets (.xlsx, .xlsm, .csv, .tsv, etc) for: (1) Creating new spreadsheets with formulas and formatting, (2) Reading or analyzing data, (3) Modify existing spreadsheets while preserving formulas, (4) Data analysis and visualization in spreadsheets, or (5) Recalculating formulas" -project - - - - - - diff --git a/frontend/app/web-gold/src/api/modelConfig.js b/frontend/app/web-gold/src/api/modelConfig.js new file mode 100644 index 0000000000..fa33b121ad --- /dev/null +++ b/frontend/app/web-gold/src/api/modelConfig.js @@ -0,0 +1,61 @@ +/** + * AI模型配置 API 服务 + * 用于获取模型积分消耗配置 + */ + +import http from './http' + +const BASE_URL = '/webApi/api/tik/ai-model-config' + +/** + * 模型配置 API 服务 + */ +export const ModelConfigService = { + /** + * 获取所有启用的模型配置列表(按平台分组) + * @returns {Promise} 按平台分组的模型配置 + * 格式: { platform: [{ modelCode, modelName, consumePoints }] } + */ + async getEnabledModelConfigList() { + const { data } = await http.get(`${BASE_URL}/list-enabled`) + return data || {} + }, + + /** + * 根据模型代码获取积分消耗 + * @param {Object} configMap - 配置映射(从 getEnabledModelConfigList 获取) + * @param {string} modelCode - 模型代码 + * @returns {number|null} 积分消耗,未找到返回 null + */ + getConsumePoints(configMap, modelCode) { + if (!configMap || !modelCode) return null + + for (const platform of Object.values(configMap)) { + const model = platform?.find(m => m.modelCode === modelCode) + if (model) { + return model.consumePoints + } + } + return null + }, + + /** + * 根据模型代码获取模型名称 + * @param {Object} configMap - 配置映射 + * @param {string} modelCode - 模型代码 + * @returns {string|null} 模型名称,未找到返回 null + */ + getModelName(configMap, modelCode) { + if (!configMap || !modelCode) return null + + for (const platform of Object.values(configMap)) { + const model = platform?.find(m => m.modelCode === modelCode) + if (model) { + return model.modelName + } + } + return null + } +} + +export default ModelConfigService diff --git a/frontend/app/web-gold/src/components/PointsTag.vue b/frontend/app/web-gold/src/components/PointsTag.vue new file mode 100644 index 0000000000..94d5d6731e --- /dev/null +++ b/frontend/app/web-gold/src/components/PointsTag.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/frontend/app/web-gold/src/components/agents/ChatDrawer.vue b/frontend/app/web-gold/src/components/agents/ChatDrawer.vue index 30db2da038..8d9b6ceef9 100644 --- a/frontend/app/web-gold/src/components/agents/ChatDrawer.vue +++ b/frontend/app/web-gold/src/components/agents/ChatDrawer.vue @@ -1,7 +1,7 @@ +

生成爆款文案将消耗积分

@@ -848,10 +852,6 @@ onMounted(() => { to { transform: rotate(360deg); } } -@keyframes spin { - to { transform: rotate(360deg); } -} - // 热点卡片 .topic-list { display: flex; @@ -1205,6 +1205,13 @@ onMounted(() => { } } +.points-hint { + text-align: center; + font-size: 12px; + color: #94a3b8; + margin: 8px 0 0; +} + // 结果区域 .result-block { padding-top: 16px; diff --git a/openspec/plans/points-oss-quota-system.md b/openspec/plans/points-oss-quota-system.md new file mode 100644 index 0000000000..1d8aeb661f --- /dev/null +++ b/openspec/plans/points-oss-quota-system.md @@ -0,0 +1,455 @@ +# 积分系统与OSS额度系统实施计划 + +> 创建日期:2026-02-25 +> 状态:待审批 + +--- + +## 一、需求概述 + +### 1.1 业务需求 + +| 模块 | 需求描述 | +|------|----------| +| **前端积分系统** | 前端启动时加载 `muye_ai_model_config`,根据 model_code 获取积分消耗,显示在对标分析、热点趋势、智能体、数字人模块 | +| **OSS额度系统** | 后端统一用字节计算,仅统计已上传未删除文件,上传前校验,删除后释放,每日凌晨自动对账 | +| **OSS存储管理** | 素材列表和个人中心显示用户正确的额度 | + +### 1.2 验收标准 + +1. 前端各业务模块正确显示积分消耗数值 +2. OSS 上传前校验额度,超额拒绝上传 +3. OSS 文件删除后实时释放额度 +4. 每日凌晨自动对账,确保数据库记录与实际一致 + +--- + +## 二、现有架构分析 + +### 2.1 已有基础设施 + +| 组件 | 位置 | 状态 | +|------|------|------| +| **积分服务** | `PointsService` | ✅ 已实现预检、扣减、预扣逻辑 | +| **AI模型配置** | `muye_ai_model_config` 表 | ✅ 已有 `consume_points` 字段 | +| **用户档案服务** | `MemberUserProfileService` | ⚠️ 需增强存储校验和更新 | +| **用户档案表** | `muye_member_user_profile` | ✅ 已有存储和积分字段(GB单位) | +| **用户Store** | `stores/user.js` | ✅ 已有积分和存储计算属性 | +| **个人中心** | `views/user/Profile.vue` | ✅ 已显示基本额度信息 | + +### 2.2 需要新增的组件 + +| 组件 | 类型 | 说明 | +|------|------|------| +| `ModelConfigApi` | 前端API | 获取模型配置列表 | +| `PointsConfigStore` | 前端Store | 管理积分配置状态 | +| `PointsTag` | 前端组件 | 积分消耗标签组件 | +| `OssQuotaReconcileJob` | 后端Job | 每日对账任务 | +| `StorageQuotaMixin` | 前端Composable | 存储额度显示逻辑复用 | + +--- + +## 三、详细设计 + +### 3.1 前端积分系统 + +#### 3.1.1 数据流 + +``` +前端启动 → 调用 API 获取模型配置 → 存入 Store → 各模块从 Store 读取显示 +``` + +#### 3.1.2 新增文件 + +``` +frontend/app/web-gold/src/ +├── api/ +│ └── modelConfig.js # 模型配置 API +├── stores/ +│ └── pointsConfig.js # 积分配置 Store +└── components/ + └── common/ + └── PointsTag.vue # 积分消耗标签组件 +``` + +#### 3.1.3 API 设计 + +```javascript +// api/modelConfig.js +export function getModelConfigList() { + return request({ + url: `${BASE_URL}/aimodelconfig/list-enabled`, + method: 'get' + }) +} + +// 返回格式 +{ + "dify": { + "agent_chat_pro": { "consumePoints": 10, "modelName": "深度版" }, + "agent_chat_standard": { "consumePoints": 5, "modelName": "标准版" } + }, + "digital_human": { + "latentsync": { "consumePoints": 50, "modelName": "数字人" } + }, + // ... +} +``` + +#### 3.1.4 Store 设计 + +```javascript +// stores/pointsConfig.js +export const usePointsConfigStore = defineStore('pointsConfig', () => { + const configMap = ref({}) // 按平台+modelCode组织的配置 + const isLoaded = ref(false) + + // 根据 platform 和 modelCode 获取积分消耗 + const getConsumePoints = (platform, modelCode) => { + return configMap.value[platform]?.[modelCode]?.consumePoints ?? 0 + } + + // 初始化加载配置 + const loadConfig = async () => { + if (isLoaded.value) return + const data = await getModelConfigList() + configMap.value = data + isLoaded.value = true + } + + return { configMap, isLoaded, getConsumePoints, loadConfig } +}) +``` + +#### 3.1.5 积分标签组件 + +```vue + + + + +``` + +#### 3.1.6 各模块集成点 + +| 模块 | 文件位置 | 集成方式 | +|------|----------|----------| +| **智能体** | `ChatDrawer.vue` | 在发送按钮旁显示积分消耗 | +| **数字人** | `Video.vue` | 在生成按钮旁显示积分消耗 | +| **对标分析** | `Benchmark.vue` | 在分析按钮旁显示积分消耗 | +| **热点趋势** | `Forecast.vue` | 在文案生成按钮旁显示积分消耗 | + +--- + +### 3.2 OSS 额度系统 + +#### 3.2.1 数据模型 + +**MemberUserProfileDO 字段(muye_member_user_profile 表):** +```java +private String userId; // 用户ID +private BigDecimal totalStorage; // 云空间总容量 (GB) +private BigDecimal usedStorage; // 云空间已用容量 (GB) +private BigDecimal remainingStorage;// 云空间剩余容量 (GB) +private Integer totalPoints; // 账户总积分 +private Integer usedPoints; // 账户消耗积分 +private Integer remainingPoints; // 账户剩余积分 +``` + +> **设计决策**:保持 GB 单位存储(兼容现有数据),后端逻辑统一用字节计算 + +**存储文件记录表(muye_material_file):** +```sql +-- 文件大小字段(字节) +file_size BIGINT NOT NULL COMMENT '文件大小(字节)' +``` + +**单位转换常量:** +```java +// 1 GB = 1024 * 1024 * 1024 字节 +public static final long BYTES_PER_GB = 1073741824L; +``` + +#### 3.2.2 后端增强设计 + +**修改/新增文件:** + +``` +yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/ +├── muye/ +│ └── memberuserprofile/ +│ ├── service/ +│ │ ├── MemberUserProfileService.java # 增强:添加存储额度方法 +│ │ └── MemberUserProfileServiceImpl.java +│ ├── mapper/ +│ │ └── MemberUserProfileMapper.java # 增强:添加原子更新方法 +│ └── job/ +│ └── OssQuotaReconcileJob.java # 新增:每日对账Job +``` + +#### 3.2.3 额度校验流程 + +``` +用户上传文件 + ↓ +检查文件大小(字节) + ↓ +调用 MemberUserProfileService.validateStorage(userId, fileSizeBytes) + ↓ + 内部逻辑:将 fileSizeBytes 转换为 GB,与 remainingStorage 比较 + ↓ +├── 余额充足 → 允许上传 +│ ↓ +│ 文件上传成功 → 调用 increaseUsedStorage(userId, fileSizeBytes) +│ +└── 余额不足 → 抛出异常,拒绝上传 +``` + +#### 3.2.4 额度释放流程 + +``` +用户删除文件 + ↓ +获取文件大小 fileSizeBytes(字节) + ↓ +调用 MemberUserProfileService.decreaseUsedStorage(userId, fileSizeBytes) + ↓ +内部逻辑:字节转GB,原子更新 usedStorage/remainingStorage +``` + +#### 3.2.5 MemberUserProfileService 增强 + +```java +// 新增方法 +/** + * 校验存储空间是否足够 + * @param userId 用户ID + * @param fileSizeBytes 文件大小(字节) + */ +void validateStorage(String userId, long fileSizeBytes); + +/** + * 增加已使用存储空间 + * @param userId 用户ID + * @param fileSizeBytes 文件大小(字节) + */ +void increaseUsedStorage(String userId, long fileSizeBytes); + +/** + * 减少已使用存储空间 + * @param userId 用户ID + * @param fileSizeBytes 文件大小(字节) + */ +void decreaseUsedStorage(String userId, long fileSizeBytes); +``` + +#### 3.2.6 Mapper 原子更新 + +```java +// MemberUserProfileMapper.java 新增 + +/** + * 原子增加已用存储(乐观锁) + * @param userId 用户ID + * @param storageGb 增加的存储量(GB,BigDecimal转String) + * @return 影响行数,0表示余额不足 + */ +@Update("UPDATE muye_member_user_profile " + + "SET used_storage = used_storage + #{storageGb}, " + + " remaining_storage = remaining_storage - #{storageGb}, " + + " update_time = NOW() " + + "WHERE user_id = #{userId} AND remaining_storage >= #{storageGb}") +int updateStorageIncrease(@Param("userId") String userId, @Param("storageGb") String storageGb); + +/** + * 原子减少已用存储 + */ +@Update("UPDATE muye_member_user_profile " + + "SET used_storage = used_storage - #{storageGb}, " + + " remaining_storage = remaining_storage + #{storageGb}, " + + " update_time = NOW() " + + "WHERE user_id = #{userId} AND used_storage >= #{storageGb}") +int updateStorageDecrease(@Param("userId") String userId, @Param("storageGb") String storageGb); +``` + +#### 3.2.7 每日对账Job + +```java +@Component +public class OssQuotaReconcileJob { + + @Scheduled(cron = "0 0 3 * * ?") // 每日凌晨3点 + public void reconcile() { + // 1. 查询所有用户的档案 + // 2. 统计每个用户 muye_material_file 表中文件总大小(字节) + // 3. 转换为 GB,与 usedStorage 对比 + // 4. 不一致则修正并记录日志 + } +} +``` + +#### 3.2.8 API 设计 + +使用现有的 `/webApi/api/tik/member-profile/get` 接口,返回数据已包含存储额度信息。 +``` + +--- + +### 3.3 OSS 存储管理 + +#### 3.3.1 素材列表集成 + +在 `MaterialListNew.vue` 顶部工具栏显示存储额度: + +```vue +
+ + {{ formatStorage(usedStorage) }} / {{ formatStorage(totalStorage) }} + +
+``` + +#### 3.3.2 个人中心优化 + +`Profile.vue` 已有存储空间显示,需要确保数据来源正确: +- 从 `userStore.remainingStorage` 读取 +- 确保 `getUserProfile()` API 返回正确的字节数据 + +--- + +## 四、任务分解 + +### Phase 1: 后端基础(优先级:高) + +| # | 任务 | 文件 | 说明 | +|---|------|------|------| +| 1.1 | 新增模型配置列表API | `AppAiModelConfigController.java` | 添加 `/list-enabled` 接口 | +| 1.2 | 增强用户档案服务 | `MemberUserProfileServiceImpl.java` | 添加存储校验和更新方法 | +| 1.3 | 新增Mapper原子更新 | `MemberUserProfileMapper.java` | 添加存储增减的原子操作 | +| 1.4 | 新增每日对账Job | `OssQuotaReconcileJob.java` | 实现自动对账 | + +### Phase 2: 前端积分系统(优先级:高) + +| # | 任务 | 文件 | 说明 | +|---|------|------|------| +| 2.1 | 创建模型配置API | `api/modelConfig.js` | 封装配置获取接口 | +| 2.2 | 创建积分配置Store | `stores/pointsConfig.js` | 管理配置状态 | +| 2.3 | 创建积分标签组件 | `components/common/PointsTag.vue` | 可复用的积分显示组件 | +| 2.4 | 应用启动时加载配置 | `App.vue` 或入口文件 | 初始化积分配置 | + +### Phase 3: 业务模块集成(优先级:中) + +| # | 任务 | 文件 | 说明 | +|---|------|------|------| +| 3.1 | 智能体模块集成 | `ChatDrawer.vue` | 显示对话积分消耗 | +| 3.2 | 数字人模块集成 | `dh/Video.vue` | 显示生成积分消耗 | +| 3.3 | 对标分析集成 | `Benchmark.vue` | 显示分析积分消耗 | +| 3.4 | 热点趋势集成 | `Forecast.vue` | 显示文案生成积分消耗 | + +### Phase 4: OSS存储管理(优先级:中) + +| # | 任务 | 文件 | 说明 | +|---|------|------|------| +| 4.1 | 素材列表显示额度 | `MaterialListNew.vue` | 工具栏显示存储额度 | +| 4.2 | 上传前额度校验 | `useUpload.js` | 上传前检查额度 | +| 4.3 | 删除后释放额度 | 后端文件删除接口 | 调用 decreaseUsedStorage | +| 4.4 | 个人中心优化 | `Profile.vue` | 确保显示正确字节值 | + +--- + +## 五、风险分析 + +| 风险 | 影响 | 对策 | +|------|------|------| +| 积分配置加载失败 | 前端显示0积分 | 添加默认值和重试机制 | +| 并发上传导致额度超限 | 超出配额 | 使用数据库乐观锁或原子操作 | +| 对账Job执行时间过长 | 影响系统性能 | 分批处理,添加超时控制 | +| 历史数据不一致 | 对账修正幅度大 | 先做数据盘点,再逐步修正 | + +--- + +## 六、测试计划 + +### 6.1 单元测试 + +- [ ] `PointsConfigStore` 状态管理测试 +- [ ] `TikUserQuotaService` 额度计算测试 +- [ ] 对账Job逻辑测试 + +### 6.2 集成测试 + +- [ ] 前端积分显示正确性 +- [ ] OSS上传额度校验 +- [ ] OSS删除额度释放 +- [ ] 每日对账执行 + +### 6.3 验收测试 + +``` +Benchark Checklist: +1. ✅ 智能体对话页面显示"消耗 X 积分" +2. ✅ 数字人生成页面显示"消耗 X 积分" +3. ✅ 对标分析页面显示"消耗 X 积分" +4. ✅ 热点趋势页面显示"消耗 X 积分" +5. ✅ 素材列表显示存储额度进度条 +6. ✅ 上传大文件超出额度时提示错误 +7. ✅ 删除文件后额度正确释放 +8. ✅ 个人中心显示正确的存储额度 +``` + +--- + +## 七、实施顺序建议 + +``` +Week 1: Phase 1 (后端基础) + Phase 2 (前端积分系统) +Week 2: Phase 3 (业务模块集成) + Phase 4 (OSS存储管理) +``` + +--- + +## 八、相关文件清单 + +### 后端文件 + +| 操作 | 文件路径 | +|------|----------| +| 修改 | `yudao-module-tik/.../aimodelconfig/AiModelConfigController.java` | +| 修改 | `yudao-module-tik/.../memberuserprofile/service/MemberUserProfileService.java` | +| 修改 | `yudao-module-tik/.../memberuserprofile/service/MemberUserProfileServiceImpl.java` | +| 修改 | `yudao-module-tik/.../memberuserprofile/mapper/MemberUserProfileMapper.java` | +| 新增 | `yudao-module-tik/.../memberuserprofile/job/OssQuotaReconcileJob.java` | + +### 前端文件 + +| 操作 | 文件路径 | +|------|----------| +| 新增 | `frontend/app/web-gold/src/api/modelConfig.js` | +| 新增 | `frontend/app/web-gold/src/stores/pointsConfig.js` | +| 新增 | `frontend/app/web-gold/src/components/common/PointsTag.vue` | +| 修改 | `frontend/app/web-gold/src/views/agents/ChatDrawer.vue` | +| 修改 | `frontend/app/web-gold/src/views/dh/Video.vue` | +| 修改 | `frontend/app/web-gold/src/views/content-style/Benchmark.vue` | +| 修改 | `frontend/app/web-gold/src/views/trends/Forecast.vue` | +| 修改 | `frontend/app/web-gold/src/views/material/MaterialListNew.vue` | +| 修改 | `frontend/app/web-gold/src/composables/useUpload.js` | diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikTestController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikTestController.java index bfb276dcb1..f3501337fa 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikTestController.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/controller/AppTikTestController.java @@ -4,9 +4,7 @@ import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils; import cn.iocoder.yudao.module.tik.file.service.TikOssInitService; import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserOssInitRespVO; -import cn.iocoder.yudao.module.tik.quota.service.TikUserQuotaService; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; @@ -17,7 +15,7 @@ import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; /** * 用户 App - 测试 Controller - * 用于测试升级会员和创建OSS目录 + * 用于测试创建OSS目录 * * @author 芋道源码 */ @@ -31,34 +29,6 @@ public class AppTikTestController { @Resource private TikOssInitService ossInitService; - @Resource - private TikUserQuotaService quotaService; - - @PostMapping("/upgrade-vip") - @Operation(summary = "测试:升级会员", description = "升级当前用户的VIP等级和配额") - @Parameter(name = "vipLevel", description = "VIP等级", example = "1") - @Parameter(name = "totalStorage", description = "总存储空间(字节)", example = "10737418240") - @Parameter(name = "totalQuota", description = "总配额(积分/额度)", example = "10000") - public CommonResult upgradeVip( - @RequestParam(value = "vipLevel", defaultValue = "1") Integer vipLevel, - @RequestParam(value = "totalStorage", required = false) Long totalStorage, - @RequestParam(value = "totalQuota", required = false) Long totalQuota) { - Long userId = SecurityFrameworkUtils.getLoginUserId(); - - // 默认值:10GB存储空间,10000配额 - if (totalStorage == null) { - totalStorage = 10L * 1024 * 1024 * 1024; // 10GB - } - if (totalQuota == null) { - totalQuota = 10000L; - } - - quotaService.updateVipLevel(userId, vipLevel, totalStorage, totalQuota); - log.info("[upgradeVip][用户({})升级VIP成功,等级({}),存储({}),配额({})]", - userId, vipLevel, totalStorage, totalQuota); - return success(true); - } - @PostMapping("/init-oss") @Operation(summary = "测试:初始化OSS目录", description = "为当前用户创建OSS目录结构") public CommonResult initOss() { @@ -68,34 +38,5 @@ public class AppTikTestController { return success(result); } - @PostMapping("/test-all") - @Operation(summary = "测试:一键执行", description = "同时执行升级会员和创建OSS目录") - @Parameter(name = "vipLevel", description = "VIP等级", example = "1") - @Parameter(name = "totalStorage", description = "总存储空间(字节)", example = "10737418240") - @Parameter(name = "totalQuota", description = "总配额(积分/额度)", example = "10000") - public CommonResult testAll( - @RequestParam(value = "vipLevel", defaultValue = "1") Integer vipLevel, - @RequestParam(value = "totalStorage", required = false) Long totalStorage, - @RequestParam(value = "totalQuota", required = false) Long totalQuota) { - Long userId = SecurityFrameworkUtils.getLoginUserId(); - - // 1. 升级会员 - if (totalStorage == null) { - totalStorage = 10L * 1024 * 1024 * 1024; // 10GB - } - if (totalQuota == null) { - totalQuota = 10000L; - } - quotaService.updateVipLevel(userId, vipLevel, totalStorage, totalQuota); - log.info("[testAll][用户({})升级VIP成功,等级({}),存储({}),配额({})]", - userId, vipLevel, totalStorage, totalQuota); - - // 2. 初始化OSS目录 - AppTikUserOssInitRespVO ossResult = ossInitService.initOssDirectory(userId); - log.info("[testAll][用户({})OSS目录初始化成功]", userId); - - return success(ossResult); - } - } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikUserFileMapper.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikUserFileMapper.java index af20f6df76..013a0bfc87 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikUserFileMapper.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/dal/mysql/TikUserFileMapper.java @@ -9,8 +9,10 @@ import cn.iocoder.yudao.module.tik.file.dal.dataobject.TikUserFileDO; import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserFilePageReqVO; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; import java.util.List; +import java.util.Map; /** * 用户文件 Mapper @@ -32,4 +34,12 @@ public interface TikUserFileMapper extends BaseMapperX { .orderByDesc(TikUserFileDO::getId)); } + /** + * 统计每个用户的实际存储使用量(字节) + * @return Map + */ + @Select("SELECT user_id, SUM(file_size) as total_size FROM tik_user_file " + + "WHERE deleted = 0 GROUP BY user_id") + List> selectStorageByUser(); + } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java index 45745f2252..e457f8938a 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/file/service/TikUserFileServiceImpl.java @@ -27,7 +27,7 @@ import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper; import cn.iocoder.yudao.module.tik.file.enums.TikFileCategoryEnum; import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserFilePageReqVO; import cn.iocoder.yudao.module.tik.file.vo.app.AppTikUserFileRespVO; -import cn.iocoder.yudao.module.tik.quota.service.TikUserQuotaService; +import cn.iocoder.yudao.module.tik.muye.memberuserprofile.service.MemberUserProfileService; import lombok.extern.slf4j.Slf4j; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; @@ -77,7 +77,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { private TikOssInitService ossInitService; @Resource - private TikUserQuotaService quotaService; + private MemberUserProfileService memberUserProfileService; @Resource private FileApi fileApi; @@ -137,7 +137,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { // 2. 校验配额 Long userId = SecurityFrameworkUtils.getLoginUserId(); if (fileSize != null && fileSize > 0) { - quotaService.validateStorage(userId, fileSize); + memberUserProfileService.validateStorage(String.valueOf(userId), fileSize); } } @@ -270,7 +270,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { userFileMapper.insert(userFile); // 10. 更新配额 - quotaService.increaseUsedStorage(userId, file.getSize()); + memberUserProfileService.increaseUsedStorage(String.valueOf(userId), file.getSize()); log.info("[saveFileRecord][用户({})保存文件记录成功,文件编号({}),infra文件编号({})]", userId, userFile.getId(), infraFileId); @@ -439,7 +439,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { // 释放配额 long totalSize = files.stream().mapToLong(TikUserFileDO::getFileSize).sum(); - quotaService.decreaseUsedStorage(userId, totalSize); + memberUserProfileService.decreaseUsedStorage(String.valueOf(userId), totalSize); log.info("[deleteFiles][用户({})删除文件成功,文件数量({})]", userId, fileIds.size()); } @@ -692,7 +692,7 @@ public class TikUserFileServiceImpl implements TikUserFileService { userFileMapper.insert(userFile); // 6. 更新配额 - quotaService.increaseUsedStorage(userId, fileSize); + memberUserProfileService.increaseUsedStorage(String.valueOf(userId), fileSize); log.info("[completeUpload][用户({})直传上传完成,infraFileId({}),userFileId({})]", userId, infraFileId, userFile.getId()); diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/member/mq/consumer/MemberUserCreateConsumer.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/member/mq/consumer/MemberUserCreateConsumer.java index b4217e51fa..44252eabf2 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/member/mq/consumer/MemberUserCreateConsumer.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/member/mq/consumer/MemberUserCreateConsumer.java @@ -2,7 +2,6 @@ package cn.iocoder.yudao.module.tik.member.mq.consumer; import cn.iocoder.yudao.module.member.api.message.user.MemberUserCreateMessage; import cn.iocoder.yudao.module.tik.muye.memberuserprofile.service.MemberUserProfileService; -import cn.iocoder.yudao.module.tik.quota.service.TikUserQuotaService; import lombok.extern.slf4j.Slf4j; import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; @@ -12,7 +11,7 @@ import jakarta.annotation.Resource; /** * 会员用户创建事件监听器 * - * 功能:用户注册后自动初始化配额和档案 + * 功能:用户注册后自动初始化档案和配额 * 触发时机:用户注册时(MemberUserServiceImpl.createUser()) * * @author 芋道源码 @@ -21,8 +20,6 @@ import jakarta.annotation.Resource; @Slf4j public class MemberUserCreateConsumer { - @Resource - private TikUserQuotaService quotaService; @Resource private MemberUserProfileService memberUserProfileService; @@ -38,11 +35,7 @@ public class MemberUserCreateConsumer { Long userId = message.getUserId(); - // 初始化用户配额(默认VIP等级0) - quotaService.initQuota(userId, 0); - log.info("[onMessage][用户({})配额初始化成功]", userId); - - // 初始化会员档案 + // 初始化会员档案(包含存储配额) memberUserProfileService.createIfAbsent(userId); log.info("[onMessage][用户({})档案初始化成功]", userId); } diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/AiModelConfigController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/AiModelConfigController.java index a2da0af8b8..f00244e78e 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/AiModelConfigController.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/AiModelConfigController.java @@ -101,4 +101,11 @@ public class AiModelConfigController { BeanUtils.toBean(list, AiModelConfigRespVO.class)); } + @GetMapping("/list-enabled") + @Operation(summary = "获取所有启用的模型配置列表(前端积分显示用)") + @PreAuthorize("@ss.hasPermission('muye:ai-model-config:query')") + public CommonResult>> getEnabledModelConfigList() { + return success(aiModelConfigService.getEnabledModelConfigList()); + } + } \ No newline at end of file diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/AppAiModelConfigController.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/AppAiModelConfigController.java new file mode 100644 index 0000000000..e73c06fa8b --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/AppAiModelConfigController.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.tik.muye.aimodelconfig; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.module.tik.muye.aimodelconfig.service.AiModelConfigService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.annotation.Resource; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; + +/** + * 用户 App - AI模型配置 + */ +@Tag(name = "用户 App - AI模型配置") +@RestController +@RequestMapping("/api/tik/ai-model-config") +@Validated +public class AppAiModelConfigController { + + @Resource + private AiModelConfigService aiModelConfigService; + + @GetMapping("/list-enabled") + @Operation(summary = "获取所有启用的模型配置列表(前端积分显示用)") + public CommonResult>> getEnabledModelConfigList() { + return success(aiModelConfigService.getEnabledModelConfigList()); + } + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/mapper/AiModelConfigMapper.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/mapper/AiModelConfigMapper.java index dff8cbc84c..3d6438f0ba 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/mapper/AiModelConfigMapper.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/mapper/AiModelConfigMapper.java @@ -49,4 +49,13 @@ public interface AiModelConfigMapper extends BaseMapperX { .eq(AiModelConfigDO::getStatus, 1)); } + /** + * 查询所有启用的模型配置 + */ + default List selectEnabledList() { + return selectList(new LambdaQueryWrapperX() + .eq(AiModelConfigDO::getStatus, 1) + .orderByAsc(AiModelConfigDO::getPlatform, AiModelConfigDO::getModelCode)); + } + } \ No newline at end of file diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/service/AiModelConfigService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/service/AiModelConfigService.java index fbbeba1db7..24e437ea50 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/service/AiModelConfigService.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/service/AiModelConfigService.java @@ -1,6 +1,7 @@ package cn.iocoder.yudao.module.tik.muye.aimodelconfig.service; import java.util.*; +import lombok.Data; import cn.iocoder.yudao.module.tik.muye.aimodelconfig.dal.AiModelConfigDO; import cn.iocoder.yudao.module.tik.muye.aimodelconfig.vo.AiModelConfigPageReqVO; @@ -60,4 +61,21 @@ public interface AiModelConfigService { */ PageResult getAiModelConfigPage(AiModelConfigPageReqVO pageReqVO); + /** + * 获取所有启用的模型配置列表(前端积分显示用) + * + * @return 模型配置列表(按平台分组) + */ + Map> getEnabledModelConfigList(); + + /** + * 启用的模型配置简单VO(用于前端显示) + */ + @Data + class ModelConfigSimpleVO { + private String modelCode; + private String modelName; + private Integer consumePoints; + } + } \ No newline at end of file diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/service/AiModelConfigServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/service/AiModelConfigServiceImpl.java index ec62341ef5..9e15faca65 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/service/AiModelConfigServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/aimodelconfig/service/AiModelConfigServiceImpl.java @@ -77,4 +77,18 @@ public class AiModelConfigServiceImpl implements AiModelConfigService { return aiModelConfigMapper.selectPage(pageReqVO); } + @Override + public Map> getEnabledModelConfigList() { + List configList = aiModelConfigMapper.selectEnabledList(); + Map> result = new HashMap<>(); + for (AiModelConfigDO config : configList) { + ModelConfigSimpleVO simpleVO = new ModelConfigSimpleVO(); + simpleVO.setModelCode(config.getModelCode()); + simpleVO.setModelName(config.getModelName()); + simpleVO.setConsumePoints(config.getConsumePoints()); + result.computeIfAbsent(config.getPlatform(), k -> new ArrayList<>()).add(simpleVO); + } + return result; + } + } \ No newline at end of file diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/job/OssQuotaReconcileJob.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/job/OssQuotaReconcileJob.java new file mode 100644 index 0000000000..29ad153e2d --- /dev/null +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/job/OssQuotaReconcileJob.java @@ -0,0 +1,113 @@ +package cn.iocoder.yudao.module.tik.muye.memberuserprofile.job; + +import cn.iocoder.yudao.module.tik.file.dal.mysql.TikUserFileMapper; +import cn.iocoder.yudao.module.tik.muye.memberuserprofile.dal.MemberUserProfileDO; +import cn.iocoder.yudao.module.tik.muye.memberuserprofile.mapper.MemberUserProfileMapper; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.List; +import java.util.Map; + +/** + * OSS配额每日对账定时任务 + * + * 功能:对比 tik_user_file 实际存储与 muye_member_user_profile 记录的存储 + * 如果存在差异,则更新 member_user_profile 表 + * 执行频率:每天凌晨3点执行 + * + * @author 芋道源码 + */ +@Component +@Slf4j +public class OssQuotaReconcileJob { + + /** + * GB转字节的常量 + */ + private static final BigDecimal GB_TO_BYTES = new BigDecimal("1073741824"); + + @Resource + private TikUserFileMapper userFileMapper; + + @Resource + private MemberUserProfileMapper profileMapper; + + /** + * 每天凌晨3点执行对账 + * cron: 秒 分 时 日 月 周 + */ + @Scheduled(cron = "0 0 3 * * ?") + public void reconcileStorage() { + log.info("[reconcileStorage][开始OSS配额对账]"); + int updatedCount = 0; + int errorCount = 0; + + try { + // 1. 获取每个用户的实际存储使用量(字节) + List> storageList = userFileMapper.selectStorageByUser(); + + for (Map record : storageList) { + try { + String userId = String.valueOf(record.get("user_id")); + Long actualSizeBytes = ((Number) record.get("total_size")).longValue(); + + // 2. 获取用户档案中记录的存储 + MemberUserProfileDO profile = profileMapper.selectByUserId(userId); + if (profile == null) { + log.warn("[reconcileStorage][用户({})档案不存在,跳过]", userId); + continue; + } + + // 3. 将实际存储(字节)转换为GB + BigDecimal actualSizeGb = new BigDecimal(actualSizeBytes) + .divide(GB_TO_BYTES, 6, RoundingMode.HALF_UP); + + // 4. 对比记录的存储(GB) + BigDecimal recordedSizeGb = profile.getUsedStorage(); + if (recordedSizeGb == null) { + recordedSizeGb = BigDecimal.ZERO; + } + + // 5. 检查差异(允许0.01GB的误差) + BigDecimal diff = actualSizeGb.subtract(recordedSizeGb).abs(); + if (diff.compareTo(new BigDecimal("0.01")) > 0) { + // 存在差异,更新记录 + BigDecimal totalStorage = profile.getTotalStorage(); + if (totalStorage == null) { + totalStorage = BigDecimal.ZERO; + } + BigDecimal remainingStorage = totalStorage.subtract(actualSizeGb); + if (remainingStorage.compareTo(BigDecimal.ZERO) < 0) { + remainingStorage = BigDecimal.ZERO; + } + + // 更新档案 + MemberUserProfileDO updateProfile = new MemberUserProfileDO(); + updateProfile.setId(profile.getId()); + updateProfile.setUsedStorage(actualSizeGb); + updateProfile.setRemainingStorage(remainingStorage); + profileMapper.updateById(updateProfile); + + log.info("[reconcileStorage][用户({})存储已修正:{}GB -> {}GB]", + userId, recordedSizeGb, actualSizeGb); + updatedCount++; + } + } catch (Exception e) { + log.error("[reconcileStorage][处理用户存储记录失败,record({})]", record, e); + errorCount++; + } + } + + log.info("[reconcileStorage][对账完成,共检查{}个用户,修正{}个,失败{}个]", + storageList.size(), updatedCount, errorCount); + } catch (Exception e) { + log.error("[reconcileStorage][对账任务执行失败]", e); + } + } + +} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/mapper/MemberUserProfileMapper.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/mapper/MemberUserProfileMapper.java index 4d1fbe5b6a..7a66efda04 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/mapper/MemberUserProfileMapper.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/mapper/MemberUserProfileMapper.java @@ -60,4 +60,30 @@ public interface MemberUserProfileMapper extends BaseMapperX= #{points}") int updatePointsDeduct(@Param("userId") String userId, @Param("points") Integer points); + /** + * 原子增加已用存储(乐观锁) + * @param userId 用户ID + * @param storageGb 增加的存储量(GB) + * @return 影响行数,0表示余额不足 + */ + @Update("UPDATE muye_member_user_profile " + + "SET used_storage = used_storage + #{storageGb}, " + + " remaining_storage = remaining_storage - #{storageGb}, " + + " update_time = NOW() " + + "WHERE user_id = #{userId} AND remaining_storage >= #{storageGb}") + int updateStorageIncrease(@Param("userId") String userId, @Param("storageGb") String storageGb); + + /** + * 原子减少已用存储 + * @param userId 用户ID + * @param storageGb 减少的存储量(GB) + * @return 影响行数,0表示已用存储不足 + */ + @Update("UPDATE muye_member_user_profile " + + "SET used_storage = used_storage - #{storageGb}, " + + " remaining_storage = remaining_storage + #{storageGb}, " + + " update_time = NOW() " + + "WHERE user_id = #{userId} AND used_storage >= #{storageGb}") + int updateStorageDecrease(@Param("userId") String userId, @Param("storageGb") String storageGb); + } \ No newline at end of file diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/service/MemberUserProfileService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/service/MemberUserProfileService.java index 24390e3ddb..e77bbdb8e7 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/service/MemberUserProfileService.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/service/MemberUserProfileService.java @@ -69,4 +69,33 @@ public interface MemberUserProfileService { */ MemberUserProfileDO createIfAbsent(Long userId); + // ========== 存储空间管理 ========== + + /** + * 校验存储空间是否足够 + * + * @param userId 用户ID + * @param fileSizeBytes 文件大小(字节) + * @throws cn.iocoder.yudao.framework.common.exception.ServiceException 存储空间不足时抛出异常 + */ + void validateStorage(String userId, long fileSizeBytes); + + /** + * 增加已使用存储空间 + * + * @param userId 用户ID + * @param fileSizeBytes 文件大小(字节) + * @return 是否成功(余额不足返回false) + */ + boolean increaseUsedStorage(String userId, long fileSizeBytes); + + /** + * 减少已使用存储空间 + * + * @param userId 用户ID + * @param fileSizeBytes 文件大小(字节) + * @return 是否成功 + */ + boolean decreaseUsedStorage(String userId, long fileSizeBytes); + } \ No newline at end of file diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/service/MemberUserProfileServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/service/MemberUserProfileServiceImpl.java index 8672d8817b..1052ace6e0 100644 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/service/MemberUserProfileServiceImpl.java +++ b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/muye/memberuserprofile/service/MemberUserProfileServiceImpl.java @@ -14,6 +14,7 @@ import org.springframework.validation.annotation.Validated; import java.time.LocalDateTime; import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.*; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; @@ -114,4 +115,45 @@ public class MemberUserProfileServiceImpl implements MemberUserProfileService { return profile; } + // ========== 存储空间管理 ========== + + /** + * GB转字节的常量 + */ + private static final BigDecimal GB_TO_BYTES = new BigDecimal("1073741824"); + + @Override + public void validateStorage(String userId, long fileSizeBytes) { + MemberUserProfileDO profile = memberUserProfileMapper.selectByUserId(userId); + if (profile == null) { + throw exception(new ErrorCode(1004, "会员用户档案不存在")); + } + + // 将剩余存储空间(GB)转换为字节进行比较 + BigDecimal remainingBytes = profile.getRemainingStorage().multiply(GB_TO_BYTES); + if (remainingBytes.compareTo(new BigDecimal(fileSizeBytes)) < 0) { + throw exception(new ErrorCode(1005, "存储空间不足,剩余 " + profile.getRemainingStorage() + " GB")); + } + } + + @Override + public boolean increaseUsedStorage(String userId, long fileSizeBytes) { + // 将字节转换为GB(保留6位小数) + BigDecimal storageGb = new BigDecimal(fileSizeBytes).divide(GB_TO_BYTES, 6, RoundingMode.HALF_UP); + String storageGbStr = storageGb.toPlainString(); + + int affectedRows = memberUserProfileMapper.updateStorageIncrease(userId, storageGbStr); + return affectedRows > 0; + } + + @Override + public boolean decreaseUsedStorage(String userId, long fileSizeBytes) { + // 将字节转换为GB(保留6位小数) + BigDecimal storageGb = new BigDecimal(fileSizeBytes).divide(GB_TO_BYTES, 6, RoundingMode.HALF_UP); + String storageGbStr = storageGb.toPlainString(); + + int affectedRows = memberUserProfileMapper.updateStorageDecrease(userId, storageGbStr); + return affectedRows > 0; + } + } \ No newline at end of file diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/dal/dataobject/TikUserQuotaDO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/dal/dataobject/TikUserQuotaDO.java deleted file mode 100644 index e09acc28c4..0000000000 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/dal/dataobject/TikUserQuotaDO.java +++ /dev/null @@ -1,55 +0,0 @@ -package cn.iocoder.yudao.module.tik.quota.dal.dataobject; - -import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO; -import com.baomidou.mybatisplus.annotation.KeySequence; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.*; - -/** - * 用户配额 DO - * - * @author 芋道源码 - */ -@TableName("tik_user_quota") -@KeySequence("tik_user_quota_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 -@Data -@EqualsAndHashCode(callSuper = true) -@ToString(callSuper = true) -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class TikUserQuotaDO extends TenantBaseDO { - - /** - * 配额编号 - */ - @TableId - private Long id; - /** - * 用户编号 - */ - private Long userId; - /** - * 总存储空间(字节) - */ - private Long totalStorage; - /** - * 已使用存储空间(字节) - */ - private Long usedStorage; - /** - * 总配额(积分/额度) - */ - private Long totalQuota; - /** - * 已使用配额(积分/额度) - */ - private Long usedQuota; - /** - * VIP等级 - */ - private Integer vipLevel; - -} - diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/dal/mysql/TikUserQuotaMapper.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/dal/mysql/TikUserQuotaMapper.java deleted file mode 100644 index 2e221bf24d..0000000000 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/dal/mysql/TikUserQuotaMapper.java +++ /dev/null @@ -1,20 +0,0 @@ -package cn.iocoder.yudao.module.tik.quota.dal.mysql; - -import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; -import cn.iocoder.yudao.module.tik.quota.dal.dataobject.TikUserQuotaDO; -import org.apache.ibatis.annotations.Mapper; - -/** - * 用户配额 Mapper - * - * @author 芋道源码 - */ -@Mapper -public interface TikUserQuotaMapper extends BaseMapperX { - - default TikUserQuotaDO selectByUserId(Long userId) { - return selectOne(TikUserQuotaDO::getUserId, userId); - } - -} - diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaService.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaService.java deleted file mode 100644 index c08f0cd915..0000000000 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaService.java +++ /dev/null @@ -1,71 +0,0 @@ -package cn.iocoder.yudao.module.tik.quota.service; - -import cn.iocoder.yudao.module.tik.quota.dal.dataobject.TikUserQuotaDO; - -/** - * 用户配额 Service 接口 - * - * @author 芋道源码 - */ -public interface TikUserQuotaService { - - /** - * 初始化用户配额 - * - * @param userId 用户编号 - * @param vipLevel VIP等级 - * @return 配额编号 - */ - Long initQuota(Long userId, Integer vipLevel); - - /** - * 获取用户配额 - * - * @param userId 用户编号 - * @return 配额信息 - */ - TikUserQuotaDO getQuota(Long userId); - - /** - * 获取或创建用户配额(如果不存在则创建) - * - * @param userId 用户编号 - * @return 配额信息 - */ - TikUserQuotaDO getOrCreateQuota(Long userId); - - /** - * 校验存储空间是否足够 - * - * @param userId 用户编号 - * @param fileSize 文件大小(字节) - */ - void validateStorage(Long userId, Long fileSize); - - /** - * 增加已使用存储空间 - * - * @param userId 用户编号 - * @param fileSize 文件大小(字节) - */ - void increaseUsedStorage(Long userId, Long fileSize); - - /** - * 减少已使用存储空间 - * - * @param userId 用户编号 - * @param fileSize 文件大小(字节) - */ - void decreaseUsedStorage(Long userId, Long fileSize); - - /** - * 更新VIP等级和配额 - * - * @param userId 用户编号 - * @param vipLevel VIP等级 - * @param totalStorage 总存储空间(字节) - * @param totalQuota 总配额(积分/额度) - */ - void updateVipLevel(Long userId, Integer vipLevel, Long totalStorage, Long totalQuota); - -} diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaServiceImpl.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaServiceImpl.java deleted file mode 100644 index 0230c07d87..0000000000 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/service/TikUserQuotaServiceImpl.java +++ /dev/null @@ -1,120 +0,0 @@ -package cn.iocoder.yudao.module.tik.quota.service; - -import cn.iocoder.yudao.module.tik.quota.dal.dataobject.TikUserQuotaDO; -import cn.iocoder.yudao.module.tik.quota.dal.mysql.TikUserQuotaMapper; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.validation.annotation.Validated; - -import jakarta.annotation.Resource; - -import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; -import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.QUOTA_NOT_ENOUGH; -import static cn.iocoder.yudao.module.tik.enums.ErrorCodeConstants.QUOTA_NOT_EXISTS; - -/** - * 用户配额 Service 实现类 - * - * @author 芋道源码 - */ -@Service -@Validated -@Slf4j -public class TikUserQuotaServiceImpl implements TikUserQuotaService { - - @Resource - private TikUserQuotaMapper quotaMapper; - - @Override - @Transactional(rollbackFor = Exception.class) - public Long initQuota(Long userId, Integer vipLevel) { - // 检查是否已存在 - TikUserQuotaDO existing = quotaMapper.selectByUserId(userId); - if (existing != null) { - log.info("[initQuota][用户({})配额已存在,跳过初始化]", userId); - return existing.getId(); - } - - // 创建配额记录(默认值) - TikUserQuotaDO quota = TikUserQuotaDO.builder() - .userId(userId) - .vipLevel(vipLevel != null ? vipLevel : 0) - .totalStorage(0L) - .usedStorage(0L) - .totalQuota(0L) - .usedQuota(0L) - .build(); - quotaMapper.insert(quota); - log.info("[initQuota][用户({})配额初始化成功,配额编号({})]", userId, quota.getId()); - return quota.getId(); - } - - @Override - public TikUserQuotaDO getQuota(Long userId) { - return quotaMapper.selectByUserId(userId); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public TikUserQuotaDO getOrCreateQuota(Long userId) { - TikUserQuotaDO quota = getQuota(userId); - if (quota == null) { - // 如果不存在,创建默认配额 - Long quotaId = initQuota(userId, 0); - quota = quotaMapper.selectById(quotaId); - } - return quota; - } - - @Override - public void validateStorage(Long userId, Long fileSize) { - TikUserQuotaDO quota = getOrCreateQuota(userId); - if (quota == null) { - throw exception(QUOTA_NOT_EXISTS); - } - - long remainingStorage = quota.getTotalStorage() - quota.getUsedStorage(); - if (remainingStorage < fileSize) { - throw exception(QUOTA_NOT_ENOUGH); - } - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void increaseUsedStorage(Long userId, Long fileSize) { - TikUserQuotaDO quota = getOrCreateQuota(userId); - quota.setUsedStorage(quota.getUsedStorage() + fileSize); - quotaMapper.updateById(quota); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void decreaseUsedStorage(Long userId, Long fileSize) { - TikUserQuotaDO quota = getQuota(userId); - if (quota == null) { - log.warn("[decreaseUsedStorage][用户({})配额不存在,跳过]", userId); - return; - } - - long newUsedStorage = Math.max(0, quota.getUsedStorage() - fileSize); - quota.setUsedStorage(newUsedStorage); - quotaMapper.updateById(quota); - } - - @Override - @Transactional(rollbackFor = Exception.class) - public void updateVipLevel(Long userId, Integer vipLevel, Long totalStorage, Long totalQuota) { - TikUserQuotaDO quota = getOrCreateQuota(userId); - quota.setVipLevel(vipLevel); - if (totalStorage != null) { - quota.setTotalStorage(totalStorage); - } - if (totalQuota != null) { - quota.setTotalQuota(totalQuota); - } - quotaMapper.updateById(quota); - } - -} - diff --git a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/vo/app/AppTikUserQuotaRespVO.java b/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/vo/app/AppTikUserQuotaRespVO.java deleted file mode 100644 index 802c5a9d74..0000000000 --- a/yudao-module-tik/src/main/java/cn/iocoder/yudao/module/tik/quota/vo/app/AppTikUserQuotaRespVO.java +++ /dev/null @@ -1,43 +0,0 @@ -package cn.iocoder.yudao.module.tik.quota.vo.app; - -import io.swagger.v3.oas.annotations.media.Schema; -import lombok.Data; - -/** - * 用户 App - 用户配额 Response VO - * - * @author 芋道源码 - */ -@Schema(description = "用户 App - 用户配额 Response VO") -@Data -public class AppTikUserQuotaRespVO { - - @Schema(description = "配额编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") - private Long id; - - @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") - private Long userId; - - @Schema(description = "总存储空间(字节)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1073741824") - private Long totalStorage; - - @Schema(description = "已使用存储空间(字节)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024000") - private Long usedStorage; - - @Schema(description = "剩余存储空间(字节)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1072717824") - private Long remainingStorage; - - @Schema(description = "总配额(积分/额度)", requiredMode = Schema.RequiredMode.REQUIRED, example = "1000") - private Long totalQuota; - - @Schema(description = "已使用配额(积分/额度)", requiredMode = Schema.RequiredMode.REQUIRED, example = "100") - private Long usedQuota; - - @Schema(description = "剩余配额(积分/额度)", requiredMode = Schema.RequiredMode.REQUIRED, example = "900") - private Long remainingQuota; - - @Schema(description = "VIP等级", requiredMode = Schema.RequiredMode.REQUIRED, example = "0") - private Integer vipLevel; - -} -