111 Commits
Author SHA1 Message Date
leeexin eb1f68b423 feat: add public purchase portal and admin tools
Deploy Frontend via Git / build-and-deploy (push) Failing after 1m0s
Deploy Backend via Git / deploy (push) Failing after 1m0s
2026-03-28 17:55:03 +08:00
wood911010 c0a6ee3cb0 refactor: improve JSON editor functionality and styling, enhance input handling, and update button visibility 2026-03-21 20:53:33 +08:00
wood911010 1ceff0ffa3 chore: update image assets for customer, home, license, and login screens 2026-03-21 17:04:18 +08:00
wood911010 f16bd5567b chore: update README.md to reflect the new demo URL and remove port number for simplified access 2026-03-21 15:44:09 +08:00
wood911010 60f49b3b27 chore: remove unused type import from LicenseForm.vue to clean up code 2026-03-15 13:20:07 +08:00
wood911010 2f9a401381 feat: implement JSON editor component for enhanced JSON data management in forms 2026-03-13 23:57:14 +08:00
wood911010 7355edf999 chore: remove sync_to_enterprise.yml as it is no longer needed for the workflow 2026-03-13 20:57:40 +08:00
wood911010 f8173ace88 chore: update sync_to_enterprise.yml to enhance branch management and streamline push process to enterprise repository 2026-03-13 20:55:46 +08:00
wood911010 a4198743a4 chore: simplify sync_to_enterprise.yml by removing redundant Git setup and explicitly setting remote for pushing changes 2026-03-13 20:44:57 +08:00
wood911010 224d36dcf1 chore: refine sync_to_enterprise.yml by improving error handling during repo cloning and enhancing commit configuration 2026-03-13 20:41:43 +08:00
wood911010 1fb4940625 chore: enhance sync_to_enterprise.yml with debug information and streamline cloning process for better error handling 2026-03-13 20:36:38 +08:00
wood911010 182f57480a chore: streamline sync_to_enterprise.yml by replacing Git setup with gh CLI for improved efficiency 2026-03-13 20:28:42 +08:00
wood911010 9680da7762 chore: improve Git setup and remote verification in sync_to_enterprise.yml for better reliability 2026-03-13 20:17:12 +08:00
wood911010 13e33777bd chore: add remote connection test to sync_to_enterprise.yml for improved debugging 2026-03-13 20:00:56 +08:00
wood911010 4dab5563c5 chore: add debug echo for enterprise repo token loading in sync_to_enterprise.yml 2026-03-13 19:43:03 +08:00
wood911010 1dba45d417 feat: add workflow_dispatch trigger to sync_to_enterprise.yml for manual workflow execution 2026-03-13 19:33:20 +08:00
wood911010 42a5803c06 Merge branch 'main' of https://github.com/cedar-v/license-manager 2026-03-13 19:20:27 +08:00
wood911010 ed00a63434 feat: Add GitHub Actions workflow for syncing to enterprise repository and update documentation for remote configuration 2026-03-13 19:20:20 +08:00
jie c2204adf0c feat: update login page localization and enhance UI elements for better user experience 2026-03-13 13:46:07 +08:00
jie ce410666d9 feat: fix chart overflow issues by adjusting CSS height strategies and ECharts grid configuration 2026-03-13 01:40:13 +08:00
jie bc5d654a59 feat: enhance dashboard stats API and UI with new metrics and improved localization 2026-03-13 01:17:24 +08:00
jie 8410a2cac4 feat: add dashboard stats redesign spec and login i18n completion 2026-03-13 00:42:22 +08:00
jie b0539c0014 Implement feature X to enhance user experience and optimize performance 2026-03-12 23:49:50 +08:00
jie ccbe6cf37b feat: remove zrender from echarts dependencies for improved bundle size 2026-03-12 22:45:03 +08:00
jie a0ff21ad3f feat: enhance LicenseInfo component by adding MessageBoxInputData type for better type safety 2026-03-12 22:37:06 +08:00
jie 10030f2fc7 Merge branch 'main' of https://github.com/cedar-v/license-manager 2026-03-12 22:33:20 +08:00
jie 9eb237513e feat: migrate from npm to pnpm for dependency management in frontend deployment 2026-03-12 10:43:02 +08:00
Coldroads 4e619c30be Merge pull request #9 from mosshello/main
feat: optimize frontend performance and update dependencies
2026-03-11 19:06:20 +08:00
jie 09be964d53 feat: optimize frontend performance and update dependencies 2026-03-11 19:02:36 +08:00
wood911010 aa3ae38968 feat: Implement initial License Manager backend service with API endpoints and Swagger documentation. 2026-03-03 18:04:09 +08:00
wood911010 9fd545797f Merge branch 'main' of https://github.com/cedar-v/license-manager 2026-03-02 14:13:08 +08:00
wood911010 e84ae246b4 feat: Update LeadCreateRequest to make requirement field optional and set default value in lead creation 2026-03-02 14:12:59 +08:00
小屁孩 d48961ecf4 fix: 套餐管理国际化配置优化 2026-03-01 17:13:22 +08:00
wood911010 b5eda1d6d1 feat: Add packages table migration, initial configuration, and build executables. 2026-02-27 22:13:03 +08:00
wood911010 b903af8402 feat: Implement customer order management, including package listing, price calculation, and order creation. 2026-02-27 22:10:20 +08:00
wood911010 5fdbe4b023 Merge branch 'main' of https://github.com/cedar-v/license-manager 2026-02-27 00:55:08 +08:00
wood911010 7dd00dc074 feat: Add license management repository, models, and configuration for license and authorization code operations. 2026-02-27 00:55:04 +08:00
小屁孩 d56dfc2bad fix: bug修改 2026-02-25 21:58:26 +08:00
小屁孩 4de1f4d2e7 fix: bug修改 2026-02-25 21:27:00 +08:00
wood911010 a95322b6c5 feat: Implement CuOrderRepository for managing customer orders, including CRUD, listing, and summary operations. 2026-02-24 23:11:36 +08:00
wood911010 a1f411639d feat: Add customer order management module including repository, service, and configuration. 2026-02-24 23:04:59 +08:00
wood911010 560adafe27 feat: Implement comprehensive client and administrator invoice management features. 2026-02-24 22:26:09 +08:00
wood911010 b65b4ac801 feat: Implement initial customer authorization code and invoice management modules with API documentation and configuration. 2026-02-24 18:50:47 +08:00
小屁孩 c3d0f4aa2a fix: bug修改 2026-02-24 17:49:51 +08:00
wood911010 dec473a62a feat: Add database migration for packages table, backend configuration, and frontend dependency lock files. 2026-02-15 11:12:02 +08:00
Coldroads c9ced7dc2a Merge pull request #8 from mosshello/main
feat: 优化全局字体加载
2026-02-15 00:33:55 +08:00
jie 27a2e41c93 feat: 优化全局字体加载 2026-02-15 00:05:07 +08:00
wood911010 a91b4e0deb add: sync 2026-02-14 20:59:08 +08:00
wood911010 91fe674dea feat: Add initial license manager backend application, configuration, and development installation documentation. 2026-02-14 19:22:07 +08:00
wood911010 5f944dd88a chore: Remove src/components/common/Header.tsx. 2026-02-13 13:54:26 +08:00
wood911010 8a8630416e fix: 修改文件静态路径 2026-02-11 23:08:17 +08:00
wood911010 b4e7d954b0 Merge branch 'main' of https://github.com/cedar-v/license-manager 2026-02-11 22:36:08 +08:00
wood911010 fa715abbc2 feat: 添加订单列表查询接口的发票申请状态字段 2026-02-11 22:34:47 +08:00
小屁孩 3570167a83 feat: 最新代码提交 2026-02-11 20:42:16 +08:00
小屁孩 7224504ee0 feat: 最新代码提交 2026-02-11 20:38:14 +08:00
小屁孩 fd19bc4070 feat: 最新代码提交 2026-02-10 18:18:20 +08:00
wood911010 f02c00f9fe fix: 试用版更新 2026-02-10 01:07:35 +08:00
wood911010 d4dad1537f chore: update swagger documentation and improve API response structure 2026-02-10 00:34:25 +08:00
wood911010 679d94abd0 fix: 更新action 2026-02-10 00:31:03 +08:00
wood911010 5a5e9a7171 Merge branch 'main' of https://github.com/cedar-v/license-manager 2026-02-10 00:25:33 +08:00
wood911010 cdf00189aa feat: 增加继续支付接口 2026-02-10 00:24:53 +08:00
小屁孩 07fbbdd2a5 feat: 最新代码提交 2026-02-09 18:59:34 +08:00
wood911010 ab983e5dd6 fix: add retry logic for git pull in backend deployment workflow and improve nginx reload process in frontend workflow 2026-02-09 16:54:47 +08:00
wood911010 5908e3e9fd fix: update frontend directory permissions for nginx user in deployment workflow 2026-02-09 16:44:57 +08:00
wood911010 c716b2bea2 refactor: remove unused icon import from Dashboard.vue 2026-02-09 16:04:03 +08:00
Coldroads 67099d21a1 Merge pull request #7 from mosshello/feature/style_refactor0208
refactor: 全局字体、样式、间距统一优化
2026-02-09 15:55:24 +08:00
wood911010 2934364013 refactor: 后端部署流程更新、套餐 接口升级 2026-02-09 15:48:17 +08:00
小屁孩 98c49e91f7 feat: 最新代码提交 2026-02-09 11:00:58 +08:00
wood911010 7b1122e125 fix: change SSH username from 'pi' to 'root' in deployment workflow 2026-02-08 19:31:16 +08:00
wood911010 c3d5b891b6 chore: 更新自动化 2026-02-08 18:49:47 +08:00
小屁孩 ea37b2ebb1 feat: 最新代码提交 2026-02-08 09:33:07 +08:00
小屁孩 89cb1d92c8 feat: 最新代码提交 2026-02-08 09:29:26 +08:00
jie 601658c992 refactor: 全局字体、样式、间距统一优化 2026-02-08 03:41:38 +08:00
wood911010 0659c7624d fix: 登录被锁错误返回优化 2026-02-07 22:47:38 +08:00
wood911010 2555b07abe fix:用户端发票管理查询接口增加结束时间 2026-02-07 21:58:56 +08:00
wood911010 01b27fdde6 fix: enable customer authentication middleware in router setup 2026-02-05 13:50:19 +08:00
wood911010 33881795c5 docs: update README to clarify official documentation and website links 2026-02-03 10:17:23 +08:00
wood911010 26dd1b9615 feat: 添加发票文件代理和静态文件服务支持 2026-02-02 21:20:39 +08:00
wood911010 eba7baaa5f Merge branch 'main' of github.com:cedar-v/license-manager 2026-02-02 19:58:38 +08:00
wood911010 97bda15450 fix: update swagger documentation comments for clarity and formatting 2026-02-02 19:58:31 +08:00
小屁孩 ce5bd6d3ba feat: 添加功能添加 2026-02-02 18:47:27 +08:00
小屁孩 41e00fcb90 feat: 添加功能添加 2026-02-02 16:20:10 +08:00
wood911010 7642e0d1ea Fix:增加缺少的备注字段 2026-02-02 16:07:27 +08:00
wood911010 e6899d26bc docs:更新swagger文档 2026-02-02 14:40:53 +08:00
wood911010 8b758cc40c feat: 增加线索汇总 2026-02-02 14:37:10 +08:00
wood911010 f2bb2ef952 fix: 更新发票请求结构体,修正内容字段为非必填,更新注释以提高可读性 2026-02-02 09:29:12 +08:00
wood911010 3edbaef170 fix: 修正删除客户时的错误代码,更新授权码和许可证相关的错误信息 2026-02-02 01:43:27 +08:00
wood911010 251fcba655 feat: 更新开发提示词文档,增加开发原则、沟通协作和行为准则,添加授权码模糊匹配参数支持 2026-02-02 01:34:42 +08:00
wood911010 b8c21c24cd feat: 添加企业线索管理功能,包括线索的创建、更新、删除和查询接口,更新相关文档和数据库结构 2026-02-01 13:19:29 +08:00
wood911010 d6890462a3 feat: 添加套餐管理功能,包括套餐的创建、更新、删除和查询接口,更新相关文档 2026-02-01 00:11:51 +08:00
小屁孩 2032950bb7 feat: 发票管理 2026-01-31 14:05:06 +08:00
wood911010 fa056ae272 Merge branch 'main' of github.com:cedar-v/license-manager 2026-01-30 23:30:51 +08:00
wood911010 f6376fbf2b feat: 添加管理端用户管理接口文档,更新发票管理相关接口的安全性和描述信息 2026-01-30 23:30:42 +08:00
duanjianhong d548f07c9f fix: 页面优化 2026-01-30 09:21:00 +08:00
小屁孩 7f4e2592b7 fix: 发票 2026-01-30 08:46:45 +08:00
wood911010 8f18d1e442 feat: 添加令牌刷新机制,支持自动延长token有效期,更新相关文档描述 2026-01-27 01:00:17 +08:00
wood911010 41b499cde7 fix: 修正授权码状态判断逻辑,确保正常状态的条件判断准确 2026-01-27 00:21:30 +08:00
wood911010 33d348c692 refactor: 修改用户授权码相关接口,将用户ID参数更改为客户ID,并更新相关文档注释以提高可读性 2026-01-27 00:01:25 +08:00
wood911010 f14f2a536f feat: 更新订单创建逻辑,根据套餐类型和支付方式处理试用版和免费订单 2026-01-24 20:00:25 +08:00
wood911010 a4a5870925 fix: 更新默认国家代码为 +86,以支持正确的手机号格式 2026-01-23 21:44:46 +08:00
wood911010 b93d51b4ad feat: 更新授权码分享功能,支持通过手机号或邮箱分享授权码,并更新相关文档描述 2026-01-23 15:10:27 +08:00
wood911010 b80f5cfdd9 feat: 更新订单删除功能,支持物理删除订单及相关支付记录,确保数据一致性 2026-01-23 09:36:51 +08:00
wood911010 af1b7d49a2 feat: 更新后端设计文档,添加发票管理模块相关内容,支持缓存系统和国际化,优化架构描述 2026-01-23 01:11:23 +08:00
wood911010 969b36f1d1 feat: 添加发票管理模块,支持客户申请发票、管理员处理发票、文件上传及下载功能,更新相关文档和国际化错误码 2026-01-23 00:38:00 +08:00
wood911010 3192b79aa4 feat: 更新订单处理接口,新增删除订单功能并更新相关文档,调整成功响应格式 2026-01-22 23:30:21 +08:00
wood911010 afa9e8204f feat: 添加取消订单接口,支持取消未支付订单并返回相应错误信息,更新相关文档和国际化错误码 2026-01-22 01:22:52 +08:00
wood911010 874820fabd feat: 增加订单号查询筛选功能,支持模糊匹配、状态和时间筛选,更新相关接口文档 2026-01-18 16:46:00 +08:00
wood911010 4c48344fc1 refactor: 更新CI/CD流程,构建后端时跳过测试以避免外部API依赖,同时删除不再需要的测试文件 2026-01-18 00:16:03 +08:00
wood911010 78a35f71e1 refactor: 删除TestSMSEnabledService测试以避免在CI/CD环境中调用真实的外部API,建议在本地开发环境中手动测试SMS功能 2026-01-17 23:13:02 +08:00
wood911010 39957295af docs:更新README 2026-01-17 23:01:07 +08:00
wood911010 9aa4688ac8 chore: update backend and frontend images to version 1.1.0 in docker-compose configuration 2026-01-17 22:53:22 +08:00
168 changed files with 33746 additions and 3287 deletions

No files matched your search

+228
View File
@@ -0,0 +1,228 @@
# 技术契约与架构设计书
## 功能:授权趋势图表自适应溢出修复
**版本**:v1.0
**日期**:2026-03-13
**设计者**:System Architect
**状态**:待实现
---
## 一、问题定位(Root Cause Analysis)
通过审计截图与源码,确认存在 **三条独立的溢出根因**,相互叠加导致图表在不同视口宽度下随机超出卡片边界。
### 根因 A — CSS 高度链断裂(最高优先级)
**文件**:`frontend/src/views/Dashboard.vue`
```
.content-container { min-height: calc(100vh - 80px); } ← ①
└─ .dashboard { height: 100%; } ← ② ❌ 100% of min-height = 0!
└─ .content-section { flex: 1; min-height: 0; } ← ③
└─ .chart-section { flex: 0.5; min-height: 300px; }
└─ .license-trend-chart { height: 100%; } ← ④ 同样失效
```
CSS 规范:`height: 100%` 只解析相对于父元素的 **`height`** 属性,而非 `min-height`。步骤②处 `.dashboard` 获取的 `height: 100%` 实际解析为 `auto`,导致整条 flex 高度链全部退化为 `auto`/内容高度,图表实际渲染高度由内容撑开,触发溢出。
**现象**:桌面端图表卡片高度不稳定,ECharts canvas 随视口/数据量变化而溢出卡片底部。
### 根因 B — ECharts Grid 使用绝对像素 + `containLabel: false`
**文件**:`frontend/src/components/charts/LicenseTrendChart.vue`
```js
grid: {
left: 60, // 固定像素,容器小时 Y 轴标签溢出左侧
right: 50, // 固定像素,容器小时溢出右侧
top: 30,
bottom: 60,
containLabel: false // ❌ 不包含坐标轴标签在 grid 范围内
}
```
当容器宽度较小时,ECharts 按绝对像素保留边距并绘制轴标签于 grid 外部,标签实际渲染于 canvas 边缘外,出现截断/溢出。
**现象**:窄屏或侧边栏展开时,X 轴日期标签被裁切或与右侧内容重叠。
### 根因 C — 父容器 `overflow: hidden` 裁切 ECharts tooltip
**文件**:`frontend/src/components/charts/LicenseTrendChart.vue`
```scss
.license-trend-chart {
overflow: hidden; // ❌ 裁切了 ECharts tooltip 的绝对定位弹层
}
```
ECharts 的 tooltip 默认渲染为 `position: absolute` 的 `div` 元素,若其最近的 `overflow: hidden` 祖先正好是卡片容器,tooltip 悬浮层会被硬裁。移动端尤其明显(截图二:tooltip "授权数量: 8" 被截断于卡片底部)。
---
## 二、数据模型层(Schema)
本次修复为纯前端 CSS/布局层变更,**不涉及任何数据模型、API 接口或后端逻辑修改**。API 契约保持不变。
---
## 三、后端服务层(API)
**无变更**。后端 `GET /api/v1/dashboard/authorization-trend` 接口及响应格式 `AuthorizationTrendResponse` 不受影响。
---
## 四、前端交互层(View)变更契约
### 4.1 变更文件清单
| 操作 | 文件路径 | 变更范围 |
|------|----------|----------|
| 修改 | `frontend/src/views/Dashboard.vue` | `.content-container` 的高度策略 |
| 修改 | `frontend/src/components/charts/LicenseTrendChart.vue` | Grid 配置 + overflow + 高度策略 |
---
### 4.2 `frontend/src/views/Dashboard.vue` — 修改意图
**目标**:修复高度链断裂,使 flex 容器的高度能正确传递给子组件。
#### 改动 1:`.content-container` 改用 `height` 替代 `min-height`
```scss
// ❌ 修改前
.content-container {
min-height: calc(100vh - 80px);
// ...
}
// ✅ 修改后
.content-container {
height: calc(100vh - 80px); // 使用确定的 height,让 flex 子项 height: 100% 可以解析
min-height: 600px; // 保留最小高度防止内容压扁,但用 min-height 做限底
// ...
}
```
> **风险评估**:`Dashboard.vue` 的 `height` 被 `Layout` 组件 `overflow: auto` 的滚动容器包裹,切换为固定 `height` 不会影响滚动行为,但需验证 Layout 组件是否已设置 `overflow-y: auto`。
---
### 4.3 `frontend/src/components/charts/LicenseTrendChart.vue` — 修改意图
**目标 A**:修复 ECharts Grid overflow(根因 B)。
#### 改动 1:`chartOption.grid` 启用 `containLabel: true` 并将边距改为百分比
```js
// ❌ 修改前
grid: {
left: 60,
right: 50,
top: 30,
bottom: 60,
containLabel: false
}
// ✅ 修改后
grid: {
left: '3%', // 改为百分比,随容器缩放
right: '3%',
top: 30,
bottom: 30, // containLabel: true 后 ECharts 自动为标签留空间,无需预留 60px
containLabel: true // 轴标签始终包含在 grid 区域内,不会超出 canvas
}
```
**目标 B**:解决 tooltip 被 `overflow: hidden` 裁切(根因 C)。
#### 改动 2:`v-chart` 的 tooltip 改为挂载到 body
在 `chartOption.tooltip` 中追加:
```js
tooltip: {
// ...现有配置不变...
appendToBody: true, // tooltip DOM 挂载到 <body>,完全逃离 overflow: hidden 容器
}
```
> 此方案比改 `overflow: visible` 更稳健,不破坏卡片的 `border-radius` 裁切效果。`overflow: hidden` 保留,仅 tooltip 逃逸。
**目标 C**:稳定图表 canvas 在 flex 链中的高度(根因 A 的组件侧保障)。
#### 改动 3:`.chart-container` 添加 `position: relative` + `.trend-chart` 使用位置绝对铺满
为防止在高度链无法修复的回退场景下图表仍能铺满容器:
```scss
// ✅ 修改后
.chart-container {
padding: 1.35vw 1.25vw 1.25vw;
flex: 1;
display: flex;
min-height: 0;
position: relative; // 新增:建立定位上下文
.trend-chart {
width: 100%;
height: 100%;
min-height: 12.8vw; // 保持现有移动端 fallback
}
}
// 移动端覆盖(已有,维持原样)
@media (max-width: 1024px) {
.chart-container .trend-chart {
height: 200px;
min-height: unset;
}
}
```
---
## 五、全栈影响面分析(Full-Stack Impact Analysis)
| 层级 | 受影响组件 | 影响类型 | 处理方式 |
|------|-----------|----------|----------|
| 数据层 | 无 | — | 无需处理 |
| API 层 | 无 | — | 无需处理 |
| 状态层 | 无 | — | 无需处理 |
| 视图组件 | `Dashboard.vue` | 高度策略调整 | 必须修改 |
| 子组件 | `LicenseTrendChart.vue` | Grid 配置 + tooltip + overflow | 必须修改 |
| 其他页面 | 其他使用 `Layout` 组件的页面 | 可能受 `.content-container` 修改影响 | 修改前审查;若 `.content-container` 属全局 class 则需回归测试 |
> ⚠️ **注意**:如果 `.content-container` 是全局共用 class(定义在全局 CSS 而非 scoped),则修改会影响所有使用该 class 的页面。需先确认作用域:当前 Dashboard.vue 的 `<style lang="scss" scoped>` 限定了 `.content-container` 只作用于该页面,因此是安全的局部修改。
---
## 六、待验证事项(Acceptance Criteria)
- [ ] 1920×1080 桌面端:图表无溢出,X 轴日期标签完整显示
- [ ] 1440×900:图表无溢出,轴标签不被截断
- [ ] 1280×800:图表卡片高度稳定,边距正常
- [ ] 768px 平板:`.chart-section min-height: 300px` 生效,图表可读
- [ ] 375px 手机:固定 200px 高度正常,tooltip 不被卡片边界截断
- [ ] 暗模式:tooltip 背景色与轴标签颜色不变
- [ ] 快捷选择切换 7 天 / 30 天:图表 resize 正常,无残影溢出
---
## 七、文件清单汇总
### 必须修改
1. `frontend/src/views/Dashboard.vue`
- SCSS `.content-container`:`min-height` → `height` + 添加 `min-height` 保底
2. `frontend/src/components/charts/LicenseTrendChart.vue`
- JS `chartOption.grid`:`containLabel: false` + 固定 px → `containLabel: true` + `%` 边距
- JS `chartOption.tooltip`:新增 `appendToBody: true`
- SCSS `.chart-container`:新增 `position: relative`
### 无需新建
本次修复不需要创建任何新文件。
---
*本文档由 System Architect 技能生成,作为 task-coder 的实现输入。实现时严格遵循上述契约,禁止超出本文档定义的修改范围。*
+26 -12
View File
@@ -18,24 +18,38 @@ jobs:
steps:
# 通过SSH连接服务器进行部署
- name: Deploy to server
uses: appleboy/ssh-action@v1.2.0
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
host: ${{ secrets.HOST43 }}
username: root
password: ${{ secrets.PASS43 }}
port: 22
command_timeout: 10m
script_stop: true
envs: USER
script: |
# 确保使用正确的用户环境
export HOME=/home/pi
export USER=pi
# 确保使用正确的用户环境(云服务器 root)
export HOME=/root
export USER=root
# 加载pi用户的环境配置(包括代理设置)
source /home/pi/.bashrc || true
source /home/pi/.profile || true
# 加载 root 用户的环境配置(包括代理设置)
source /root/.bashrc || true
source /root/.profile || true
cd /home/pi/license-manager
git pull origin main
cd /root/license-manager
echo "Starting git pull with retry (max 5 times)..."
max_retries=5
count=0
until git pull origin main; do
count=$((count + 1))
if [ "$count" -ge "$max_retries" ]; then
echo "❌ git pull failed after $max_retries attempts."
exit 1
fi
echo "⚠️ git pull failed (attempt $count/$max_retries), retrying in 10 seconds..."
sleep 10
done
echo "✅ git pull succeeded."
pm2 restart license_manager
-108
View File
@@ -1,108 +0,0 @@
name: Deploy Backend
on:
# push:
# branches: [ main ]
# paths:
# - 'backend/**'
# - 'Dockerfile'
# - '.github/workflows/deploy_backend.yml'
workflow_dispatch:
env:
IMAGE_NAME: license-manager-backend
CONTAINER_NAME: license-manager
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23'
- name: Build and test backend
run: |
cd backend
go mod tidy
go mod verify
go test ./...
go build -ldflags="-s -w" -o license-manager cmd/main.go
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
run: |
docker build -f Dockerfile.backend -t ${{ env.IMAGE_NAME }}:latest .
docker save ${{ env.IMAGE_NAME }}:latest > backend-image.tar
- name: Copy files to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
port: 22
source: "backend-image.tar"
target: "/home/pi/license-manager/"
- name: Deploy to server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
port: 22
script: |
cd /home/pi/license-manager
# Stop and remove existing container
docker stop ${{ env.CONTAINER_NAME }} || true
docker rm ${{ env.CONTAINER_NAME }} || true
# Load new image
docker load < backend-image.tar
# Remove old image
docker rmi ${{ env.IMAGE_NAME }}:old || true
docker tag ${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:old || true
# Run new container with host network
docker run -d \
--name ${{ env.CONTAINER_NAME }} \
--restart unless-stopped \
--network host \
-v /home/pi/license-manager/logs:/app/logs \
${{ env.IMAGE_NAME }}:latest
# Clean up
rm -f backend-image.tar
docker image prune -f
# Check deployment status
sleep 5
if docker ps | grep -q ${{ env.CONTAINER_NAME }}; then
echo "✅ Deployment successful! Container is running."
docker logs --tail 10 ${{ env.CONTAINER_NAME }}
else
echo "❌ Deployment failed! Container is not running."
docker logs ${{ env.CONTAINER_NAME }}
exit 1
fi
- name: Health check
run: |
sleep 10
response=$(curl -s -o /dev/null -w "%{http_code}" http://${{ secrets.HOST_PI }}:18888/health || echo "000")
if [ "$response" = "200" ]; then
echo "✅ Health check passed! Service is responding."
else
echo "⚠️ Health check returned status: $response"
echo "Service may still be starting up..."
fi
+34 -19
View File
@@ -25,8 +25,13 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
# 设置pnpm
- name: Set up pnpm
uses: pnpm/action-setup@v4
with:
version: 10
run_install: false
# 缓存node_modules(不缓存构建输出,确保每次都是最新构建)
- name: Cache dependencies
@@ -34,7 +39,8 @@ jobs:
with:
path: |
frontend/node_modules
key: ${{ runner.os }}-frontend-deps-${{ hashFiles('frontend/package-lock.json') }}
~/.pnpm-store
key: ${{ runner.os }}-frontend-deps-${{ hashFiles('frontend/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-frontend-deps-
@@ -42,7 +48,7 @@ jobs:
- name: Install dependencies
run: |
cd frontend
npm ci
pnpm install --frozen-lockfile
# 清理旧的构建目录(确保使用最新代码构建)
- name: Clean old build artifacts
@@ -59,9 +65,9 @@ jobs:
export VITE_OUTDIR=production
export VITE_ADDRESS_BASE_URL=/production/
# 先执行类型检查
npm run type-check
pnpm run type-check
# 然后执行构建
npm run build:prod
pnpm run build:prod
# 检查构建结果
echo "Build completed, checking production directory:"
ls -la production/
@@ -76,14 +82,14 @@ jobs:
- name: Prepare temp directory
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
host: ${{ secrets.HOST43 }}
username: root
password: ${{ secrets.PASS43 }}
port: 22
script: |
sudo rm -rf /home/pi/lm-www/frontend_tmp
sudo mkdir -p /home/pi/lm-www/frontend_tmp/production
sudo chown -R pi:pi /home/pi/lm-www/frontend_tmp
sudo chown -R root:root /home/pi/lm-www/frontend_tmp
# 安装 rsync(用于带进度显示的文件传输)
- name: Install rsync
@@ -105,10 +111,10 @@ jobs:
echo "Starting file transfer with rsync..."
# 启动后台传输(使用 --info=progress2 只显示总体进度,减少输出)
sshpass -p "${{ secrets.PASS_PI }}" rsync -avz --info=progress2 \
sshpass -p "${{ secrets.PASS43 }}" rsync -avz --info=progress2 \
-e "ssh -o StrictHostKeyChecking=no" \
frontend/production/ \
pi@${{ secrets.HOST_PI }}:/home/pi/lm-www/frontend_tmp/production/ > /tmp/rsync.log 2>&1 &
root@${{ secrets.HOST43 }}:/home/pi/lm-www/frontend_tmp/production/ > /tmp/rsync.log 2>&1 &
RSYNC_PID=$!
# 每10秒显示一次进度
@@ -118,8 +124,8 @@ jobs:
while kill -0 $RSYNC_PID 2>/dev/null; do
sleep 10
# 通过SSH检查目标目录大小
TRANSFERRED=$(sshpass -p "${{ secrets.PASS_PI }}" ssh -o StrictHostKeyChecking=no \
pi@${{ secrets.HOST_PI }} \
TRANSFERRED=$(sshpass -p "${{ secrets.PASS43 }}" ssh -o StrictHostKeyChecking=no \
root@${{ secrets.HOST43 }} \
"du -sb /home/pi/lm-www/frontend_tmp/production 2>/dev/null | cut -f1" || echo "0")
if [ "$TRANSFERRED" != "0" ] && [ "$TOTAL_SIZE" != "0" ]; then
@@ -153,9 +159,9 @@ jobs:
- name: Deploy to production
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
host: ${{ secrets.HOST43 }}
username: root
password: ${{ secrets.PASS43 }}
port: 22
script: |
set -e
@@ -171,7 +177,8 @@ jobs:
sudo rm -rf /home/pi/lm-www/frontend_tmp
echo "Setting permissions..."
sudo chown -R www-data:www-data /home/pi/lm-www/frontend/
# 将前端目录权限授予 nginx 运行用户(OpenCloudOS 上通常为 nginx)
sudo chown -R nginx:nginx /home/pi/lm-www/frontend/
sudo chmod -R 644 /home/pi/lm-www/frontend/
sudo find /home/pi/lm-www/frontend/ -type d -exec chmod 755 {} \;
@@ -180,6 +187,14 @@ jobs:
ls -la /home/pi/lm-www/frontend/production/ || echo "Warning: production directory not found"
echo "Reloading nginx..."
sudo systemctl reload nginx
# 与服务器手动操作保持一致:先测试配置,再重载
if command -v nginx >/dev/null 2>&1; then
echo "Testing nginx config (nginx -t)..."
sudo nginx -t
echo "Reloading nginx (nginx -s reload)..."
sudo nginx -s reload
else
echo "⚠️ 未找到 nginx 命令,请确认 nginx 是否已安装并在 PATH 中"
fi
echo "✅ Deployment completed!"
-96
View File
@@ -1,96 +0,0 @@
name: Deploy Frontend
on:
# push:
# branches: [ main ]
# paths:
# - 'frontend/**'
# - 'Dockerfile.frontend'
# - 'nginx.conf'
# - '.github/workflows/deploy_frontend.yml'
workflow_dispatch:
env:
IMAGE_NAME: license-manager-frontend
CONTAINER_NAME: license-manager-frontend
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
run: |
docker build -f Dockerfile.frontend --build-arg NGINX_CONF=nginx-ci.conf -t ${{ env.IMAGE_NAME }}:latest .
docker save ${{ env.IMAGE_NAME }}:latest > frontend-image.tar
- name: Copy files to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
port: 22
source: "frontend-image.tar"
target: "/home/pi/license-manager/"
- name: Deploy to server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
port: 22
script: |
cd /home/pi/license-manager
# Stop and remove existing container
docker stop ${{ env.CONTAINER_NAME }} || true
docker rm ${{ env.CONTAINER_NAME }} || true
# Load new image
docker load < frontend-image.tar
# Remove old image
docker rmi ${{ env.IMAGE_NAME }}:old || true
docker tag ${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:old || true
# Run new container
docker run -d \
--name ${{ env.CONTAINER_NAME }} \
--restart unless-stopped \
-p 18080:80 \
--add-host host.docker.internal:host-gateway \
${{ env.IMAGE_NAME }}:latest
# Clean up
rm -f frontend-image.tar
docker image prune -f
# Check deployment status
sleep 5
if docker ps | grep -q ${{ env.CONTAINER_NAME }}; then
echo "✅ Frontend deployment successful! Container is running."
docker logs --tail 10 ${{ env.CONTAINER_NAME }}
else
echo "❌ Frontend deployment failed! Container is not running."
docker logs ${{ env.CONTAINER_NAME }}
exit 1
fi
- name: Health check
run: |
sleep 10
response=$(curl -s -o /dev/null -w "%{http_code}" http://${{ secrets.HOST_PI }}:18080/ || echo "000")
if [ "$response" = "200" ]; then
echo "✅ Frontend health check passed! Service is responding."
else
echo "⚠️ Frontend health check returned status: $response"
echo "Service may still be starting up..."
fi
+3 -2
View File
@@ -38,12 +38,13 @@ jobs:
restore-keys: |
${{ runner.os }}-go-
- name: Run tests
- name: Build backend
run: |
cd backend
go mod tidy
go mod verify
go test -v ./...
# Skip tests in CI/CD to avoid external API dependencies
echo "Skipping tests in CI/CD environment..."
go build -ldflags="-s -w" -o license-manager cmd/main.go
- name: Set up Docker Buildx
+2 -1
View File
@@ -1,6 +1,7 @@
# Backend only Dockerfile
FROM golang:1.23-alpine AS builder
WORKDIR /app/backend
ENV GOPROXY=https://goproxy.cn,direct
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
@@ -14,4 +15,4 @@ COPY backend/configs/ ../configs/
# Use production config by default
RUN ln -sf ../configs/config.prod.yaml config.yaml
EXPOSE 18888
CMD ["./license-manager"]
CMD ["./license-manager"]
+29 -5
View File
@@ -1,4 +1,4 @@
# 软件授权管理平台
# 软件授权管理系统
<p align="center">
<a href="https://github.com/cedar-v/license-manager/releases">
@@ -24,24 +24,25 @@
## 项目概述
软件授权管理平台是一个独立的软件授权管理系统,为软件系统提供授权码生成、分发、验证和管理服务。支持在线和离线两种授权模式,基于硬件绑定的授权机制确保安全性。
软件授权管理系统是一个完整的软件商业化解决方案,集成了软件授权管理、用户自助服务和商业化运营能力。支持B2B企业客户和B2C个人用户的双轨运营模式,提供从注册购买到授权激活的全流程数字化服务。采用在线/离线混合授权模式,结合硬件绑定和RSA数字签名确保安全性。
✨同时支持**任何开发语言**、**任何类型的软件产品**通过标准化 **HTTP API** 与**跨平台客户端 SDK** **快速集成授权能力**。
## Demo环境
<a href="https://lm.cedar-v.com:18080">
<a href="http://lm.cedar-v.com">
<img src="https://img.shields.io/badge/Demo-Online-success?logo=googlechrome" alt="Live Demo">
</a>
访问地址:[lm.cedar-v.com:18080](http://lm.cedar-v.com:18080)
访问地址:[lm.cedar-v.com](http://lm.cedar-v.com)
**登录凭证:**
- 用户名:`admin`
- 密码:`admin@123`
> 💡 这是一个当前功能的演示环境,您可以体验已开发的核心特性。
## 官方文档
## 官方
官方网站请访问:[https://www.cedar-v.com](https://www.cedar-v.com)
官方文档请访问:[https://docs.cedar-v.com](https://docs.lm.cedar-v.com)
## Star收藏与关注
@@ -79,6 +80,28 @@
客户管理模块提供完整的客户信息管理功能,包括客户信息的增删改查、状态管理、授权关联等核心功能。
## 用户端自助服务
平台提供完整的用户端自助服务,支持 **B2B+B2C双轨运营**:
### B2B企业客户服务
- **企业用户注册**:支持手机号注册,企业管理员可管理成员账户
- **产品套餐购买**:提供试用版、基础版、专业版三种套餐,支持批量许可购买
- **授权码管理**:在线购买授权码,支持离线激活和授权码分享
- **设备管理**:企业用户可查看和管理所有激活设备,支持设备解绑
- **订单管理**:完整的订单历史查询和状态跟踪
### B2C个人客户服务
- **个人用户注册**:手机号快速注册,简化的个人账户管理
- **灵活购买**:支持单个或少量许可购买,满足个人用户需求
- **便捷激活**:支持在线激活和离线激活两种模式
- **设备监控**:个人用户可实时查看设备状态和使用情况
### 统一支付体验
- **支付宝集成**:支持支付宝扫码支付,安全可靠的支付体验
- **支付状态跟踪**:实时支付状态查询和回调通知
- **多环境支持**:沙箱环境测试和生产环境切换
## 核心功能
- 🔧 **客户管理**:完整的客户信息管理和状态控制
@@ -88,6 +111,7 @@
- 🌐 **API服务**:提供验证、激活、心跳监控等RESTful API
- ⚙️ **系统管理**:管理员认证和监控仪表盘
- 🛠️ **跨平台工具**:多平台硬件信息获取工具
- 👥 **用户端自助服务**:B2B+B2C双轨运营,支持企业客户和个人用户自助操作
## 技术栈
+24 -1
View File
@@ -24,7 +24,7 @@
## Overview
License Manager is an independent software licensing platform that provides license code generation, distribution, validation, and management services for IoT platforms and other software systems. It offers both online and offline licensing modes with hardware-based binding for enhanced security.
License Manager is a comprehensive software commercialization solution that integrates software licensing management, self-service user portals, and commercial operation capabilities. It supports dual-track operations for both B2B enterprise customers and B2C individual users, providing full-process digital services from registration and purchase to license activation. It employs a hybrid online/offline licensing model combined with hardware binding and RSA digital signatures to ensure security.
✨It enables **any programming language** and **any type of software product** to **quickly integrate licensing capabilities** via standardized **HTTP APIs** and **cross-platform client SDKs**.
@@ -71,6 +71,28 @@ Client SDK repository: [https://github.com/cedar-v/license-manager-sdk](https://
The customer management module provides comprehensive customer information management features, including CRUD operations for customer data, status management, and authorization associations.
## Self-Service User Portal
The platform provides comprehensive self-service user portals supporting **dual-track operations for B2B+B2C**:
### B2B Enterprise Customer Services
- **Enterprise User Registration**: Mobile phone registration with enterprise admin managing member accounts
- **Product Package Purchase**: Three package types (Trial, Basic, Professional) with bulk license purchasing
- **License Code Management**: Online license purchase with offline activation and license sharing
- **Device Management**: Enterprise users can view and manage all activated devices with device unbinding
- **Order Management**: Complete order history queries and status tracking
### B2C Individual Customer Services
- **Individual User Registration**: Quick mobile phone registration with simplified personal account management
- **Flexible Purchasing**: Support for single or small quantity license purchases meeting individual user needs
- **Convenient Activation**: Support for both online activation and offline activation modes
- **Device Monitoring**: Individual users can monitor device status and usage in real-time
### Unified Payment Experience
- **Alipay Integration**: Support for Alipay QR code payment with secure and reliable payment experience
- **Payment Status Tracking**: Real-time payment status queries and callback notifications
- **Multi-environment Support**: Sandbox environment testing and production environment switching
## Key Features
- 🔧 **Customer Management**: Complete customer information management with status control
@@ -80,6 +102,7 @@ The customer management module provides comprehensive customer information manag
- 🌐 **API Services**: RESTful APIs for validation, activation, and heartbeat monitoring
- ⚙️ **System Management**: Admin authentication and monitoring dashboard
- 🛠️ **Cross-platform Tools**: Hardware information extraction tools for multiple platforms
- 👥 **Self-Service User Portal**: Dual-track B2B+B2C operations supporting enterprise customers and individual users with self-service operations
## Technical Stack
+14 -4
View File
@@ -1,6 +1,7 @@
package main
import (
"flag"
"fmt"
"log"
"os"
@@ -17,7 +18,7 @@ import (
// @title License Manager API
// @version 1.0
// @description 软件授权管理平台API文档
// @description 软件授权管理系统API文档
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
@@ -36,10 +37,19 @@ import (
// @description Bearer token for authentication
func main() {
// 定义命令行参数
var customPath string
flag.StringVar(&customPath, "config", "", "path to configuration file")
flag.StringVar(&customPath, "c", "", "path to configuration file (shorthand)")
flag.Parse()
// 加载配置
configPath := "config.yaml" // Docker 环境默认路径
if _, err := os.Stat(configPath); os.IsNotExist(err) {
configPath = "../configs/config.yaml" // 本地开发环境
configPath := customPath
if configPath == "" {
configPath = "config.yaml" // Docker 环境默认路径
if _, err := os.Stat(configPath); os.IsNotExist(err) {
configPath = "../configs/config.yaml" // 本地开发环境
}
}
if err := config.Load(configPath); err != nil {
+4 -4
View File
@@ -5,11 +5,11 @@ server:
database:
driver: "mysql"
host: "mysql"
host: "203.2.164.31"
port: 3306
username: "license_user"
password: "license@123"
database: "license_manager"
username: "license"
password: "8X3Yd8hQ4WMPw227"
database: "license"
charset: "utf8mb4"
parse_time: true
loc: "Local"
+22 -1
View File
@@ -73,6 +73,7 @@ errors:
"500015": "Verification code does not exist"
"500016": "SMS sending too frequent, please try again later"
"500017": "SMS sending failed"
"500018": "Account locked, please try again later"
# Package order module (60xxxx)
cu_package:
@@ -86,6 +87,8 @@ errors:
cu_order:
"601001": "Order does not exist"
"601005": "License quantity exceeds limit"
"601006": "Order has been paid and cannot be cancelled"
"601007": "Order status does not allow continue payment"
# System general (90xxxx)
system:
@@ -94,6 +97,14 @@ errors:
"900003": "Resource conflict"
"900004": "Internal server error"
# Invoice module (70xxxx)
invoice:
"700001": "Invoice not found"
"700002": "Order not found or access denied"
"700003": "Invoice already exists for this order"
"700004": "Invoice status does not allow this operation"
"700005": "Invoice file not found"
# Common text
common:
success: "Success"
@@ -159,5 +170,15 @@ enums:
"unlock": "Unlock"
"other": "Other"
invoice_status:
"pending": "Pending"
"issued": "Issued"
"rejected": "Rejected"
invoice_type:
"personal": "Personal"
"enterprise": "Enterprise"
"vat_special": "VAT Special Invoice"
# Default error message
default_error: "Unknown error"
default_error: "Unknown error"
+4 -1
View File
@@ -73,6 +73,7 @@ errors:
"500015": "認証コードが存在しません"
"500016": "SMS送信が頻繁すぎます。しばらくしてからお試しください"
"500017": "SMS送信に失敗しました"
"500018": "アカウントがロックされています。しばらくしてからお試しください"
# セット料金モジュール (60xxxx)
cu_package:
@@ -86,6 +87,8 @@ errors:
cu_order:
"601001": "注文が存在しません"
"601005": "ライセンス数量が制限を超えています"
"601006": "支払い済みの注文はキャンセルできません"
"601007": "注文の状態により、支払いを続けることができません"
# システム共通 (90xxxx)
system:
@@ -160,4 +163,4 @@ enums:
"other": "その他"
# デフォルトエラーメッセージ
default_error: "不明なエラー"
default_error: "不明なエラー"
+22 -1
View File
@@ -73,6 +73,7 @@ errors:
"500015": "验证码不存在"
"500016": "短信发送过于频繁,请稍后再试"
"500017": "短信发送失败"
"500018": "账号已被锁定,请稍后再试"
# 套餐订单模块 (60xxxx)
cu_package:
@@ -86,11 +87,21 @@ errors:
cu_order:
"601001": "订单不存在"
"601005": "许可数量超出限制"
"601006": "订单已支付,无法取消"
"601007": "订单状态不允许继续支付"
# 设备管理模块 (62xxxx)
cu_device:
"620001": "设备不存在或无权限访问"
# 发票模块 (70xxxx)
invoice:
"700001": "发票不存在"
"700002": "订单不存在或无权限访问"
"700003": "该订单已存在发票申请"
"700004": "发票状态不允许操作"
"700005": "发票文件不存在"
# 系统通用 (90xxxx)
system:
"900001": "请求参数无效"
@@ -163,5 +174,15 @@ enums:
"unlock": "解锁"
"other": "其他"
invoice_status:
"pending": "待处理"
"issued": "已开票"
"rejected": "已驳回"
invoice_type:
"personal": "个人"
"enterprise": "企业普票"
"vat_special": "增值税专用发票"
# 默认错误信息
default_error: "未知错误"
default_error: "未知错误"
+37 -12
View File
@@ -5,12 +5,14 @@
Web框架: Gin
ORM: GORM,开发环境使用Auto Migration
配置: Viper
日志: logrus
日志: logrus (封装为logger包)
认证: JWT (支持管理员和C端用户双JWT体系)
缓存: Redis (可选)
数据库: PostgreSQL/MySQL
缓存: Redis/内存缓存 (支持切换)
数据库: MySQL (推荐)/PostgreSQL
支付集成: 支付宝
短信服务: 阿里云SMS
加密算法: RSA
国际化: 支持多语言错误信息和枚举显示
## 目录结构
@@ -45,9 +47,10 @@ backend/
│ │ │ ├── cu_auth_handler.go
│ │ │ ├── cu_authorization_handler.go
│ │ │ ├── cu_device_handler.go
│ │ │ ├── cu_invoice_handler.go # 客户端发票处理器
│ │ │ ├── cu_order_handler.go
│ │ │ ├── cu_order_handler_test.go
│ │ │ └── cu_profile_handler.go
│ │ │ ├── cu_profile_handler.go
│ │ │ └── invoice_handler.go # 管理端发票处理器
│ │ ├── middleware/ # 中间件
│ │ │ ├── auth.go
│ │ │ ├── cors.go
@@ -64,31 +67,41 @@ backend/
│ │ ├── license_service.go
│ │ ├── payment_service.go
│ │ ├── system_service.go
│ │ ├── admin_invoice_service.go # 管理端发票服务
│ │ ├── cu_device_service.go
│ │ ├── cu_invoice_service.go # 客户端发票服务
│ │ ├── cu_order_filters.go # 订单过滤器
│ │ ├── cu_order_filters_test.go # 订单过滤器测试
│ │ ├── cu_order_service.go
│ │ ├── cu_user_service.go
│ │ └── interfaces.go # 接口定义
│ ├── repository/ # 数据访问层
│ │ ├── admin_invoice_repository.go # 管理端发票仓储
│ │ ├── authorization_code_repository.go
│ │ ├── cu_invoice_repository.go # 客户端发票仓储
│ │ ├── cu_order_repository.go
│ │ ├── cu_user_repository.go
│ │ ├── customer_repository.go
│ │ ├── dashboard_repository.go
│ │ ├── license_repository.go
│ │ ├── payment_repository.go
│ │ ├── user_repository.go
│ │ ├── cu_order_repository.go
│ │ ├── cu_user_repository.go
│ │ ├── gorm/ # GORM相关扩展
│ │ ├── errors.go
│ │ └── interfaces.go
│ ├── models/ # 数据模型
│ │ ├── admin_invoice_models.go # 管理端发票模型
│ │ ├── auth.go
│ │ ├── common.go
│ │ ├── customer.go
│ │ ├── cu_authorization.go
│ │ ├── cu_invoice_models.go # 客户端发票模型
│ │ ├── cu_order.go
│ │ ├── cu_user.go
│ │ ├── customer.go
│ │ ├── dashboard.go
│ │ ├── license.go
│ │ ├── payment.go
│ │ ├── shared_invoice_models.go # 共享发票模型
│ │ ├── system.go
│ │ └── user.go
│ ├── config/ # 配置管理
@@ -97,17 +110,25 @@ backend/
│ ├── connection.go
│ └── migration.go
├── pkg/
│ ├── cache/ # 缓存系统
│ │ ├── cache.go
│ │ ├── factory.go
│ │ ├── memory.go
│ │ ├── redis.go
│ │ └── key_builder.go
│ ├── context/ # 上下文封装
│ │ └── context.go
│ ├── i18n/ # 国际化
│ │ ├── errors.go
│ │ └── manager.go
│ │ ├── manager.go
│ │ └── messages/
│ ├── utils/ # 工具函数
│ │ ├── alipay.go # 支付宝支付集成
│ │ ├── authorization_code.go # 授权码生成
│ │ ├── crypto.go # 加密工具
│ │ ├── cu_jwt.go # C端用户JWT工具
│ │ ├── jwt.go # JWT工具
│ │ └── authorization_code.go # 授权码生成
│ │ └── sms.go # 短信服务
│ └── logger/ # 日志封装
│ └── logger.go
├── configs/
@@ -153,6 +174,7 @@ backend/
│ ├── 009_update_authorization_codes_code_length.sql
│ ├── 010_update_cu_orders_authorization_code_length.sql
│ ├── 011_create_payments_table.sql
│ ├── 012_create_invoices_table.sql
│ └── README.md
├── go.mod
└── go.sum
@@ -162,8 +184,11 @@ backend/
- **分层架构**:Handler → Service → Repository
- **依赖注入**:通过接口解耦各层
- **配置驱动**:Viper管理所有配置
- **缓存抽象**:支持内存/Redis切换
- **缓存抽象**:支持内存/Redis切换,带过期时间和键构建器
- **双JWT体系**:支持管理员和C端用户独立认证体系
- **客户端/管理端分离**:采用cu_*前缀严格分离不同用户角色的业务逻辑
- **模块化设计**:支持客户管理、订单管理、支付集成等独立模块
- **国际化支持**:完整的多语言错误信息系统
- **国际化支持**:完整的多语言错误信息和枚举显示系统
- **软删除支持**:所有数据表支持gorm.DeletedAt软删除
- **SQL迁移**:复杂的索引和约束通过SQL文件管理,保证生产环境一致性
@@ -0,0 +1,16 @@
# 令牌刷新
目前系统有一个超管端和一个用户端,需要对两个端进行令牌刷新机制的改造。
Token过期与刷新机制
Token刷新是为了在保证安全性的同时提升用户体验,避免频繁登录。核心是使用短期访问令牌(Access Token) + 长期刷新令牌(Refresh Token) 的双令牌策略。
基本流程
1. 用户登录
用户提交账号密码 → 后端验证 → 返回两个token:
- Access Token (短期,如15分钟-2小时)
- Refresh Token (长期,如7-30天)
2. 日常请求
前端携带Access Token → 后端验证 → 返回数据
3. Token过期处理
Access Token过期 → 请求失败(401) → 前端自动用Refresh Token换新的Access Token → 重试原请求
@@ -0,0 +1,33 @@
# 管理端企业线索汇总接口设计
## 接口路径
`GET /api/leads/summary`
## 请求
不需要任何参数。
## 鉴权
需要管理端权限(与线索列表/详情接口一致)。
## 响应示例
```json
{
"code": "000000",
"message": "success",
"data": {
"total_count": 1200,
"pending_count": 300,
"following_count": 520,
"converted_count": 260,
"lost_count": 120
},
"timestamp": 1738454400
}
```
## 字段说明
- total_count:线索总数
- pending_count:待跟进数量
- following_count:已跟进数量
- converted_count:已成交数量
- lost_count:已流失数量
@@ -0,0 +1,45 @@
# 订单号查询增加筛选
改造接口:`GET /api/cu/orders`
## 背景
当前订单列表仅支持分页查询,需要增加筛选能力以提升定位订单效率。
## 目标
在现有订单列表接口基础上,增加 3 个可选筛选字段:`search`、`status`、`time`,并保持向后兼容(不传筛选参数时行为不变)。
## 接口定义
### 请求
`GET /api/cu/orders`
#### Query 参数
- `page`:页码,默认 `1`
- `page_size`:每页数量,默认 `10`,最大 `100`
- `search`:模糊匹配关键字(订单号 `order_no` 或订单授权码 `authorization_code`)
- `status`:订单状态筛选(等值匹配)
- 目前系统内常见值:`pending` / `paid` / `cancelled`
- `time`:创建时间筛选(按订单 `created_at`)
- 支持值(推荐):`today` / `week` / `month` / `three_months`
- 支持中文别名:`今天` / `本周` / `本月` / `近三个月`
#### 过滤规则
- `search`:`order_no LIKE %search% OR authorization_code LIKE %search%`
- `status`:`status = ?`
- `time`:对 `created_at` 增加范围过滤(左闭右开:`>= start AND < end`)
- `today/今天`:当天 00:00:00 ~ 次日 00:00:00
- `week/本周`:以周一为一周起始
- `month/本月`:当月 1 日 00:00:00 ~ 下月 1 日 00:00:00
- `three_months/近三个月`:从当前时间往前推 3 个月(按日期对齐到 00:00:00)~ 次日 00:00:00
### 响应
保持现有返回结构不变:`models.CuOrderListResponse`
## 示例
- 订单号/授权码模糊搜索:
- `GET /api/cu/orders?page=1&page_size=10&search=ORD20260118`
- 按状态筛选:
- `GET /api/cu/orders?status=pending`
- 按时间筛选(近三个月):
- `GET /api/cu/orders?time=three_months`
- 组合筛选:
- `GET /api/cu/orders?search=ORD&status=paid&time=month`
@@ -0,0 +1,95 @@
# 取消订单接口
## 背景
当前客户侧订单支持创建、查询与列表查看(`/api/cu/orders`),但缺少“取消未支付订单”的能力;用户误下单/重复下单时只能等待支付超时,体验较差。
## 目标
- 客户用户可取消“未支付(`pending`)”订单。
- 已支付(`paid`)订单不允许取消,返回明确的业务错误码与错误信息。
- 返回结构与现有接口保持一致:成功返回 `models.APIResponse`,失败返回 `models.ErrorResponse`。
## 参考接口(已实现)
为保持风格一致,本接口沿用以下已实现接口的路径风格、鉴权方式与返回结构:
- `POST /api/cu/orders`:创建订单
- `GET /api/cu/orders/{order_id}`:订单详情
- `GET /api/cu/orders`:订单列表(含 `status` 筛选)
## 业务规则(与当前项目对齐)
> 说明:当前 `cu_orders` 表包含 `deleted_at`(GORM 软删除),但订单列表接口按 `status` 做筛选;为了支持“已取消订单可追溯/可筛选”,本设计采用**状态取消**(`status=cancelled`)而不是直接删除记录。
- 允许取消:订单属于当前登录客户用户,且订单状态为 `pending`。
- 不允许取消:订单状态为 `paid`(包含试用/免费单,当前实现中免费单直接为 `paid`)。
- 幂等性:重复取消同一订单(已为 `cancelled`)应返回成功(不重复变更),避免前端重复点击导致报错。
## 接口定义
### 请求
```http
PUT /api/cu/orders/{order_id}/cancel
```
#### Headers
- `Authorization: Bearer <token>`(必填)
- `Accept-Language`(可选)或 query `?lang=zh-CN`(可选):与现有多语言中间件一致
#### Path 参数
- `order_id`:订单ID(必填,字符串,UUID)
#### Body
无
### 响应
#### 成功(200)
返回取消后的订单信息(与 `GET /api/cu/orders/{order_id}` 一致):
```json
{
"code": "000000",
"message": "成功",
"data": {
"id": "uuid-string",
"order_no": "ORD202601210001",
"customer_id": "customer-uuid",
"cu_user_id": "cu-user-uuid",
"package_id": "basic",
"package_name": "基础版",
"license_count": 10,
"unit_price": 300,
"discount_rate": 1,
"total_amount": 3000,
"status": "cancelled",
"authorization_code": null,
"expired_at": null,
"created_at": "2026-01-21T10:00:00Z",
"updated_at": "2026-01-21T10:05:00Z"
},
"timestamp": "2026-01-21T10:05:00Z"
}
```
#### 失败
失败时返回 `models.ErrorResponse`:
```json
{
"code": "601006",
"message": "订单已支付,无法取消",
"timestamp": "2026-01-21T10:05:00Z"
}
```
## 状态约定
- `pending`:待支付(允许取消)
- `paid`:已支付(不允许取消)
- `cancelled`:已取消(重复取消返回成功)
## 错误码(建议新增/复用)
- `100004`:缺少认证信息(未登录)
- `100005`:权限不足(只能取消自己的订单)
- `601001`:订单不存在
- `601006`:订单已支付,无法取消(建议新增到 `backend/configs/i18n/errors/*` 的 `cu_order` 下)
- `900001`:请求参数无效(如 `order_id` 为空)
- `900004`:服务器内部错误
## 示例
- 取消订单:
- `PUT /api/cu/orders/7b8c2c2e-9f2f-4f0b-9f31-0c8d3f6a9c11/cancel`
@@ -0,0 +1,123 @@
# 用户授权管理相关接口
> 分组建议:放到“用户端授权码管理”(Customer User / CU)分组,统一前缀:`/api/cu`
## 通用约定
- 鉴权:`Authorization: Bearer <token>`(必填)
- 多语言:`Accept-Language` header 或 `?lang=zh-CN`(可选)
- 成功返回:`models.APIResponse`
- 失败返回:`models.ErrorResponse`
## 参考接口(已实现)
为保持接口风格一致(路径、鉴权、返回结构),本设计参考并沿用以下已实现接口:
- `POST /api/cu/authorization-codes/{codeId}/share`:用户分享授权码
- `POST /api/cu/authorization-codes/product-activation-code`:获取产品激活码
- `GET /api/v1/authorization-codes`:授权码列表(管理端,分页结构可复用)
## 状态约定
授权码状态沿用系统枚举:
- `normal`:有效(当前时间未超过 `end_date` 且未锁定)
- `expired`:已过期(当前时间超过 `end_date`)
- `locked`:已锁定(`is_locked=true`;是否过期由 `end_date` 决定)
> 前端展示“有效/已过期”时,可将 `locked` 作为“有效但锁定”或单独状态展示(以 UI 为准)。
## 接口定义
### 1) 获取用户授权码列表(新增)
```http
GET /api/cu/authorization-codes
```
#### Query 参数
- `page`:页码,默认 `1`
- `page_size`:每页数量,默认 `10`,最大 `100`
- `status`:状态筛选(可选)`normal` / `expired` / `locked`
- `search`:授权码模糊匹配(可选,匹配 `code`)
#### 响应(200)
```json
{
"code": "000000",
"message": "成功",
"data": {
"list": [
{
"id": "uuid-string",
"code": "LIC-COMP001-A7B9X2-C8F4",
"status": "normal",
"status_display": "正常",
"max_activations": 10,
"current_activations": 7,
"remaining_activations": 3,
"created_at": "2026-01-21T10:00:00Z",
"end_date": "2026-12-31T23:59:59Z"
}
],
"total": 1,
"page": 1,
"page_size": 10,
"total_pages": 1
},
"timestamp": "2026-01-21T10:05:00Z"
}
```
#### 字段说明
- `max_activations`:设备总数(可绑定/可激活上限)
- `current_activations`:已绑定设备数(已激活许可证数量)
- `remaining_activations`:剩余可绑定设备数(建议字段;也可由前端计算 `max_activations - current_activations`)
- `end_date`:到期时间(授权结束时间)
#### 错误码
- `100004`:缺少认证信息
- `900001`:请求参数无效
- `900004`:服务器内部错误
---
### 2) 用户授权信息统计(新增)
```http
GET /api/cu/authorization-codes/summary
```
#### 响应(200)
```json
{
"code": "000000",
"message": "成功",
"data": {
"total_count": 12,
"expired_count": 3,
"valid_count": 9,
"valid_max_activations_sum": 150
},
"timestamp": "2026-01-21T10:05:00Z"
}
```
#### 字段说明
- `total_count`:总授权码数量
- `expired_count`:已过期授权码数量
- `valid_count`:有效授权码数量(建议口径:`status=normal`,是否包含 `locked` 由产品定义;若包含请在实现中统一)
- `valid_max_activations_sum`:有效授权码的 `max_activations` 之和
#### 错误码
- `100004`:缺少认证信息
- `900004`:服务器内部错误
---
### 3) 用户分享授权码(已实现)
```http
POST /api/cu/authorization-codes/{codeId}/share
```
> 说明:接口实现与 Swagger 注释已存在于 `backend/internal/api/handlers/cu_authorization_handler.go`。
---
### 4) 获取产品激活码(已实现)
```http
POST /api/cu/authorization-codes/product-activation-code
```
> 说明:接口实现与 Swagger 注释已存在于 `backend/internal/api/handlers/cu_authorization_handler.go`。
@@ -0,0 +1,12 @@
# 管理端用户管理相关接口设计
这两接口都属于管理端接口 不属于用户端接口
## 用户列表分页查询
系统管理员可以查看用户列表,并进行分页查询。
## 进入用户端
系统管理员可以以直接登录的方式进入用户端
接口和用户登录接口一样可以返回token
@@ -0,0 +1,145 @@
# 套餐管理需求
管理员端可以配置套餐,用户端从数据库查询套餐列表。
## 表设计
```sql
-- 套餐表
CREATE TABLE packages (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
name VARCHAR(100) NOT NULL COMMENT '套餐名称',
type VARCHAR(20) NOT NULL COMMENT '套餐类型: trial-试用版, basic-基础版, professional-专业版, custom-定制版',
price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
price_description VARCHAR(100) DEFAULT '' COMMENT '价格描述,如"定制报价"',
duration_description VARCHAR(200) DEFAULT '' COMMENT '期限描述,如"永久有效"、"当月25日到期"',
description VARCHAR(500) DEFAULT '' COMMENT '套餐说明',
features TEXT DEFAULT '' COMMENT '功能项,JSON格式: ["功能1", "功能2"]',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 1-启用, 0-禁用',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序,数字越大越靠前',
remark VARCHAR(500) DEFAULT '' COMMENT '备注',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME NULL,
INDEX idx_packages_type (type),
INDEX idx_packages_status (status),
INDEX idx_packages_sort_order (sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品套餐表';
```
## 接口设计
### 管理端接口
#### 1. 获取套餐列表
```
GET /api/packages
```
**请求参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| type | string | 否 | 套餐类型筛选 |
| status | int | 否 | 状态筛选,1-启用,0-禁用 |
**响应:**
```json
{
"code": "000000",
"data": [
{
"id": "uuid",
"name": "试用版",
"type": "trial",
"price": 0,
"price_description": "免费",
"duration_description": "当月25日到期",
"description": "免费体验全部功能",
"features": ["全部功能", "1个许可"],
"status": 1,
"sort_order": 1
}
]
}
```
#### 2. 创建套餐
```
POST /api/packages
```
**请求体:**
```json
{
"name": "试用版",
"type": "trial",
"price": 0,
"price_description": "免费",
"duration_description": "当月25日到期",
"description": "免费体验全部功能",
"features": ["全部功能", "1个许可"],
"status": 1,
"sort_order": 1,
"remark": ""
}
```
#### 3. 更新套餐
```
PUT /api/packages/{id}
```
**请求体:** 同创建接口
#### 4. 删除套餐
```
DELETE /api/packages/{id}
```
### 用户端接口
#### 获取套餐列表
```
GET /api/cu/packages
```
**说明:** 只返回启用状态的套餐,按 sort_order 排序,保持原有返回结构不变
**响应:** 与原有接口一致
```json
{
"code": "000000",
"data": [
{
"id": "trial",
"name": "试用版",
"type": "trial",
"price": 0,
"max_devices": 1,
"description": "免费体验全部功能",
"features": ["全部功能", "1个许可"],
"details": "包含全部功能|试用期限制|1个许可等"
}
]
}
```
## 套餐类型
| 类型 | 名称 | 说明 |
|------|------|------|
| trial | 试用版 | 免费,当月25日到期 |
| basic | 基础版 | 付费,永久有效 |
| professional | 专业版 | 付费,永久有效,全部功能 |
| custom | 定制版 | 定制报价,按需配置 |
## 实现文件
### 新建文件
- `backend/internal/models/package.go` - 套餐模型
- `backend/internal/repository/package_repository.go` - 套餐仓储
- `backend/internal/service/package_service.go` - 套餐服务
- `backend/internal/api/handlers/package_handler.go` - 管理端套餐处理器
- `backend/migrations/013_create_packages_table.sql` - 数据库迁移
### 修改文件
- `backend/internal/api/routes/router.go` - 添加路由
- `backend/internal/api/handlers/cu_order_handler.go` - 修改 GetPackages 从数据库查询
- `backend/internal/service/cu_order_service.go` - 修改 CalculatePrice 支持数据库套餐
@@ -0,0 +1,210 @@
# 企业线索收集
从各个渠道收集用户线索,并进行跟进和转化。
## 表设计
```sql
-- 企业线索表
CREATE TABLE leads (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
lead_no VARCHAR(50) NOT NULL COMMENT '线索编号,系统生成',
company_name VARCHAR(200) NOT NULL COMMENT '企业/机构名称',
contact_name VARCHAR(100) NOT NULL COMMENT '联系人',
contact_phone VARCHAR(20) NOT NULL COMMENT '联系电话',
contact_email VARCHAR(100) COMMENT '邮箱地址',
requirement TEXT NOT NULL COMMENT '需求描述',
extra_info TEXT COMMENT '补充信息',
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending-待联系, contacted-已联系, converted-已成交, invalid-已失效',
follow_up_date DATETIME COMMENT '跟进日期',
follow_up_record TEXT COMMENT '跟进记录',
internal_note TEXT COMMENT '内部备注',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_leads_status (status),
INDEX idx_leads_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业线索表';
```
## 字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | VARCHAR(36) | 是 | 主键,UUID |
| lead_no | VARCHAR(50) | 是 | 线索编号,系统生成,格式:LEAD + 时间戳 + 6位随机数 |
| company_name | VARCHAR(200) | 是 | 企业/机构名称 |
| contact_name | VARCHAR(100) | 是 | 联系人姓名 |
| contact_phone | VARCHAR(20) | 是 | 联系电话 |
| contact_email | VARCHAR(100) | 否 | 邮箱地址 |
| requirement | TEXT | 是 | 需求描述 |
| extra_info | TEXT | 否 | 补充信息 |
| status | VARCHAR(20) | 是 | 状态:pending-待联系,contacted-已联系,converted-已成交,invalid-已失效 |
| follow_up_date | DATETIME | 否 | 跟进日期 |
| follow_up_record | TEXT | 否 | 跟进记录 |
| internal_note | TEXT | 否 | 内部备注 |
| created_at | DATETIME | 是 | 创建时间 |
| updated_at | DATETIME | 是 | 更新时间 |
## 状态流转
```
pending (待联系) → contacted (已联系) → converted (已成交)
↓
invalid (已失效)
```
## 接口设计
### 1. 新增线索(公开接口)
```
POST /api/leads
```
**无需鉴权**
**请求体:**
```json
{
"company_name": "北京科技有限公司",
"contact_name": "张三",
"contact_phone": "13800138000",
"contact_email": "zhangsan@example.com",
"requirement": "需要采购50套软件授权",
"extra_info": "希望了解批量购买优惠"
}
```
**响应:**
```json
{
"code": "000000",
"data": {
"id": "uuid",
"lead_no": "LEAD20260201123456ABC123",
"company_name": "北京科技有限公司",
"contact_name": "张三",
"contact_phone": "13800138000",
"contact_email": "zhangsan@example.com",
"requirement": "需要采购50套软件授权",
"status": "pending",
"created_at": "2026-02-01T12:00:00Z"
}
}
```
### 2. 线索列表(管理端)
```
GET /api/leads
```
**需要管理员鉴权**
**请求参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | int | 否 | 页码,默认1 |
| page_size | int | 否 | 每页数量,默认10,最大100 |
| search | string | 否 | 关键词检索(企业名称/联系人/联系电话) |
| status | string | 否 | 状态筛选:pending/contacted/converted/invalid |
**响应:**
```json
{
"code": "000000",
"data": {
"leads": [
{
"id": "uuid",
"lead_no": "LEAD20260201123456ABC123",
"company_name": "北京科技有限公司",
"contact_name": "张三",
"contact_phone": "13800138000",
"status": "pending",
"created_at": "2026-02-01T12:00:00Z"
}
],
"total_count": 100,
"page": 1,
"page_size": 10
}
}
```
### 3. 线索详情(管理端)
```
GET /api/leads/{id}
```
**需要管理员鉴权**
**响应:**
```json
{
"code": "000000",
"data": {
"id": "uuid",
"lead_no": "LEAD20260201123456ABC123",
"company_name": "北京科技有限公司",
"contact_name": "张三",
"contact_phone": "13800138000",
"contact_email": "zhangsan@example.com",
"requirement": "需要采购50套软件授权",
"extra_info": "希望了解批量购买优惠",
"status": "contacted",
"follow_up_date": "2026-02-05T10:00:00Z",
"follow_up_record": "已电话沟通,客户表示下周决策",
"internal_note": "重点跟进客户",
"created_at": "2026-02-01T12:00:00Z",
"updated_at": "2026-02-05T10:00:00Z"
}
}
```
### 4. 线索跟进(管理端)
```
PUT /api/leads/{id}
```
**需要管理员鉴权**
**请求体:**
```json
{
"status": "contacted",
"follow_up_date": "2026-02-05T10:00:00Z",
"follow_up_record": "已电话沟通,客户表示下周决策",
"internal_note": "重点跟进客户"
}
```
**响应:**
```json
{
"code": "000000",
"message": "成功"
}
```
### 5. 删除线索(管理端)
```
DELETE /api/leads/{id}
```
**需要管理员鉴权**
**响应:**
```json
{
"code": "000000",
"message": "成功"
}
```
## 实现文件
### 新建文件
- `backend/internal/models/lead.go` - 线索模型
- `backend/internal/repository/lead_repository.go` - 线索仓储
- `backend/internal/service/lead_service.go` - 线索服务
- `backend/internal/api/handlers/lead_handler.go` - 线索处理器
- `backend/migrations/014_create_leads_table.sql` - 数据库迁移
### 修改文件
- `backend/internal/api/routes/router.go` - 添加路由
- `backend/internal/database/migration.go` - 添加自动迁移和初始化
@@ -0,0 +1,26 @@
# 订单列表查询接口修改
## 接口说明
- **接口路径**:`GET /api/cu/orders`
- **修改内容**:在订单列表的每条订单数据中新增一个字段,表示该订单是否已经申请过发票。
## 新增字段
- **字段名**:`has_invoice_applied`
- **类型**:`boolean`
- **含义**:
- `true`:该订单在发票表中已经有发票申请记录
- `false`:该订单还没有发票申请记录
## 判断规则
- 相关表:`invoices`
- 判断条件:
- 在 `invoices` 表中存在 `order_id = cu_orders.id` 且 `deleted_at IS NULL` 的记录,则 `has_invoice_applied = true`
- 否则 `has_invoice_applied = false`
## 前端使用建议
- 当 `has_invoice_applied = true` 时,前端可以隐藏或禁用“申请开票”按钮。
- 当 `has_invoice_applied = false` 时,前端正常展示“申请开票”按钮。
@@ -0,0 +1,80 @@
# 2026.2.24-用户端发票申请修改
## 1. 需求背景
目前用户申请发票后,如果被管理员驳回(状态为 `rejected`),用户无法修改该申请信息并重新提交。由于每个订单只能关联一个发票记录,用户也无法为该订单重新发起新的申请。因此需要增加一个发票修改接口,允许用户在驳回状态下编辑发票信息并重新进入待处理状态。
## 2. 业务流程
1. **用户侧**:查看发票详情,发现状态为“已驳回”,查看“驳回原因”和“修改建议”。
2. **用户侧**:点击修改,编辑发票抬头、税号、邮箱等信息。
3. **提交修改**:调用修改接口。
- 系统校验发票当前状态必须为 `rejected`。
- 系统校验发票所属权。
- 系统更新发票信息,并将状态重置为 `pending`。
- 系统清空原有的驳回信息(可选,或保留历史但在界面不再作为当前驳回提示)。
4. **管理侧**:发票重新出现在待处理列表中。
## 3. 接口设计
### 3.1 修改发票申请 (客户端)
- **接口地址**:`PUT /api/cu/invoices/{id}`
- **鉴权**:需要客户端登录态 (`CustomerAuth`)
- **请求参数 (JSON Body)**:
| 字段名 | 类型 | 必填 | 说明 |
| :--- | :--- | :--- | :--- |
| invoice_type | string | 是 | 发票类型: personal/enterprise/vat_special |
| receiver_email| string | 是 | 收票邮箱 |
| title | string | 是 | 发票抬头 |
| taxpayer_id | string | 否 | 纳税人识别号 (企业类型必填) |
| content | string | 否 | 开票内容 |
| remark | string | 否 | 备注 |
> [!NOTE]
> `order_id` 不允许修改,因为发票记录与订单是一一绑定的。
- **逻辑校验**:
1. **存在性校验**:发票 ID 必须存在。
2. **权限校验**:发票必须属于当前登录的客户用户 (`cu_user_id`)。
3. **状态校验**:只有 `status = 'rejected'` 的发票允许修改。若状态为 `pending` 或 `issued`,则返回错误。
4. **字段校验**:与创建发票时的校验逻辑一致(如企业类型必须有税号)。
- **数据更新**:
- 更新传入的业务字段。
- `status` 更新为 `pending`。
- `updated_at` 更新为当前时间。
- `reject_reason`、`suggestion`、`rejected_at`、`rejected_by` 建议清空,以表示这是一次全新的提交。
### 3.2 响应示例
**成功响应**:
```json
{
"code": "000000",
"message": "成功",
"data": {
"id": "invoice-uuid",
"invoice_no": "INV20260224001",
"status": "pending"
},
"timestamp": "2026-02-24T18:00:00Z"
}
```
**失败响应 (状态错误)**:
```json
{
"code": "700004",
"message": "发票当前状态不允许该操作",
"timestamp": "2026-02-24T18:00:00Z"
}
```
## 4. 影响范围
- **后端模型**:需新增 `InvoiceUpdateRequest` 结构体。
- **后端服务**:`CuInvoiceService` 新增 `UpdateInvoice` 方法。
- **后端接口**:`router.go` 注册新路由。
- **前端**:发票详情页增加修改按钮(仅在驳回时显示),跳转至编辑页。
## 5. 错误码补充
- `700006`: 发票当前状态不允许修改 (如果 700004 语义不够具体可以新增,或沿用 700004)。
+11
View File
@@ -0,0 +1,11 @@
## 本地开发环境
```bash
# 使用默认配置
cd cmd
go run .
# 指定配置文件
cd cmd
go run . -config configs/config.dev.yaml
```
@@ -0,0 +1,414 @@
# 发票模块设计
## 1. 背景与目标
客户购买套餐后(订单已支付),需要在用户端发起开票申请;管理员在管理端处理开票、上传发票 PDF,或驳回并给出修改建议;用户可在列表/详情查看处理进度并下载发票文件。
## 2. 实体关系
- `cu_orders`(订单) : `invoices`(发票) = 1 : 1(一个订单最多一张发票)
约束:
- `invoices.order_id` 唯一,确保 1:1
## 3. 状态与枚举
### 3.1 开票状态(invoice_status)
- `pending`:待处理
- `issued`:已开票
- `rejected`:已驳回
状态流转:
- `pending -> issued`
- `pending -> rejected`
- (可选)`rejected -> issued`(管理员确认无误后直接开票)
### 3.2 发票类型(invoice_type)
- `personal`:个人
- `enterprise`:企业普票
- `vat_special`:增值税专用发票
字段约束建议:
- `invoice_type=personal`:`taxpayer_id` 可为空
- `invoice_type=enterprise/vat_special`:`taxpayer_id` 必填
## 4. 编号与金额口径
### 4.1 发票申请号(invoice_no)
建议生成规则与订单号风格一致:
- `INV{YYYYMMDD}{9位序列}`(序列使用纳秒时间戳后 9 位,确保全局唯一)
### 4.2 发票金额(amount)
强制以订单 `cu_orders.total_amount` 为准(下单时已确定),避免用户端传入金额造成口径不一致。
## 5. 表结构设计(新增 invoices 表)
> 说明:当前项目大量使用软删除(`deleted_at`),发票也建议沿用该模式。
### 5.1 invoices 表(建议)
字段设计(按业务分组):
- 主键:`id`
- 关联:`order_id`(唯一)、`order_no`(冗余快照,便于搜索)、`customer_id`、`cu_user_id`
- 金额:`amount`
- 业务:`status`、`invoice_type`、`title`、`taxpayer_id`、`content`、`receiver_email`、`remark`
- 开票处理:`issued_at`、`invoice_file_url`、`uploaded_at`、`uploaded_by`
- 驳回处理:`rejected_at`、`rejected_by`、`reject_reason`、`suggestion`
- 下载(可选):`download_token`(用于邮件链接下载的随机 token)
- 时间:`created_at`、`updated_at`、`deleted_at`
推荐 SQL(MySQL):
```sql
CREATE TABLE `invoices` (
`id` varchar(36) NOT NULL,
`invoice_no` varchar(50) NOT NULL COMMENT '发票申请号',
`order_id` varchar(36) NOT NULL COMMENT '订单ID(唯一)',
`order_no` varchar(50) NOT NULL COMMENT '订单号(冗余快照,便于检索)',
`customer_id` varchar(36) NOT NULL COMMENT '客户ID',
`cu_user_id` varchar(36) NOT NULL COMMENT '申请人(客户用户)ID',
`amount` decimal(10,2) NOT NULL COMMENT '发票金额(取订单total_amount)',
`status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/issued/rejected',
`invoice_type` varchar(20) NOT NULL COMMENT 'personal/enterprise/vat_special',
`title` varchar(200) NOT NULL COMMENT '发票抬头',
`taxpayer_id` varchar(50) DEFAULT NULL COMMENT '纳税人识别号',
`content` varchar(200) NOT NULL COMMENT '开票内容',
`receiver_email` varchar(255) NOT NULL COMMENT '收票邮箱',
`remark` varchar(1000) DEFAULT NULL COMMENT '备注',
`invoice_file_url` varchar(500) DEFAULT NULL COMMENT '发票文件URL(PDF)',
`uploaded_at` datetime(3) DEFAULT NULL COMMENT '上传时间',
`uploaded_by` varchar(36) DEFAULT NULL COMMENT '上传人(管理员)ID',
`issued_at` datetime(3) DEFAULT NULL COMMENT '开票完成时间',
`reject_reason` varchar(500) DEFAULT NULL COMMENT '驳回原因',
`suggestion` varchar(500) DEFAULT NULL COMMENT '建议修改内容',
`rejected_at` datetime(3) DEFAULT NULL COMMENT '驳回时间',
`rejected_by` varchar(36) DEFAULT NULL COMMENT '驳回人(管理员)ID',
`download_token` varchar(64) DEFAULT NULL COMMENT '下载token(可选)',
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_invoices_invoice_no` (`invoice_no`),
UNIQUE KEY `idx_invoices_order_id` (`order_id`),
KEY `idx_invoices_customer_id` (`customer_id`),
KEY `idx_invoices_cu_user_id` (`cu_user_id`),
KEY `idx_invoices_status` (`status`),
KEY `idx_invoices_created_at` (`created_at`),
KEY `idx_invoices_deleted_at` (`deleted_at`),
KEY `idx_invoices_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
## 6. 接口设计
### 6.1 通用约定(与项目一致)
- 鉴权:客户端 `CustomerAuth()`;管理端 `AuthMiddleware()`(是否叠加 `AdminOnlyMiddleware()` 视权限策略)
- 返回:成功 `models.APIResponse`,失败 `models.ErrorResponse`
- 多语言:`Accept-Language` 或 `?lang=...`
### 6.2 客户端(/api/cu)
#### 6.2.1 客户申请发票
```http
POST /api/cu/invoices
```
请求 Body:
```json
{
"order_id": "order-uuid",
"invoice_type": "enterprise",
"receiver_email": "finance@example.com",
"title": "某某公司",
"taxpayer_id": "9133xxxxxxxxxxxxxx",
"content": "软件服务费",
"remark": "请开电子发票"
}
```
规则(建议):
- `order_id` 必填,且订单必须属于当前用户(`cu_orders.cu_user_id`)
- 仅允许已支付订单开票:`cu_orders.status=paid`
- 一个订单仅允许一张发票:若已存在发票则返回冲突错误
- `invoice_type` 影响 `taxpayer_id` 是否必填(见 3.2)
成功响应:
```json
{
"code": "000000",
"message": "成功",
"data": { "id": "invoice-uuid", "invoice_no": "INV202601210123456789", "status": "pending" },
"timestamp": "2026-01-21T10:05:00Z"
}
```
#### 6.2.2 客户发票列表
```http
GET /api/cu/invoices
```
Query:
- `page`(默认 1)
- `page_size`(默认 10,最大 100)
- `status`(可选):`pending/issued/rejected`
- `search`(可选):`invoice_no` 或 `order_no` 模糊匹配
- `apply_date`(可选):申请日期(`YYYY-MM-DD`,按 `created_at` 过滤)
#### 6.2.3 客户发票详情
```http
GET /api/cu/invoices/{id}
```
返回:发票表全部字段 + 关联订单号/套餐类型(可 join `cu_orders` 返回 `order_no/package_name`)。
#### 6.2.4 客户发票汇总信息
```http
GET /api/cu/invoices/summary
```
返回:
- `total_count`
- `pending_count`
- `issued_count`
- `rejected_count`
#### 6.2.5 发票下载(推荐:鉴权下载)
```http
GET /api/cu/invoices/{id}/download
```
仅允许下载自己的发票;返回 PDF 文件流。
### 6.3 管理端(/api/v1)
#### 6.3.1 管理员发票列表
```http
GET /api/v1/invoices
```
Query 与客户端一致,额外可支持 `customer_id` 筛选。
#### 6.3.2 管理员发票详情
```http
GET /api/v1/invoices/{id}
```
返回:
- 发票表所有字段
- 关联订单号、套餐类型(`cu_orders.package_name`)
- 申请人姓名/电话(`cu_users.real_name/phone`)
- 开票人 `full_name`(通过 `users.full_name`,关联 `uploaded_by`)
#### 6.3.3 管理员发票汇总信息
```http
GET /api/v1/invoices/summary
```
#### 6.3.4 管理员发票驳回
```http
POST /api/v1/invoices/{id}/reject
```
Body:
```json
{ "reject_reason": "抬头信息不完整", "suggestion": "请补充纳税人识别号" }
```
规则:仅允许对 `pending` 发票操作,写入驳回字段并更新 `status=rejected`。
#### 6.3.5 管理员发票开票(支持覆盖重开)
```http
POST /api/v1/invoices/{id}/issue
```
Body:
```json
{ "invoice_file_url": "/files/invoices/INV202601210123456789_20260121103000.pdf", "issued_at": "2026-01-21T10:30:00Z" }
```
规则:
- 允许从 `pending/rejected` 置为 `issued`
- 允许在 `issued` 状态下覆盖更新 `invoice_file_url/issued_at`(“重开覆盖”)
#### 6.3.6 管理端发票文件上传
```http
POST /api/v1/invoices/upload
```
说明:
- 存储目录:程序运行目录相对路径 `../files/invoices/`
- 文件名:`{invoice_no}_{yyyyMMddHHmmss}.pdf`
- 返回相对 URL:`/files/invoices/...pdf`
FormData:
- `invoice_no`
- `file`(pdf)
### 6.4 公共下载接口(可选)
若需要邮件直达下载(不登录),建议采用 token 方式(不要直接暴露任意 `file_url`):
```http
GET /api/public/invoices/download?token=xxxxx
```
规则:
- `token` 对应 `invoices.download_token`(随机生成)
- 仅当 `status=issued` 且存在 `invoice_file_url` 才允许下载
## 7. 错误码规划(建议)
建议新增发票模块错误码段:`70xxxx`
- `700001` 发票不存在
- `700002` 订单不允许开票(非 `paid`)
- `700003` 该订单已存在发票
- `700004` 发票状态不允许操作(如非 `pending` 不能驳回)
- `700005` 发票文件缺失/不可下载
## 8. 相关表结构(现有)
### 附录:现有表结构(参考)
-- license_manager.users definition
CREATE TABLE `users` (
`id` varchar(36) NOT NULL,
`username` varchar(50) NOT NULL,
`email` varchar(255) NOT NULL,
`password_hash` varchar(255) NOT NULL,
`full_name` varchar(100) NOT NULL,
`phone` varchar(20) DEFAULT NULL,
`role` varchar(20) NOT NULL DEFAULT 'viewer',
`status` varchar(20) NOT NULL DEFAULT 'active',
`last_login_at` datetime(3) DEFAULT NULL,
`last_login_ip` varchar(45) DEFAULT NULL,
`login_attempts` bigint NOT NULL DEFAULT '0',
`locked_until` datetime(3) DEFAULT NULL,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_users_username` (`username`),
UNIQUE KEY `idx_users_email` (`email`),
KEY `idx_users_role` (`role`),
KEY `idx_users_status` (`status`),
KEY `idx_users_last_login_at` (`last_login_at`),
KEY `idx_users_locked_until` (`locked_until`),
KEY `idx_users_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- license_manager.cu_users definition
CREATE TABLE `cu_users` (
`id` varchar(36) NOT NULL,
`customer_id` varchar(36) NOT NULL,
`phone` varchar(20) NOT NULL,
`phone_country_code` varchar(10) NOT NULL DEFAULT '+86',
`password` varchar(255) DEFAULT NULL,
`salt` varchar(32) DEFAULT NULL,
`user_role` varchar(20) NOT NULL DEFAULT 'member',
`real_name` varchar(100) DEFAULT NULL,
`email` varchar(255) DEFAULT NULL,
`status` varchar(20) NOT NULL DEFAULT 'active',
`phone_verified` tinyint(1) NOT NULL DEFAULT '1',
`email_verified` tinyint(1) NOT NULL DEFAULT '0',
`last_login_at` datetime(3) DEFAULT NULL,
`last_login_ip` varchar(50) DEFAULT NULL,
`login_attempts` bigint NOT NULL DEFAULT '0',
`locked_until` datetime(3) DEFAULT NULL,
`avatar_url` varchar(500) DEFAULT NULL,
`language` varchar(10) NOT NULL DEFAULT 'zh-CN',
`timezone` varchar(50) NOT NULL DEFAULT 'Asia/Shanghai',
`additional_info` json DEFAULT NULL,
`remark` text,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_cu_users_customer_id` (`customer_id`),
KEY `idx_cu_users_email` (`email`),
KEY `idx_cu_users_status` (`status`),
KEY `idx_cu_users_last_login_at` (`last_login_at`),
KEY `idx_cu_users_locked_until` (`locked_until`),
KEY `idx_cu_users_created_at` (`created_at`),
KEY `idx_cu_users_deleted_at` (`deleted_at`),
KEY `idx_cu_users_phone` (`phone`,`phone_country_code`),
KEY `idx_cu_users_customer_status` (`customer_id`,`status`),
KEY `idx_cu_users_status_locked` (`status`,`locked_until`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- license_manager.cu_orders definition
CREATE TABLE `cu_orders` (
`id` varchar(36) NOT NULL,
`order_no` varchar(50) NOT NULL,
`customer_id` varchar(36) NOT NULL,
`cu_user_id` varchar(36) NOT NULL,
`package_id` varchar(50) NOT NULL,
`package_name` varchar(100) NOT NULL,
`license_count` bigint NOT NULL,
`unit_price` decimal(10,2) NOT NULL,
`discount_rate` decimal(3,2) NOT NULL DEFAULT '1.00',
`total_amount` decimal(10,2) NOT NULL,
`status` varchar(20) NOT NULL DEFAULT 'paid',
`authorization_code` varchar(500) DEFAULT NULL,
`expired_at` datetime(3) DEFAULT NULL,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_cu_orders_order_no` (`order_no`),
KEY `idx_cu_orders_customer_id` (`customer_id`),
KEY `idx_cu_orders_cu_user_id` (`cu_user_id`),
KEY `idx_cu_orders_package_id` (`package_id`),
KEY `idx_cu_orders_status` (`status`),
KEY `idx_cu_orders_expired_at` (`expired_at`),
KEY `idx_cu_orders_created_at` (`created_at`),
KEY `idx_cu_orders_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- license_manager.customers definition
CREATE TABLE `customers` (
`id` varchar(36) NOT NULL DEFAULT (uuid()),
`customer_code` varchar(20) NOT NULL,
`customer_name` varchar(200) NOT NULL,
`customer_type` varchar(20) NOT NULL DEFAULT 'enterprise',
`contact_person` varchar(100) NOT NULL,
`contact_title` varchar(100) DEFAULT NULL,
`email` varchar(255) DEFAULT NULL,
`phone` varchar(20) DEFAULT NULL,
`address` text,
`company_size` varchar(20) DEFAULT NULL,
`customer_level` varchar(20) NOT NULL DEFAULT 'basic',
`status` varchar(20) NOT NULL DEFAULT 'active',
`description` text,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`created_by` varchar(36) NOT NULL,
`updated_by` varchar(36) DEFAULT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_customers_customer_code` (`customer_code`),
KEY `idx_customers_customer_name` (`customer_name`),
KEY `idx_customers_customer_type` (`customer_type`),
KEY `idx_customers_customer_level` (`customer_level`),
KEY `idx_customers_status` (`status`),
KEY `idx_customers_created_at` (`created_at`),
KEY `idx_customers_created_by` (`created_by`),
KEY `idx_customers_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- license_manager.authorization_codes definition
CREATE TABLE `authorization_codes` (
`id` varchar(36) NOT NULL,
`code` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`customer_id` varchar(36) NOT NULL,
`created_by` varchar(36) NOT NULL,
`software_id` varchar(50) DEFAULT NULL,
`description` text,
`start_date` datetime(3) NOT NULL,
`end_date` datetime(3) NOT NULL,
`deployment_type` varchar(20) NOT NULL DEFAULT 'standalone',
`encryption_type` varchar(20) DEFAULT 'standard',
`software_version` varchar(50) DEFAULT NULL,
`max_activations` bigint NOT NULL DEFAULT '1',
`is_locked` tinyint(1) NOT NULL DEFAULT '0',
`lock_reason` text,
`locked_at` datetime(3) DEFAULT NULL,
`locked_by` varchar(36) DEFAULT NULL,
`feature_config` json DEFAULT NULL,
`usage_limits` json DEFAULT NULL,
`custom_parameters` json DEFAULT NULL,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_authorization_codes_customer_id` (`customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
+80 -3
View File
@@ -1,5 +1,82 @@
# 提示词
# 开发提示词
## 接口设计提示词
> 用于指导 AI 助手在项目中的开发行为和决策方式
请参考至少三个当前已经实现的接口,熟悉接口设计规范,然后设计新的接口。
---
## 一、核心定位
作为一名经验丰富的开发工程师和架构师,你需要:
1. **主动审查** - 发现架构设计不合理或存在潜在问题时,必须及时与请求者讨论,说明具体问题和建议
2. **充分调研** - 不要凭空设计,而要基于现有实践和项目规范
3. **主动沟通** - 遇到无法决策的问题时,主动询问请求者而不是随便决策
---
## 二、开发原则
### 2.1 代码规范
| 规范项 | 要求 |
|:------|:----|
| 日志库 | 使用 logrus,不要使用其他日志库 |
| 注释 | 接口的请求和响应结构体需要加上**中文注释** |
| 接口参数 | 可选参数(非必输)必须使用指针类型(如 `*string`),以便正确区分“未传”和“明确传空”的区别 |
| 参数校验 | 返回无效请求错误(如 `900001`)时,必须在提示信息后追加具体的原因,例如:`Message: msg + ": " + err.Error()` |
| 设计原则 | 避免过度设计,保持代码的简洁和可读性 |
| 错误处理 | 不可为了处理错误而处理错误,让错误在源头尖叫,而不是让它在下游腐烂。|
### 2.2 接口设计
1. **参考学习** - 设计新接口前,先查看项目中至少 3 个已实现的类似接口
2. **保持一致** - 遵循项目现有的接口设计模式和命名规范
3. **文档完善** - 所有请求和响应结构体字段必须添加中文注释,说明字段用途、数据类型、是否必填、取值范围等
### 2.3 开发实施
- 如果当前项目已在开发中,按照项目现有代码规范和最佳实践开发,不要独辟蹊径
- 如果当前项目尚未开发,按照项目规范和最佳实践开发,不要独辟蹊径
- 代码风格、目录结构、命名方式应与项目保持一致
- 实现新功能前,先研究项目中类似功能的实现方式
---
## 三、沟通协作
### 必须主动询问的情况
| 情况 | 说明 |
|:----|:----|
| 需求不明确 | 需求存在歧义或不够清晰 |
| 技术方案选择 | 有多种技术方案可选择 |
| 重要决策 | 涉及架构或技术的重要决策 |
| 规范冲突 | 与现有规范冲突需要权衡 |
### 禁止的行为
- 不要在不确定的情况下自行决策
- 不要臆断需求,避免返工
---
## 四、行为准则
### 应该做的
- ✅ 主动反馈架构设计中的问题
- ✅ 设计接口前充分调研现有实现
- ✅ 保持代码风格与项目一致
- ✅ 为所有接口字段添加中文注释
- ✅ 遇到不确定的问题时主动询问
- ✅ 让错误在源头尖叫,而不是让它在下游腐烂
- ✅ 可选参数使用指针类型
- ✅ 参数校验报错追加具体原因 (`err.Error()`)
### 不应该做的
- ❌ 不要凭空设计,要基于现有实践
- ❌ 不要独辟蹊径,要遵循项目规范
- ❌ 不要在不确定的情况下自行决策
- ❌ 不要使用 logrus 以外的日志库
- ❌ 不要为了处理错误而处理错误
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+4 -7
View File
@@ -3,6 +3,10 @@ module license-manager
go 1.23
require (
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0
github.com/alibabacloud-go/tea v1.4.0
github.com/alibabacloud-go/tea-utils/v2 v2.0.9
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.4.0
@@ -11,7 +15,6 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/smartwalle/alipay/v3 v3.2.28
github.com/spf13/viper v1.19.0
github.com/stretchr/testify v1.9.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.3
@@ -27,11 +30,7 @@ require (
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0 // indirect
github.com/alibabacloud-go/tea v1.4.0 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 // indirect
github.com/aliyun/credentials-go v1.4.5 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
@@ -39,7 +38,6 @@ require (
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
@@ -69,7 +67,6 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
+9
View File
@@ -8,16 +8,22 @@ github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tN
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 h1:Q00FU3H94Ts0ZIHDmY+fYGgB7dV9D/YX6FGsgorQPgw=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
@@ -25,7 +31,9 @@ github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0 h1:rYyjylSQ0xdA2AaYIFky9CSyKDOZpEHdAsIMAcbSIjw=
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0/go.mod h1:6ax2auNlAXYhdIqYOTpXWm7vS9AwaWigZFzHKkDm9k8=
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
@@ -37,6 +45,7 @@ github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZL
github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea v1.4.0 h1:MSKhu/kWLPX7mplWMngki8nNt+CyUZ+kfkzaR5VpMhA=
github.com/alibabacloud-go/tea v1.4.0/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
@@ -102,6 +102,7 @@ func (h *AuthorizationCodeHandler) CreateAuthorizationCode(c *gin.Context) {
// @Param page query int false "页码,默认1" minimum(1)
// @Param page_size query int false "每页条数,默认20,最大100" minimum(1) maximum(100)
// @Param customer_id query string false "客户ID筛选"
// @Param code query string false "授权码(模糊匹配)"
// @Param status query string false "状态筛选" Enums(normal, locked, expired)
// @Param start_date query string false "创建开始时间"
// @Param end_date query string false "创建结束时间"
@@ -24,7 +24,7 @@ func NewCuAuthorizationHandler(authCodeService service.AuthorizationCodeService)
// ShareAuthorizationCode 用户分享授权码
// @Summary 用户分享授权码
// @Description 用户可以将自己的授权码分享给其他用户
// @Description 用户可以将自己的授权码分享给其他用户(通过手机号或邮箱)
// @Tags 用户端授权码管理
// @Accept json
// @Produce json
@@ -165,3 +165,114 @@ func (h *CuAuthorizationHandler) GetProductActivationCode(c *gin.Context) {
Data: data,
})
}
// GetCuAuthorizationCodes 获取用户授权码列表
// @Summary 获取用户授权码列表
// @Description 获取当前登录用户的授权码列表,支持分页、状态筛选与授权码模糊搜索
// @Tags 用户端授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param status query string false "状态筛选" Enums(normal, locked, expired)
// @Param search query string false "授权码模糊匹配"
// @Success 200 {object} models.APIResponse{data=models.CuAuthorizationCodeListResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/authorization-codes [get]
func (h *CuAuthorizationHandler) GetCuAuthorizationCodes(c *gin.Context) {
claims := c.MustGet("cu_user").(*utils.CuClaims)
var req models.CuAuthorizationCodeListRequest
if err := c.ShouldBindQuery(&req); err != nil {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.authCodeService.GetCuAuthorizationCodeList(ctx, claims.CustomerID, &req)
if err != nil {
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
Timestamp: getCurrentTimestamp(),
})
}
// GetCuAuthorizationCodeSummary 获取用户授权信息统计
// @Summary 获取用户授权信息统计
// @Description 获取当前登录用户的授权码统计信息
// @Tags 用户端授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.CuAuthorizationCodeSummaryResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/authorization-codes/summary [get]
func (h *CuAuthorizationHandler) GetCuAuthorizationCodeSummary(c *gin.Context) {
claims := c.MustGet("cu_user").(*utils.CuClaims)
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.authCodeService.GetCuAuthorizationCodeSummary(ctx, claims.CustomerID)
if err != nil {
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
Timestamp: getCurrentTimestamp(),
})
}
@@ -0,0 +1,253 @@
package handlers
import (
"net/http"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"github.com/gin-gonic/gin"
)
type CuInvoiceHandler struct {
invoiceService service.CuInvoiceService
}
func NewCuInvoiceHandler(invoiceService service.CuInvoiceService) *CuInvoiceHandler {
return &CuInvoiceHandler{
invoiceService: invoiceService,
}
}
// CreateInvoice 客户申请发票
// @Summary 客户申请发票
// @Description 客户为已支付的订单申请发票
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body models.InvoiceCreateRequest true "发票申请请求"
// @Success 200 {object} models.APIResponse{data=object{id=string,invoice_no=string,status=string}} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices [post]
func (h *CuInvoiceHandler) CreateInvoice(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.InvoiceCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
invoice, err := h.invoiceService.CreateInvoice(c.Request.Context(), getCuUserID(c), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
response := invoice.ToResponse()
// 只返回关键信息
result := map[string]interface{}{
"id": response.ID,
"invoice_no": response.InvoiceNo,
"status": response.Status,
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: result,
Timestamp: getCurrentTimestamp(),
})
}
// GetUserInvoices 客户发票列表
// @Summary 客户发票列表
// @Description 获取当前客户的发票列表
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码,默认1" minimum(1)
// @Param page_size query int false "每页条数,默认10,最大100" minimum(1) maximum(100)
// @Param status query string false "状态筛选,pending-待处理/issued-已开票/rejected-已驳回"
// @Param search query string false "搜索关键词,支持发票号或订单号模糊匹配"
// @Param apply_date query string false "申请日期筛选,格式YYYY-MM-DD 或 RFC3339范围(start,end)"
// @Success 200 {object} models.APIResponse{data=models.InvoiceListResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices [get]
func (h *CuInvoiceHandler) GetUserInvoices(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.InvoiceListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
invoices, err := h.invoiceService.GetUserInvoices(c.Request.Context(), getCuUserID(c), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoices,
Timestamp: getCurrentTimestamp(),
})
}
// GetUserInvoiceDetail 客户发票详情
// @Summary 客户发票详情
// @Description 获取指定发票的详细信息
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "发票ID"
// @Success 200 {object} models.APIResponse{data=models.InvoiceResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices/{id} [get]
func (h *CuInvoiceHandler) GetUserInvoiceDetail(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
invoice, err := h.invoiceService.GetInvoice(c.Request.Context(), invoiceID, getCuUserID(c))
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoice.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// GetUserInvoiceSummary 客户发票汇总信息
// @Summary 客户发票汇总信息
// @Description 获取当前客户的发票统计汇总信息
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.InvoiceSummaryResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices/summary [get]
func (h *CuInvoiceHandler) GetUserInvoiceSummary(c *gin.Context) {
lang := middleware.GetLanguage(c)
summary, err := h.invoiceService.GetInvoiceSummary(c.Request.Context(), getCuUserID(c))
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: summary,
Timestamp: getCurrentTimestamp(),
})
}
// DownloadInvoice 客户发票下载
// @Summary 客户发票下载
// @Description 下载指定发票的文件
// @Tags 发票管理
// @Accept json
// @Produce application/pdf
// @Security BearerAuth
// @Param id path string true "发票ID"
// @Success 200 {file} binary "发票文件"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在或文件不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices/{id}/download [get]
func (h *CuInvoiceHandler) DownloadInvoice(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
fileURL, err := h.invoiceService.DownloadInvoice(c.Request.Context(), invoiceID, getCuUserID(c))
if err != nil {
handleI18nError(c, err, lang)
return
}
// 重定向到文件URL
c.Redirect(http.StatusFound, fileURL)
}
// UpdateInvoice 客户修改发票申请
// @Summary 客户修改发票申请
// @Description 客户修改已被驳回的发票申请并重新提交
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "发票ID"
// @Param request body models.InvoiceUpdateRequest true "发票修改请求"
// @Success 200 {object} models.APIResponse{data=object{id=string,invoice_no=string,status=string}} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices/{id} [put]
func (h *CuInvoiceHandler) UpdateInvoice(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
var req models.InvoiceUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
invoice, err := h.invoiceService.UpdateInvoice(c.Request.Context(), invoiceID, getCuUserID(c), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
response := invoice.ToResponse()
result := map[string]interface{}{
"id": response.ID,
"invoice_no": response.InvoiceNo,
"status": response.Status,
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: result,
Timestamp: getCurrentTimestamp(),
})
}
+257 -62
View File
@@ -2,7 +2,6 @@ package handlers
import (
"net/http"
"strconv"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
@@ -16,12 +15,14 @@ import (
type CuOrderHandler struct {
cuOrderService service.CuOrderService
paymentService service.PaymentService
packageService service.PackageService
}
func NewCuOrderHandler(cuOrderService service.CuOrderService, paymentService service.PaymentService) *CuOrderHandler {
func NewCuOrderHandler(cuOrderService service.CuOrderService, paymentService service.PaymentService, packageService service.PackageService) *CuOrderHandler {
return &CuOrderHandler{
cuOrderService: cuOrderService,
paymentService: paymentService,
packageService: packageService,
}
}
@@ -36,58 +37,43 @@ func NewCuOrderHandler(cuOrderService service.CuOrderService, paymentService ser
func (h *CuOrderHandler) GetPackages(c *gin.Context) {
lang := middleware.GetLanguage(c)
// 获取套餐列表
packages := h.getPackageList()
// 从数据库获取套餐列表
packages, err := h.packageService.GetCuPackageList(c.Request.Context())
if err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("800005", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 转换为map数组格式(兼容原有结构)
result := make([]map[string]interface{}, len(packages))
for i, pkg := range packages {
result[i] = map[string]interface{}{
"id": pkg.ID,
"name": pkg.Name,
"type": pkg.Type,
"price": pkg.Price,
"display_price": pkg.DisplayPrice,
"price_description": pkg.PriceDescription,
"max_devices": pkg.MaxDevices,
"description": pkg.Description,
"features": pkg.Features,
"details": pkg.Details,
}
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: packages,
Data: result,
Timestamp: getCurrentTimestamp(),
})
}
// getPackageList 获取套餐列表(暂时写死数据)
func (h *CuOrderHandler) getPackageList() []map[string]interface{} {
return []map[string]interface{}{
{
"id": "trial",
"name": "试用版",
"type": "trial",
"price": 0,
"max_devices": 1,
"description": "免费体验全部功能",
"details": "包含全部功能|试用期限制|1个许可等",
},
{
"id": "basic",
"name": "基础版",
"type": "basic",
"price": 300,
"max_devices": 1000,
"description": "小型企业最佳选择",
"details": "包含基础功能|批量许可购买|折扣优惠等",
},
{
"id": "professional",
"name": "专业版",
"type": "professional",
"price": 2000,
"max_devices": 1000,
"description": "企业级完整解决方案",
"details": "包含高级功能|技术支持|数据分析等",
}, {
"id": "custom",
"name": "企业定制版",
"type": "custom",
"price": "定制报价",
"max_devices": 0,
"description": "按需定制",
"details": "包含高级功能|技术支持|数据分析等",
},
}
}
// CalculatePrice 计算订单价格
// @Summary 计算订单价格
// @Description 根据套餐和许可数量计算价格和折扣
@@ -205,9 +191,26 @@ func (h *CuOrderHandler) CreateOrder(c *gin.Context) {
return
}
// 根据是否有支付方式决定处理逻辑
// 先计算价格,根据金额决定处理逻辑
priceResult, err := h.cuOrderService.CalculatePrice(c.Request.Context(), req.PackageID, req.LicenseCount)
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
Message: err.(*i18n.I18nError).Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果金额为0(免费/试用版),直接生成授权码,无论是否传了payment_method
if priceResult.TotalAmount == 0 {
h.createFreeOrder(c, cuUserID.(string), customerID.(string), &req)
return
}
// 如果金额大于0,根据是否传了payment_method决定处理逻辑
if req.PaymentMethod == "" {
// 免费订单:直接创建订单
// 金额大于0但没有传支付方式,走免费订单流程(直接生成授权码)
h.createFreeOrder(c, cuUserID.(string), customerID.(string), &req)
} else {
// 付费订单:创建订单和支付单
@@ -369,6 +372,67 @@ func (h *CuOrderHandler) GetOrder(c *gin.Context) {
})
}
// CancelOrder 删除订单
// @Summary 删除订单
// @Description 删除当前用户的订单记录
// @Tags 客户订单管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param order_id path string true "订单ID"
// @Success 200 {object} models.APIResponse{} "成功"
// @Failure 400
// @Failure 401
// @Failure 403
// @Failure 404
// @Failure 409
// @Router /api/cu/orders/{order_id}/cancel [put]
func (h *CuOrderHandler) CancelOrder(c *gin.Context) {
orderID := c.Param("order_id")
if orderID == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
cuUserID, exists := c.Get("cu_user_id")
if !exists {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("100004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
ctx := middleware.WithLanguage(c.Request.Context(), c)
err := h.cuOrderService.DeleteOrder(ctx, orderID, cuUserID.(string))
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
Message: err.(*i18n.I18nError).Message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMsg := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMsg,
Data: nil,
Timestamp: getCurrentTimestamp(),
})
}
// GetUserOrders 获取用户订单列表
// @Summary 获取用户订单列表
// @Description 获取当前用户的订单列表,支持分页
@@ -378,22 +442,25 @@ func (h *CuOrderHandler) GetOrder(c *gin.Context) {
// @Security BearerAuth
// @Param page query integer false "页码" default(1)
// @Param page_size query integer false "每页数量" default(10)
// @Param search query string false "订单号或授权码模糊匹配"
// @Param status query string false "订单状态筛选" Enums(pending, paid, cancelled)
// @Param time query string false "时间筛选" Enums(today, week, month, three_months)
// @Success 200 {object} models.APIResponse{data=models.CuOrderListResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Router /api/cu/orders [get]
func (h *CuOrderHandler) GetUserOrders(c *gin.Context) {
// 解析分页参数
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "10"))
if page < 1 {
page = 1
var req models.CuOrderListRequest
if err := c.ShouldBindQuery(&req); err != nil {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
if pageSize < 1 || pageSize > 100 {
pageSize = 10
}
offset := (page - 1) * pageSize
// 获取用户ID
cuUserID, exists := c.Get("cu_user_id")
@@ -409,7 +476,8 @@ func (h *CuOrderHandler) GetUserOrders(c *gin.Context) {
}
// 获取订单列表
orders, total, err := h.cuOrderService.GetUserOrders(c.Request.Context(), cuUserID.(string), offset, pageSize)
ctx := middleware.WithLanguage(c.Request.Context(), c)
orders, total, err := h.cuOrderService.GetUserOrders(ctx, cuUserID.(string), &req)
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
@@ -433,8 +501,8 @@ func (h *CuOrderHandler) GetUserOrders(c *gin.Context) {
Data: &models.CuOrderListResponse{
Orders: orderResponses,
TotalCount: total,
Page: page,
PageSize: pageSize,
Page: req.Page,
PageSize: req.PageSize,
},
Timestamp: getCurrentTimestamp(),
})
@@ -487,3 +555,130 @@ func (h *CuOrderHandler) GetOrderSummary(c *gin.Context) {
Data: result,
})
}
// ContinuePay 继续支付
// @Summary 继续支付
// @Description 对指定订单继续支付,如果支付单已过期则创建新的支付单
// @Tags 客户订单管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param order_id path string true "订单ID"
// @Param payment_method query string false "支付方式" Enums(alipay, wechat)
// @Success 200 {object} models.APIResponse{data=object{order_id=string,order_no=string,payment_no=string,payment_url=string,total_amount=number,expire_time=string}} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "订单不存在"
// @Failure 400 {object} models.ErrorResponse "订单状态不允许继续支付"
// @Router /api/cu/orders/{order_id}/pay [post]
func (h *CuOrderHandler) ContinuePay(c *gin.Context) {
orderID := c.Param("order_id")
if orderID == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 获取用户ID和客户ID
cuUserID, exists := c.Get("cu_user_id")
if !exists {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("100004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
customerID, exists := c.Get("cu_customer_id")
if !exists {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("100004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 获取支付方式(可选,默认使用配置的默认支付方式)
paymentMethod := c.DefaultQuery("payment_method", "")
// 调用服务层
ctx := middleware.WithLanguage(c.Request.Context(), c)
result, err := h.cuOrderService.ContinuePay(ctx, orderID, cuUserID.(string), customerID.(string), paymentMethod)
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
Message: err.(*i18n.I18nError).Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果支付单号为空,说明需要创建新的支付单
if result.PaymentNo == "" {
// 获取订单信息以获取金额
order, err := h.cuOrderService.GetOrder(ctx, orderID, cuUserID.(string))
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
Message: err.(*i18n.I18nError).Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 创建新的支付单
paymentReq := &models.PaymentCreateRequest{
BusinessType: models.BusinessTypePackageOrder,
BusinessID: &order.ID,
CustomerID: customerID.(string),
CuUserID: cuUserID.(string),
Amount: order.TotalAmount,
Currency: "CNY",
PaymentMethod: paymentMethod,
}
payment, err := h.paymentService.CreatePayment(ctx, paymentReq)
if err != nil {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("602001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
// 更新返回结果
result.PaymentNo = payment.PaymentNo
result.PaymentURL = payment.PaymentURL
result.ExpireTime = payment.ExpireTime
}
lang := middleware.GetLanguage(c)
successMsg := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMsg,
Data: gin.H{
"order_id": result.OrderID,
"order_no": result.OrderNo,
"payment_no": result.PaymentNo,
"payment_url": result.PaymentURL,
"total_amount": result.TotalAmount,
"expire_time": result.ExpireTime.Format("2006-01-02T15:04:05Z"),
},
Timestamp: getCurrentTimestamp(),
})
}
@@ -1,35 +0,0 @@
package handlers
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestCuOrderHandler_getPackageList(t *testing.T) {
handler := &CuOrderHandler{}
packages := handler.getPackageList()
// 验证返回了3种套餐
assert.Equal(t, 3, len(packages))
// 验证试用版
trial := packages[0]
assert.Equal(t, "trial", trial["id"])
assert.Equal(t, "试用版", trial["name"])
assert.Equal(t, 0, trial["price"])
assert.Equal(t, 1, trial["max_devices"])
// 验证基础版
basic := packages[1]
assert.Equal(t, "basic", basic["id"])
assert.Equal(t, "基础版", basic["name"])
assert.Equal(t, 300, basic["price"])
// 验证专业版
professional := packages[2]
assert.Equal(t, "professional", professional["id"])
assert.Equal(t, "专业版", professional["name"])
assert.Equal(t, 2000, professional["price"])
}
@@ -0,0 +1,378 @@
package handlers
import (
"net/http"
"path/filepath"
"strconv"
"time"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"github.com/gin-gonic/gin"
)
type AdminInvoiceHandler struct {
invoiceService service.AdminInvoiceService
}
func NewAdminInvoiceHandler(invoiceService service.AdminInvoiceService) *AdminInvoiceHandler {
return &AdminInvoiceHandler{
invoiceService: invoiceService,
}
}
// GetAdminInvoices 管理员发票列表
// @Summary 管理员发票列表
// @Description 管理员查看所有发票列表,支持按客户筛选
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码,默认1" minimum(1)
// @Param page_size query int false "每页条数,默认10,最大100" minimum(1) maximum(100)
// @Param status query string false "状态筛选,pending-待处理/issued-已开票/rejected-已驳回"
// @Param search query string false "搜索关键词,支持发票号或订单号模糊匹配"
// @Param apply_date query string false "申请日期筛选,格式YYYY-MM-DD 或 RFC3339范围(start,end)"
// @Param customer_id query string false "客户ID筛选"
// @Success 200 {object} models.APIResponse{data=models.InvoiceListResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices [get]
func (h *AdminInvoiceHandler) GetAdminInvoices(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.InvoiceListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang),
Timestamp: getCurrentTimestamp(),
})
return
}
invoices, err := h.invoiceService.GetAdminInvoices(c.Request.Context(), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoices,
Timestamp: getCurrentTimestamp(),
})
}
// GetAdminInvoiceDetail 管理员发票详情
// @Summary 管理员发票详情
// @Description 管理员查看发票详细信息,包含关联信息
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "发票ID"
// @Success 200 {object} models.APIResponse{data=models.InvoiceDetailResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/{id} [get]
func (h *AdminInvoiceHandler) GetAdminInvoiceDetail(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
adminID := getUserID(c)
detail, err := h.invoiceService.GetInvoiceDetail(c.Request.Context(), invoiceID, true, adminID)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: detail,
Timestamp: getCurrentTimestamp(),
})
}
// GetAdminInvoiceSummary 管理员发票汇总信息
// @Summary 管理员发票汇总信息
// @Description 管理员查看全平台的发票统计汇总信息
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.InvoiceSummaryResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/summary [get]
func (h *AdminInvoiceHandler) GetAdminInvoiceSummary(c *gin.Context) {
lang := middleware.GetLanguage(c)
summary, err := h.invoiceService.GetAdminInvoiceSummary(c.Request.Context())
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: summary,
Timestamp: getCurrentTimestamp(),
})
}
// RejectInvoice 管理员发票驳回
// @Summary 管理员发票驳回
// @Description 管理员驳回发票申请,需要填写驳回原因和修改建议
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "发票ID"
// @Param request body models.InvoiceRejectRequest true "驳回请求"
// @Success 200 {object} models.APIResponse{data=models.InvoiceResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/{id}/reject [post]
func (h *AdminInvoiceHandler) RejectInvoice(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
adminID := getUserID(c)
var req models.InvoiceRejectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
invoice, err := h.invoiceService.RejectInvoice(c.Request.Context(), invoiceID, adminID, &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoice.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// IssueInvoice 管理员发票开票
// @Summary 管理员发票开票
// @Description 管理员为发票开票,上传发票文件并设置开票时间
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "发票ID"
// @Param request body models.InvoiceIssueRequest true "开票请求"
// @Success 200 {object} models.APIResponse{data=models.InvoiceResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/{id}/issue [post]
func (h *AdminInvoiceHandler) IssueInvoice(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
adminID := getUserID(c)
var req models.InvoiceIssueRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
invoice, err := h.invoiceService.IssueInvoice(c.Request.Context(), invoiceID, adminID, &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoice.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// UploadInvoiceFile 管理端发票文件上传
// @Summary 管理端发票文件上传
// @Description 管理员上传发票PDF文件
// @Tags 发票管理
// @Accept multipart/form-data
// @Produce json
// @Security BearerAuth
// @Param invoice_no formData string true "发票申请号"
// @Param file formData file true "发票PDF文件"
// @Success 200 {object} models.APIResponse{data=models.InvoiceUploadResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/upload [post]
func (h *AdminInvoiceHandler) UploadInvoiceFile(c *gin.Context) {
lang := middleware.GetLanguage(c)
// 获取表单参数
invoiceNo := c.PostForm("invoice_no")
if invoiceNo == "" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": invoice_no is required",
Timestamp: getCurrentTimestamp(),
})
return
}
// 获取上传的文件
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
defer file.Close()
// 验证文件类型
if filepath.Ext(header.Filename) != ".pdf" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": file type must be pdf",
Timestamp: getCurrentTimestamp(),
})
return
}
// 构建文件保存路径
uploadDir := filepath.Join("..", "files", "invoices")
filename := invoiceNo + "_" + strconv.FormatInt(time.Now().Unix(), 10) + ".pdf"
filePath := filepath.Join(uploadDir, filename)
// 保存文件
if err := c.SaveUploadedFile(header, filePath); err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: "900004",
Message: i18n.GetI18nErrorMessage("900004", lang) + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
// 返回相对URL
fileURL := "/files/invoices/" + filename
response := models.InvoiceUploadResponse{
FileURL: fileURL,
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: response,
Timestamp: getCurrentTimestamp(),
})
}
// DownloadByToken 公共发票下载(通过token)
// @Summary 公共发票下载
// @Description 通过下载token下载发票文件,无需登录验证
// @Tags 发票管理
// @Accept json
// @Produce application/pdf
// @Param token query string true "下载token"
// @Success 200 {file} binary "发票文件"
// @Failure 400 {object} models.ErrorResponse "token无效"
// @Failure 404 {object} models.ErrorResponse "文件不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/public/invoices/download [get]
func (h *AdminInvoiceHandler) DownloadByToken(c *gin.Context) {
lang := middleware.GetLanguage(c)
token := c.Query("token")
if token == "" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": token is required",
Timestamp: getCurrentTimestamp(),
})
return
}
fileURL, err := h.invoiceService.DownloadByToken(c.Request.Context(), token)
if err != nil {
handleI18nError(c, err, lang)
return
}
// 重定向到文件URL
c.Redirect(http.StatusFound, fileURL)
}
// 辅助函数
// getCuUserID 获取客户用户ID
func getCuUserID(c *gin.Context) string {
if userID, exists := c.Get("cu_user_id"); exists {
if id, ok := userID.(string); ok {
return id
}
}
return ""
}
// getUserID 获取管理员用户ID
func getUserID(c *gin.Context) string {
if userID, exists := c.Get("user_id"); exists {
if id, ok := userID.(string); ok {
return id
}
}
return ""
}
// handleI18nError 处理国际化错误
func handleI18nError(c *gin.Context, err error, lang string) {
if i18nErr, ok := err.(*i18n.I18nError); ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 默认错误处理
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: "900004",
Message: i18n.GetI18nErrorMessage("900004", lang),
Timestamp: getCurrentTimestamp(),
})
}
@@ -0,0 +1,307 @@
package handlers
import (
"net/http"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"github.com/gin-gonic/gin"
)
type LeadHandler struct {
leadService service.LeadService
}
func NewLeadHandler(leadService service.LeadService) *LeadHandler {
return &LeadHandler{
leadService: leadService,
}
}
// CreateLead 创建线索
// @Summary 创建线索
// @Description 用户提交企业线索,无需鉴权
// @Tags 企业线索
// @Accept json
// @Produce json
// @Param request body models.LeadCreateRequest true "线索创建请求"
// @Success 200 {object} models.APIResponse{data=models.LeadResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/leads [post]
func (h *LeadHandler) CreateLead(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.LeadCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lead, err := h.leadService.CreateLead(c.Request.Context(), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: lead.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// GetLeads 获取线索列表
// @Summary 获取线索列表
// @Description 管理员获取线索列表,支持分页和筛选
// @Tags 企业线索
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码,默认1" minimum(1)
// @Param page_size query int false "每页条数,默认10,最大100" minimum(1) maximum(100)
// @Param search query string false "关键词检索"
// @Param status query string false "状态筛选"
// @Success 200 {object} models.APIResponse{data=models.LeadListResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/leads [get]
func (h *LeadHandler) GetLeads(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.LeadListRequest
if err := c.ShouldBindQuery(&req); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
result, err := h.leadService.GetLeadList(c.Request.Context(), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: result,
Timestamp: getCurrentTimestamp(),
})
}
// GetLeadSummary 获取线索汇总
// @Summary 获取线索汇总
// @Description 管理员获取企业线索汇总统计信息
// @Tags 企业线索
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.LeadSummaryResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/leads/summary [get]
func (h *LeadHandler) GetLeadSummary(c *gin.Context) {
lang := middleware.GetLanguage(c)
summary, err := h.leadService.GetLeadSummary(c.Request.Context())
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: summary,
Timestamp: getCurrentTimestamp(),
})
}
// GetLead 获取线索详情
// @Summary 获取线索详情
// @Description 根据ID获取线索详细信息
// @Tags 企业线索
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "线索ID"
// @Success 200 {object} models.APIResponse{data=models.LeadResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "线索不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/leads/{id} [get]
func (h *LeadHandler) GetLead(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
lead, err := h.leadService.GetLead(c.Request.Context(), id)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: lead.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// UpdateLead 更新线索
// @Summary 更新线索
// @Description 更新线索信息,用于线索跟进
// @Tags 企业线索
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "线索ID"
// @Param request body models.LeadUpdateRequest true "线索更新请求"
// @Success 200 {object} models.APIResponse{data=models.LeadResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "线索不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/leads/{id} [put]
func (h *LeadHandler) UpdateLead(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
var req models.LeadUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lead, err := h.leadService.UpdateLead(c.Request.Context(), id, &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: lead.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// DeleteLead 删除线索
// @Summary 删除线索
// @Description 删除指定线索
// @Tags 企业线索
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "线索ID"
// @Success 200 {object} models.APIResponse{} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "线索不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/leads/{id} [delete]
func (h *LeadHandler) DeleteLead(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
if err := h.leadService.DeleteLead(c.Request.Context(), id); err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: nil,
Timestamp: getCurrentTimestamp(),
})
}
// UpdateLeadStatus 更新线索状态
// @Summary 更新线索状态
// @Description 快速更新线索状态
// @Tags 企业线索
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "线索ID"
// @Param status query string true "状态: pending/contacted/converted/invalid"
// @Success 200 {object} models.APIResponse{data=models.LeadResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "线索不存在"
// @Router /api/leads/{id}/status [put]
func (h *LeadHandler) UpdateLeadStatus(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
status := c.Query("status")
if status == "" {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 验证状态值
validStatuses := map[string]bool{
string(models.LeadStatusPending): true,
string(models.LeadStatusContacted): true,
string(models.LeadStatusConverted): true,
string(models.LeadStatusInvalid): true,
}
if !validStatuses[status] {
statusCode, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(statusCode, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
req := &models.LeadUpdateRequest{
Status: status,
}
lead, err := h.leadService.UpdateLead(c.Request.Context(), id, req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: lead.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
@@ -0,0 +1,275 @@
package handlers
import (
"net/http"
"strconv"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"github.com/gin-gonic/gin"
)
type PackageHandler struct {
packageService service.PackageService
}
func NewPackageHandler(packageService service.PackageService) *PackageHandler {
return &PackageHandler{
packageService: packageService,
}
}
// GetPackages 获取套餐列表
// @Summary 获取套餐列表
// @Description 管理员获取套餐列表,支持类型和状态筛选
// @Tags 套餐管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param type query string false "套餐类型筛选"
// @Param status query int false "状态筛选,1-启用,0-禁用"
// @Success 200 {object} models.APIResponse{data=models.PackageListResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/packages [get]
func (h *PackageHandler) GetPackages(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.PackageListRequest
if err := c.ShouldBindQuery(&req); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
result, err := h.packageService.GetPackageList(c.Request.Context(), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: result,
Timestamp: getCurrentTimestamp(),
})
}
// GetPackageDetail 获取套餐详情
// @Summary 获取套餐详情
// @Description 根据ID获取套餐详细信息
// @Tags 套餐管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "套餐ID"
// @Success 200 {object} models.APIResponse{data=models.PackageResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "套餐不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/packages/{id} [get]
func (h *PackageHandler) GetPackageDetail(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
pkg, err := h.packageService.GetPackageDetail(c.Request.Context(), id)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: pkg.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// CreatePackage 创建套餐
// @Summary 创建套餐
// @Description 创建新的套餐
// @Tags 套餐管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body models.PackageCreateRequest true "套餐创建请求"
// @Success 200 {object} models.APIResponse{data=models.PackageResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/packages [post]
func (h *PackageHandler) CreatePackage(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.PackageCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
pkg, err := h.packageService.CreatePackage(c.Request.Context(), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: pkg.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// UpdatePackage 更新套餐
// @Summary 更新套餐
// @Description 更新指定套餐的信息
// @Tags 套餐管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "套餐ID"
// @Param request body models.PackageUpdateRequest true "套餐更新请求"
// @Success 200 {object} models.APIResponse{data=models.PackageResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "套餐不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/packages/{id} [put]
func (h *PackageHandler) UpdatePackage(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
var req models.PackageUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
pkg, err := h.packageService.UpdatePackage(c.Request.Context(), id, &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: pkg.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// DeletePackage 删除套餐
// @Summary 删除套餐
// @Description 删除指定套餐(软删除)
// @Tags 套餐管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "套餐ID"
// @Success 200 {object} models.APIResponse{} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "套餐不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/packages/{id} [delete]
func (h *PackageHandler) DeletePackage(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
if err := h.packageService.DeletePackage(c.Request.Context(), id); err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: nil,
Timestamp: getCurrentTimestamp(),
})
}
// UpdatePackageStatus 更新套餐状态
// @Summary 更新套餐状态
// @Description 启用或禁用套餐
// @Tags 套餐管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "套餐ID"
// @Param status query int true "状态,1-启用,0-禁用"
// @Success 200 {object} models.APIResponse{data=models.PackageResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "套餐不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/packages/{id}/status [put]
func (h *PackageHandler) UpdatePackageStatus(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
statusStr := c.Query("status")
if statusStr == "" {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
status, err := strconv.Atoi(statusStr)
if err != nil || (status != 0 && status != 1) {
statusCode, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(statusCode, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
req := &models.PackageUpdateRequest{
Status: &status,
}
pkg, err := h.packageService.UpdatePackage(c.Request.Context(), id, req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: pkg.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
@@ -0,0 +1,582 @@
package handlers
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"license-manager/internal/models"
"license-manager/internal/repository"
"license-manager/internal/service"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
type PublicRenewalHandler struct {
licenseRepo repository.LicenseRepository
customerRepo repository.CustomerRepository
cuUserRepo repository.CuUserRepository
packageService service.PackageService
cuOrderService service.CuOrderService
paymentService service.PaymentService
}
func NewPublicRenewalHandler(
licenseRepo repository.LicenseRepository,
customerRepo repository.CustomerRepository,
cuUserRepo repository.CuUserRepository,
packageService service.PackageService,
cuOrderService service.CuOrderService,
paymentService service.PaymentService,
) *PublicRenewalHandler {
return &PublicRenewalHandler{
licenseRepo: licenseRepo,
customerRepo: customerRepo,
cuUserRepo: cuUserRepo,
packageService: packageService,
cuOrderService: cuOrderService,
paymentService: paymentService,
}
}
type publicAuthCodeQueryRequest struct {
AuthorizationCode string `json:"authorization_code" binding:"required"`
}
type publicRenewalOrderRequest struct {
AuthorizationCode string `json:"authorization_code" binding:"required"`
PackageID string `json:"package_id" binding:"required"`
LicenseCount int `json:"license_count" binding:"required,min=1,max=1000"`
RenewDays int `json:"renew_days" binding:"required,min=1,max=3650"`
PaymentMethod string `json:"payment_method"`
ContactName string `json:"contact_name" binding:"required,max=100"`
ContactPhone string `json:"contact_phone" binding:"required,min=7,max=20"`
ContactEmail string `json:"contact_email" binding:"omitempty,email,max=255"`
CompanyName string `json:"company_name" binding:"omitempty,max=200"`
}
type publicPurchaseOrderRequest struct {
PackageID string `json:"package_id" binding:"required"`
LicenseCount int `json:"license_count" binding:"required,min=1,max=1000"`
PaymentMethod string `json:"payment_method"`
ContactName string `json:"contact_name" binding:"required,max=100"`
ContactPhone string `json:"contact_phone" binding:"required,min=7,max=20"`
ContactEmail string `json:"contact_email" binding:"omitempty,email,max=255"`
CompanyName string `json:"company_name" binding:"omitempty,max=200"`
CustomerType string `json:"customer_type" binding:"omitempty,oneof=individual enterprise government education"`
Description string `json:"description" binding:"omitempty,max=500"`
}
// QueryAuthorizationCode 公开查询授权码信息
func (h *PublicRenewalHandler) QueryAuthorizationCode(c *gin.Context) {
var req publicAuthCodeQueryRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: "请求参数错误",
Timestamp: getCurrentTimestamp(),
})
return
}
code := strings.TrimSpace(req.AuthorizationCode)
authCode, err := h.licenseRepo.GetAuthorizationCodeByCode(c.Request.Context(), code)
if err != nil {
c.JSON(http.StatusNotFound, models.ErrorResponse{
Code: "300001",
Message: "授权码不存在",
Timestamp: getCurrentTimestamp(),
})
return
}
customerName := ""
contactPerson := ""
contactPhone := ""
contactEmail := ""
customer, err := h.customerRepo.GetCustomerByID(c.Request.Context(), authCode.CustomerID)
if err == nil {
customerName = customer.CustomerName
contactPerson = customer.ContactPerson
if customer.Phone != nil {
contactPhone = *customer.Phone
}
if customer.Email != nil {
contactEmail = *customer.Email
}
}
activeCount, _ := h.licenseRepo.GetActiveLicenseCount(c.Request.Context(), authCode.ID)
status := "normal"
now := time.Now()
if authCode.IsLocked {
status = "locked"
} else if now.After(authCode.EndDate) {
status = "expired"
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: "success",
Data: gin.H{
"authorization_code_id": authCode.ID,
"authorization_code": authCode.Code,
"customer_id": authCode.CustomerID,
"customer_name": customerName,
"contact_person": contactPerson,
"contact_phone": contactPhone,
"contact_email": contactEmail,
"status": status,
"is_locked": authCode.IsLocked,
"start_date": authCode.StartDate.Format(time.RFC3339),
"end_date": authCode.EndDate.Format(time.RFC3339),
"max_activations": authCode.MaxActivations,
"active_licenses": activeCount,
},
Timestamp: getCurrentTimestamp(),
})
}
// CreateRenewalOrder 公开创建续期支付单
func (h *PublicRenewalHandler) CreateRenewalOrder(c *gin.Context) {
var req publicRenewalOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: "请求参数错误",
Timestamp: getCurrentTimestamp(),
})
return
}
authCode, err := h.licenseRepo.GetAuthorizationCodeByCode(c.Request.Context(), strings.TrimSpace(req.AuthorizationCode))
if err != nil {
c.JSON(http.StatusNotFound, models.ErrorResponse{
Code: "300001",
Message: "授权码不存在",
Timestamp: getCurrentTimestamp(),
})
return
}
if authCode.IsLocked {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "300003",
Message: "授权码已锁定,无法续期",
Timestamp: getCurrentTimestamp(),
})
return
}
_, err = h.packageService.GetPackageDetail(c.Request.Context(), req.PackageID)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "800002",
Message: "套餐不存在",
Timestamp: getCurrentTimestamp(),
})
return
}
priceResult, err := h.cuOrderService.CalculatePrice(c.Request.Context(), req.PackageID, req.LicenseCount)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "601004",
Message: "价格计算失败",
Timestamp: getCurrentTimestamp(),
})
return
}
customer, err := h.customerRepo.GetCustomerByID(c.Request.Context(), authCode.CustomerID)
if err == nil {
if strings.TrimSpace(req.CompanyName) != "" {
customer.CustomerName = strings.TrimSpace(req.CompanyName)
}
customer.ContactPerson = strings.TrimSpace(req.ContactName)
phone := strings.TrimSpace(req.ContactPhone)
if phone != "" {
customer.Phone = &phone
}
email := strings.TrimSpace(req.ContactEmail)
if email != "" {
customer.Email = &email
}
_ = h.customerRepo.UpdateCustomer(c.Request.Context(), customer)
}
cuUserID, err := h.ensureCuUser(c.Request.Context(), authCode.CustomerID, req.ContactPhone, req.ContactName, req.ContactEmail)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: "900004",
Message: "创建客户用户失败",
Timestamp: getCurrentTimestamp(),
})
return
}
renewalExtra := map[string]interface{}{
"authorization_code_id": authCode.ID,
"renew_days": req.RenewDays,
}
extraDataBytes, _ := json.Marshal(renewalExtra)
extraData := string(extraDataBytes)
paymentMethod := strings.TrimSpace(req.PaymentMethod)
if paymentMethod == "" {
paymentMethod = models.PaymentMethodAlipay
}
businessID := authCode.ID
payment, err := h.paymentService.CreatePayment(c.Request.Context(), &models.PaymentCreateRequest{
BusinessType: models.BusinessTypeLicenseExtend,
BusinessID: &businessID,
CustomerID: authCode.CustomerID,
CuUserID: cuUserID,
Amount: priceResult.TotalAmount,
Currency: "CNY",
PaymentMethod: paymentMethod,
ExtraData: &extraData,
})
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: "602001",
Message: "创建支付单失败",
Timestamp: getCurrentTimestamp(),
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: "success",
Data: gin.H{
"payment_no": payment.PaymentNo,
"payment_url": payment.PaymentURL,
"total_amount": payment.Amount,
"expire_time": payment.ExpireTime.Format(time.RFC3339),
"renew_days": req.RenewDays,
},
Timestamp: getCurrentTimestamp(),
})
}
// CreatePurchaseOrder 公开创建新购支付单
func (h *PublicRenewalHandler) CreatePurchaseOrder(c *gin.Context) {
var req publicPurchaseOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: "请求参数错误",
Timestamp: getCurrentTimestamp(),
})
return
}
pkg, err := h.packageService.GetPackageDetail(c.Request.Context(), req.PackageID)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "800002",
Message: "套餐不存在",
Timestamp: getCurrentTimestamp(),
})
return
}
priceResult, err := h.cuOrderService.CalculatePrice(c.Request.Context(), req.PackageID, req.LicenseCount)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "601004",
Message: "价格计算失败",
Timestamp: getCurrentTimestamp(),
})
return
}
customerID, cuUserID, err := h.ensureCustomerAndUser(
c.Request.Context(),
strings.TrimSpace(req.CompanyName),
strings.TrimSpace(req.ContactName),
strings.TrimSpace(req.ContactPhone),
strings.TrimSpace(req.ContactEmail),
strings.TrimSpace(req.CustomerType),
strings.TrimSpace(req.Description),
)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: "900004",
Message: "客户信息写入失败",
Timestamp: getCurrentTimestamp(),
})
return
}
orderReq := &models.CuOrderCreateRequest{
PackageID: req.PackageID,
LicenseCount: req.LicenseCount,
PaymentMethod: strings.TrimSpace(req.PaymentMethod),
}
if orderReq.PaymentMethod == "" {
orderReq.PaymentMethod = models.PaymentMethodAlipay
}
if priceResult.TotalAmount == 0 {
order, createErr := h.cuOrderService.CreateOrder(c.Request.Context(), cuUserID, customerID, orderReq)
if createErr != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "601001",
Message: "订单创建失败",
Timestamp: getCurrentTimestamp(),
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: "success",
Data: gin.H{
"order_id": order.ID,
"order_no": order.OrderNo,
"package_name": pkg.Name,
"total_amount": 0,
"license_count": req.LicenseCount,
"authorization_code": order.AuthorizationCode,
},
Timestamp: getCurrentTimestamp(),
})
return
}
order, err := h.cuOrderService.CreatePendingOrder(c.Request.Context(), cuUserID, customerID, orderReq)
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "601001",
Message: "订单创建失败",
Timestamp: getCurrentTimestamp(),
})
return
}
payment, err := h.paymentService.CreatePayment(c.Request.Context(), &models.PaymentCreateRequest{
BusinessType: models.BusinessTypePackageOrder,
BusinessID: &order.ID,
CustomerID: customerID,
CuUserID: cuUserID,
Amount: priceResult.TotalAmount,
Currency: "CNY",
PaymentMethod: orderReq.PaymentMethod,
})
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: "602001",
Message: "支付单创建失败",
Timestamp: getCurrentTimestamp(),
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: "success",
Data: gin.H{
"order_id": order.ID,
"order_no": order.OrderNo,
"package_name": pkg.Name,
"payment_no": payment.PaymentNo,
"payment_url": payment.PaymentURL,
"total_amount": payment.Amount,
"expire_time": payment.ExpireTime.Format(time.RFC3339),
"license_count": req.LicenseCount,
},
Timestamp: getCurrentTimestamp(),
})
}
// GetPublicPaymentStatus 公开支付状态查询
func (h *PublicRenewalHandler) GetPublicPaymentStatus(c *gin.Context) {
paymentNo := strings.TrimSpace(c.Param("payment_no"))
if paymentNo == "" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: "支付单号不能为空",
Timestamp: getCurrentTimestamp(),
})
return
}
statusResp, err := h.paymentService.GetPaymentStatus(c.Request.Context(), paymentNo)
if err != nil {
c.JSON(http.StatusNotFound, models.ErrorResponse{
Code: "602002",
Message: "支付单不存在",
Timestamp: getCurrentTimestamp(),
})
return
}
payment, err := h.paymentService.GetPayment(c.Request.Context(), paymentNo)
if err != nil {
c.JSON(http.StatusNotFound, models.ErrorResponse{
Code: "602002",
Message: "支付单不存在",
Timestamp: getCurrentTimestamp(),
})
return
}
authCodeID := ""
endDate := ""
if payment.ExtraData != nil && strings.TrimSpace(*payment.ExtraData) != "" {
var renewalData struct {
AuthorizationCodeID string `json:"authorization_code_id"`
}
if err := json.Unmarshal([]byte(*payment.ExtraData), &renewalData); err == nil {
authCodeID = renewalData.AuthorizationCodeID
if strings.TrimSpace(authCodeID) != "" {
authCode, err := h.licenseRepo.GetAuthorizationCodeByID(c.Request.Context(), authCodeID)
if err == nil {
endDate = authCode.EndDate.Format(time.RFC3339)
}
}
}
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: "success",
Data: gin.H{
"payment_no": statusResp.PaymentNo,
"status": statusResp.Status,
"amount": statusResp.Amount,
"payment_time": statusResp.PaymentTime,
"trade_no": statusResp.TradeNo,
"authorization_code_id": authCodeID,
"authorization_end_date": endDate,
"business_order": statusResp.BusinessOrder,
},
Timestamp: getCurrentTimestamp(),
})
}
func (h *PublicRenewalHandler) ensureCuUser(ctx context.Context, customerID, phone, contactName, contactEmail string) (string, error) {
countryCode := "+86"
trimmedPhone := strings.TrimSpace(phone)
user, err := h.cuUserRepo.GetByPhone(trimmedPhone, countryCode)
if err == nil {
return user.ID, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return "", err
}
var realName *string
name := strings.TrimSpace(contactName)
if name != "" {
realName = &name
}
var email *string
trimmedEmail := strings.TrimSpace(contactEmail)
if trimmedEmail != "" {
email = &trimmedEmail
}
newUser := &models.CuUser{
CustomerID: customerID,
Phone: trimmedPhone,
PhoneCountryCode: countryCode,
UserRole: "member",
RealName: realName,
Email: email,
Status: "active",
PhoneVerified: true,
EmailVerified: false,
Language: "zh-CN",
Timezone: "Asia/Shanghai",
}
if err := h.cuUserRepo.Create(newUser); err != nil {
return "", err
}
return newUser.ID, nil
}
func (h *PublicRenewalHandler) ensureCustomerAndUser(
ctx context.Context,
companyName, contactName, phone, email, customerType, description string,
) (string, string, error) {
countryCode := "+86"
trimmedPhone := strings.TrimSpace(phone)
existingUser, err := h.cuUserRepo.GetByPhone(trimmedPhone, countryCode)
if err == nil {
customer, customerErr := h.customerRepo.GetCustomerByID(ctx, existingUser.CustomerID)
if customerErr == nil {
if companyName != "" {
customer.CustomerName = companyName
}
if contactName != "" {
customer.ContactPerson = contactName
}
if trimmedPhone != "" {
customer.Phone = &trimmedPhone
}
if email != "" {
customer.Email = &email
}
if description != "" {
customer.Description = &description
}
_ = h.customerRepo.UpdateCustomer(ctx, customer)
}
return existingUser.CustomerID, existingUser.ID, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return "", "", err
}
ct := customerType
if ct == "" {
ct = "enterprise"
}
customerName := companyName
if customerName == "" {
customerName = contactName
}
if customerName == "" {
customerName = trimmedPhone
}
contactPerson := contactName
if contactPerson == "" {
contactPerson = trimmedPhone
}
customer := &models.Customer{
CustomerName: customerName,
CustomerType: ct,
ContactPerson: contactPerson,
CustomerLevel: "basic",
Status: "active",
CreatedBy: "public-order",
}
if trimmedPhone != "" {
customer.Phone = &trimmedPhone
}
if email != "" {
customer.Email = &email
}
if description != "" {
customer.Description = &description
}
if err := h.customerRepo.CreateCustomer(ctx, customer); err != nil {
return "", "", err
}
cuUserID, err := h.ensureCuUser(ctx, customer.ID, trimmedPhone, contactName, email)
if err != nil {
return "", "", err
}
return customer.ID, cuUserID, nil
}
+24 -2
View File
@@ -16,7 +16,7 @@ const (
CuUserPayloadKey = "cu_user"
)
// AuthMiddleware 认证中间件
// AuthMiddleware 认证中间件(支持自动延长)
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
authorizationHeader := c.GetHeader(AuthorizationHeaderKey)
@@ -81,6 +81,17 @@ func AuthMiddleware() gin.HandlerFunc {
return
}
// 检查是否需要自动延长token(单令牌延长机制)
if newToken, err := utils.RefreshTokenIfNeeded(accessToken); err == nil && newToken != accessToken {
// 自动延长token有效期,后端直接使用新token,客户端无需感知
accessToken = newToken
// 重新解析claims
newClaims, parseErr := utils.ParseToken(newToken)
if parseErr == nil {
claims = newClaims
}
}
// 将用户信息存储到上下文
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
@@ -156,7 +167,7 @@ func AdminOnlyMiddleware() gin.HandlerFunc {
}
}
// CustomerAuth 客户用户认证中间件
// CustomerAuth 客户用户认证中间件(支持自动延长)
func CustomerAuth() gin.HandlerFunc {
return func(c *gin.Context) {
authorizationHeader := c.GetHeader(AuthorizationHeaderKey)
@@ -217,6 +228,17 @@ func CustomerAuth() gin.HandlerFunc {
return
}
// 检查是否需要自动延长token(单令牌延长机制)
if newToken, err := utils.RefreshCuTokenIfNeeded(accessToken); err == nil && newToken != accessToken {
// 自动延长token有效期,后端直接使用新token,客户端无需感知
accessToken = newToken
// 重新解析claims
newClaims, parseErr := utils.ParseCuToken(newToken)
if parseErr == nil {
claims = newClaims
}
}
// 将客户用户信息存储到上下文
c.Set("cu_user_id", claims.UserID)
c.Set("cu_customer_id", claims.CustomerID)
+66 -2
View File
@@ -47,6 +47,10 @@ func SetupRouter() *gin.Engine {
licenseRepo := repository.NewLicenseRepository(db)
dashboardRepo := repository.NewDashboardRepository(db)
paymentRepo := repository.NewPaymentRepository(db)
cuInvoiceRepo := repository.NewCuInvoiceRepository(db)
adminInvoiceRepo := repository.NewAdminInvoiceRepository(db)
packageRepo := repository.NewPackageRepository(db)
leadRepo := repository.NewLeadRepository(db)
// 获取logger实例
log := logger.GetLogger()
@@ -88,7 +92,8 @@ func SetupRouter() *gin.Engine {
customerService := service.NewCustomerService(customerRepo)
cuUserService := service.NewCuUserService(cuUserRepo, customerRepo, smsService, db)
authCodeService := service.NewAuthorizationCodeService(authCodeRepo, customerRepo, cuUserRepo, licenseRepo)
cuOrderService := service.NewCuOrderService(cuOrderRepo, cuUserRepo, authCodeRepo, db)
packageService := service.NewPackageService(packageRepo, db)
cuOrderService := service.NewCuOrderService(cuOrderRepo, cuUserRepo, authCodeRepo, packageRepo, paymentRepo, cuInvoiceRepo, db)
cuDeviceService := service.NewCuDeviceService(licenseRepo)
paymentService := service.NewPaymentService(paymentRepo, cuOrderRepo, convertPaymentConfig(cfg.Payment), db)
enumService := service.NewEnumService()
@@ -101,18 +106,31 @@ func SetupRouter() *gin.Engine {
customerHandler := handlers.NewCustomerHandler(customerService)
cuAuthHandler := handlers.NewCuAuthHandler(cuUserService)
cuProfileHandler := handlers.NewCuProfileHandler(cuUserService)
cuOrderHandler := handlers.NewCuOrderHandler(cuOrderService, paymentService)
cuOrderHandler := handlers.NewCuOrderHandler(cuOrderService, paymentService, packageService)
cuDeviceHandler := handlers.NewCuDeviceHandler(cuDeviceService)
paymentHandler := handlers.NewPaymentHandler(paymentService)
cuAuthorizationHandler := handlers.NewCuAuthorizationHandler(authCodeService)
cuInvoiceService := service.NewCuInvoiceService(cuInvoiceRepo, cuOrderRepo, db)
cuInvoiceHandler := handlers.NewCuInvoiceHandler(cuInvoiceService)
enumHandler := handlers.NewEnumHandler(enumService)
authCodeHandler := handlers.NewAuthorizationCodeHandler(authCodeService)
licenseHandler := handlers.NewLicenseHandler(licenseService)
dashboardHandler := handlers.NewDashboardHandler(dashboardService)
adminInvoiceService := service.NewAdminInvoiceService(adminInvoiceRepo, cuOrderRepo, userRepo, cuUserRepo, db)
adminInvoiceHandler := handlers.NewAdminInvoiceHandler(adminInvoiceService)
packageHandler := handlers.NewPackageHandler(packageService)
leadService := service.NewLeadService(leadRepo, db)
leadHandler := handlers.NewLeadHandler(leadService)
publicRenewalHandler := handlers.NewPublicRenewalHandler(licenseRepo, customerRepo, cuUserRepo, packageService, cuOrderService, paymentService)
// 健康检测接口(无需认证)
router.GET("/health", systemHandler.HealthCheck)
// 静态文件服务 - 发票文件等
// 发票PDF上传保存路径为 "../files/invoices"
// 这里将URL前缀 /files 映射到 "../files" 目录,保证 /files/invoices 下的文件可被访问
router.Static("/files", "../files")
// Swagger文档路由
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.URL("/swagger/doc.json")))
@@ -131,6 +149,16 @@ func SetupRouter() *gin.Engine {
// 支付回调接口
public.POST("/payment/alipay/callback", paymentHandler.AlipayCallback)
public.POST("/v1/payment/alipay/callback", paymentHandler.AlipayCallback)
// 公共发票下载接口
public.GET("/public/invoices/download", adminInvoiceHandler.DownloadByToken)
public.POST("/public/authorization-codes/query", publicRenewalHandler.QueryAuthorizationCode)
public.POST("/public/orders", publicRenewalHandler.CreatePurchaseOrder)
public.POST("/public/renewals/order", publicRenewalHandler.CreateRenewalOrder)
public.GET("/public/payment/:payment_no/status", publicRenewalHandler.GetPublicPaymentStatus)
// 线索收集(无需鉴权)
public.POST("/leads", leadHandler.CreateLead)
}
// 需要认证的接口
@@ -176,6 +204,30 @@ func SetupRouter() *gin.Engine {
// 仪表盘接口
auth.GET("/v1/dashboard/authorization-trend", dashboardHandler.GetAuthorizationTrend)
auth.GET("/v1/dashboard/recent-authorizations", dashboardHandler.GetRecentAuthorizations)
// 发票管理(管理员)
auth.GET("/v1/invoices", adminInvoiceHandler.GetAdminInvoices)
auth.GET("/v1/invoices/:id", adminInvoiceHandler.GetAdminInvoiceDetail)
auth.GET("/v1/invoices/summary", adminInvoiceHandler.GetAdminInvoiceSummary)
auth.POST("/v1/invoices/:id/reject", adminInvoiceHandler.RejectInvoice)
auth.POST("/v1/invoices/:id/issue", adminInvoiceHandler.IssueInvoice)
auth.POST("/v1/invoices/upload", adminInvoiceHandler.UploadInvoiceFile)
// 套餐管理(管理员)
auth.GET("/packages", packageHandler.GetPackages)
auth.GET("/packages/:id", packageHandler.GetPackageDetail)
auth.POST("/packages", packageHandler.CreatePackage)
auth.PUT("/packages/:id", packageHandler.UpdatePackage)
auth.DELETE("/packages/:id", packageHandler.DeletePackage)
auth.PUT("/packages/:id/status", packageHandler.UpdatePackageStatus)
// 线索管理(管理员)
auth.GET("/leads", leadHandler.GetLeads)
auth.GET("/leads/summary", leadHandler.GetLeadSummary)
auth.GET("/leads/:id", leadHandler.GetLead)
auth.PUT("/leads/:id", leadHandler.UpdateLead)
auth.PUT("/leads/:id/status", leadHandler.UpdateLeadStatus)
auth.DELETE("/leads/:id", leadHandler.DeleteLead)
}
// 管理员接口
@@ -217,6 +269,8 @@ func SetupRouter() *gin.Engine {
cuAuth.POST("/orders/calculate", cuOrderHandler.CalculatePrice)
cuAuth.POST("/orders", cuOrderHandler.CreateOrder)
cuAuth.GET("/orders/:order_id", cuOrderHandler.GetOrder)
cuAuth.PUT("/orders/:order_id/cancel", cuOrderHandler.CancelOrder)
cuAuth.POST("/orders/:order_id/pay", cuOrderHandler.ContinuePay)
cuAuth.GET("/orders", cuOrderHandler.GetUserOrders)
cuAuth.GET("/orders/summary", cuOrderHandler.GetOrderSummary)
@@ -227,11 +281,21 @@ func SetupRouter() *gin.Engine {
// 授权码管理
cuAuth.POST("/authorization-codes/:codeId/share", cuAuthorizationHandler.ShareAuthorizationCode)
cuAuth.POST("/authorization-codes/product-activation-code", cuAuthorizationHandler.GetProductActivationCode)
cuAuth.GET("/authorization-codes", cuAuthorizationHandler.GetCuAuthorizationCodes)
cuAuth.GET("/authorization-codes/summary", cuAuthorizationHandler.GetCuAuthorizationCodeSummary)
// 设备管理
cuAuth.GET("/devices", cuDeviceHandler.GetDevices)
cuAuth.GET("/devices/summary", cuDeviceHandler.GetDeviceSummary)
cuAuth.DELETE("/devices/:id", cuDeviceHandler.UnbindDevice)
// 发票管理
cuAuth.POST("/invoices", cuInvoiceHandler.CreateInvoice)
cuAuth.GET("/invoices", cuInvoiceHandler.GetUserInvoices)
cuAuth.GET("/invoices/:id", cuInvoiceHandler.GetUserInvoiceDetail)
cuAuth.PUT("/invoices/:id", cuInvoiceHandler.UpdateInvoice)
cuAuth.GET("/invoices/summary", cuInvoiceHandler.GetUserInvoiceSummary)
cuAuth.GET("/invoices/:id/download", cuInvoiceHandler.DownloadInvoice)
}
}
+79
View File
@@ -29,6 +29,9 @@ func AutoMigrate() error {
&models.AuthorizationCode{},
&models.License{},
&models.AuthorizationChange{},
&models.Invoice{}, // 发票表
&models.Package{}, // 套餐表
&models.Lead{}, // 线索表
)
if err != nil {
return fmt.Errorf("failed to migrate database: %w", err)
@@ -49,6 +52,11 @@ func AutoMigrate() error {
return fmt.Errorf("failed to initialize default admin user: %w", err)
}
// 初始化默认套餐数据
if err := initDefaultPackages(); err != nil {
return fmt.Errorf("failed to initialize default packages: %w", err)
}
log.Println("Database auto migration completed successfully")
return nil
}
@@ -139,3 +147,74 @@ func initDefaultAdminUser() error {
return nil
}
// initDefaultPackages 初始化默认套餐数据
func initDefaultPackages() error {
var count int64
if err := DB.Model(&models.Package{}).Where("deleted_at IS NULL").Count(&count).Error; err != nil {
return fmt.Errorf("failed to count packages: %w", err)
}
if count > 0 {
log.Println("Packages already initialized, skipping")
return nil
}
// 默认套餐数据
packages := []models.Package{
{
Name: "试用版",
Type: "trial",
Price: 0,
PriceDescription: "免费",
DurationDescription: "当月25日到期",
Description: "免费体验全部功能",
Features: `["全部功能", "1个许可"]`,
Status: 1,
SortOrder: 1,
},
{
Name: "基础版",
Type: "basic",
Price: 300,
PriceDescription: "300元/许可",
DurationDescription: "永久有效",
Description: "小型企业最佳选择",
Features: `["基础功能", "批量许可购买", "折扣优惠"]`,
Status: 1,
SortOrder: 2,
},
{
Name: "专业版",
Type: "professional",
Price: 2000,
PriceDescription: "2000元/许可",
DurationDescription: "永久有效",
Description: "企业级完整解决方案",
Features: `["全部功能", "技术支持", "数据分析"]`,
Status: 1,
SortOrder: 3,
},
{
Name: "企业定制版",
Type: "custom",
Price: 0,
PriceDescription: "定制报价",
DurationDescription: "按需定制",
Description: "按需定制解决方案",
Features: `["按需定制", "专属服务"]`,
Status: 1,
SortOrder: 4,
},
}
for _, pkg := range packages {
if err := DB.Create(&pkg).Error; err != nil {
return fmt.Errorf("failed to create package %s: %w", pkg.Name, err)
}
log.Printf("Created default package: %s", pkg.Name)
}
log.Println("Default packages initialized successfully")
return nil
}
@@ -0,0 +1,35 @@
package models
// ========== 管理端发票请求/响应结构体 ==========
// InvoiceRejectRequest 发票驳回请求结构(管理端)
type InvoiceRejectRequest struct {
RejectReason string `json:"reject_reason" binding:"required,max=500"` // 驳回原因,必填,最大500字符
Suggestion *string `json:"suggestion" binding:"omitempty,max=500"` // 修改建议,非必填,最大500字符
}
// InvoiceIssueRequest 发票开票请求结构(管理端)
type InvoiceIssueRequest struct {
InvoiceFileURL string `json:"invoice_file_url" binding:"required,max=500"` // 发票文件URL,必填,最大500字符
IssuedAt string `json:"issued_at" binding:"required"` // 开票时间,必填,ISO 8601格式时间字符串
}
// InvoiceUploadRequest 发票文件上传请求结构(管理端)
type InvoiceUploadRequest struct {
InvoiceNo string `form:"invoice_no" binding:"required"` // 发票申请号,必填
}
// InvoiceUploadResponse 发票文件上传响应结构(管理端)
type InvoiceUploadResponse struct {
FileURL string `json:"file_url"` // 上传后的文件URL
}
// InvoiceDetailResponse 发票详情响应结构(包含关联信息,管理端使用)
type InvoiceDetailResponse struct {
*InvoiceResponse
OrderPackageName string `json:"order_package_name,omitempty"` // 关联订单的套餐名称
ApplicantName string `json:"applicant_name,omitempty"` // 发票申请人真实姓名
ApplicantPhone string `json:"applicant_phone,omitempty"` // 发票申请人电话号码
UploaderName string `json:"uploader_name,omitempty"` // 发票文件上传人姓名(管理员)
RejecterName string `json:"rejecter_name,omitempty"` // 发票驳回人姓名(管理员)
}
+3 -2
View File
@@ -132,6 +132,7 @@ type AuthorizationCodeListRequest struct {
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认20,最大100
CustomerID string `form:"customer_id" binding:"omitempty"` // 客户ID筛选
Code string `form:"code" binding:"omitempty"` // 授权码模糊匹配
Status string `form:"status" binding:"omitempty,oneof=normal locked expired"` // 状态筛选
StartDate string `form:"start_date" binding:"omitempty"` // 创建开始时间
EndDate string `form:"end_date" binding:"omitempty"` // 创建结束时间
@@ -263,8 +264,8 @@ type AuthorizationChangeListResponse struct {
// AuthorizationCodeShareRequest 授权码分享请求结构
type AuthorizationCodeShareRequest struct {
TargetUserID string `json:"target_user_id" binding:"required"` // 受赠用户ID
ShareCount int `json:"share_count" binding:"required,min=1"` // 分享激活次数
TargetContact string `json:"target_contact" binding:"required"` // 受赠用户联系方式(手机号或邮箱)
ShareCount int `json:"share_count" binding:"required,min=1"` // 分享激活次数
}
// AuthorizationCodeShareResponse 授权码分享响应结构
@@ -0,0 +1,39 @@
package models
// CuAuthorizationCodeListRequest 用户端授权码列表查询请求
type CuAuthorizationCodeListRequest struct {
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页数量,默认10,最大100
Status string `form:"status" binding:"omitempty,oneof=normal locked expired"`
Search string `form:"search" binding:"omitempty"` // 授权码模糊匹配(code LIKE %search%)
}
// CuAuthorizationCodeListItem 用户端授权码列表项
type CuAuthorizationCodeListItem struct {
ID string `json:"id"` // 授权码ID
Code string `json:"code"` // 授权码
Status string `json:"status"` // 状态
StatusDisplay string `json:"status_display,omitempty"` // 状态显示
MaxActivations int `json:"max_activations"` // 最大激活数量
CurrentActivations int `json:"current_activations"` // 当前激活数量
RemainingActivations int `json:"remaining_activations"` // 剩余激活数量
CreatedAt string `json:"created_at"` // 创建时间
EndDate string `json:"end_date"` // 到期时间
}
// CuAuthorizationCodeListResponse 用户端授权码列表响应
type CuAuthorizationCodeListResponse struct {
List []CuAuthorizationCodeListItem `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// CuAuthorizationCodeSummaryResponse 用户端授权信息统计响应
type CuAuthorizationCodeSummaryResponse struct {
TotalCount int64 `json:"total_count"` // 总授权码数量
ExpiredCount int64 `json:"expired_count"` // 已过期授权码数量
ValidCount int64 `json:"valid_count"` // 有效授权码数量
ValidMaxActivationsSum int64 `json:"valid_max_activations_sum"` // 有效授权码的 `max_activations` 之和
}
@@ -0,0 +1,52 @@
package models
// ========== 客户端发票请求/响应结构体 ==========
// InvoiceCreateRequest 客户端申请发票请求结构
// Content 非必填。
type InvoiceCreateRequest struct {
OrderID string `json:"order_id" binding:"required"` // 订单ID,必填
InvoiceType string `json:"invoice_type" binding:"required,oneof=personal enterprise vat_special"` // 发票类型,必填,personal-个人/enterprise-企业普票/vat_special-增值税专用发票
ReceiverEmail string `json:"receiver_email" binding:"required,email"` // 收票邮箱,必填,格式为邮箱地址
Title string `json:"title" binding:"required,max=200"` // 发票抬头,必填,最多200字符
TaxpayerID *string `json:"taxpayer_id,omitempty"` // 纳税人识别号,企业发票必填,个人发票可为空
Content string `json:"content" binding:"omitempty,max=200"` // 开票内容,非必填,最多200字符
Remark *string `json:"remark,omitempty"` // 备注,可选
}
// InvoiceUpdateRequest 客户端修改发票请求结构
type InvoiceUpdateRequest struct {
InvoiceType *string `json:"invoice_type" binding:"omitempty,oneof=personal enterprise vat_special"` // 发票类型,非必填,personal-个人/enterprise-企业普票/vat_special-增值税专用发票
ReceiverEmail *string `json:"receiver_email" binding:"omitempty,email"` // 收票邮箱,非必填,格式为邮箱地址
Title *string `json:"title" binding:"omitempty,max=200"` // 发票抬头,非必填,最多200字符
TaxpayerID *string `json:"taxpayer_id,omitempty"` // 纳税人识别号,企业发票必填,个人发票可为空
Content *string `json:"content" binding:"omitempty,max=200"` // 开票内容,非必填,最多200字符
Remark *string `json:"remark,omitempty"` // 备注,可选
}
// InvoiceListRequest 发票列表查询请求结构
// 注:apply_date 支持 YYYY-MM-DD 或 RFC3339 时间范围 "start,end"。
type InvoiceListRequest struct {
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认10,最大100
Status string `form:"status" binding:"omitempty"` // 状态筛选,pending-待处理/issued-已开票/rejected-已驳回
Search string `form:"search" binding:"omitempty"` // 搜索关键字,支持发票号或订单号模糊匹配
ApplyDate string `form:"apply_date" binding:"omitempty"` // 申请日期筛选,格式YYYY-MM-DD 或 RFC3339范围start,end
CustomerID string `form:"customer_id" binding:"omitempty"` // 客户ID筛选(管理端)
}
// InvoiceListResponse 发票列表响应结构
type InvoiceListResponse struct {
Invoices []*InvoiceResponse `json:"invoices"` // 发票列表
TotalCount int64 `json:"total_count"` // 总记录数
Page int `json:"page"` // 当前页码
PageSize int `json:"page_size"` // 每页条数
}
// InvoiceSummaryResponse 发票汇总统计响应结构
type InvoiceSummaryResponse struct {
TotalCount int64 `json:"total_count"` // 发票总数
PendingCount int64 `json:"pending_count"` // 待处理发票数量
IssuedCount int64 `json:"issued_count"` // 已开票发票数量
RejectedCount int64 `json:"rejected_count"` // 已驳回发票数量
}
+34 -18
View File
@@ -24,6 +24,9 @@ type CuOrder struct {
ExpiredAt *time.Time `gorm:"index" json:"expired_at"`
CreatedAt time.Time `gorm:"not null;index" json:"created_at"`
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
// 非持久化字段:是否已经申请发票(由服务层填充)
HasInvoiceApplied bool `gorm:"-" json:"-"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
@@ -64,23 +67,35 @@ type CuOrderCreateRequest struct {
PaymentMethod string `json:"payment_method,omitempty"` // 可选:支付方式,不传则为免费订单 支持:alipay,wechat
}
// CuOrderListRequest 订单列表查询请求结构
type CuOrderListRequest struct {
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认10,最大100
Search string `form:"search" binding:"omitempty"` // 订单号或授权码模糊匹配
Status string `form:"status" binding:"omitempty"` // 订单状态筛选
Time string `form:"time" binding:"omitempty"` // 时间筛选:today/week/month/three_months 或 今天/本周/本月/近三个月
}
// CuOrderResponse 订单响应结构
type CuOrderResponse struct {
ID string `json:"id"`
OrderNo string `json:"order_no"`
CustomerID string `json:"customer_id"`
CuUserID string `json:"cu_user_id"`
PackageID string `json:"package_id"`
PackageName string `json:"package_name"`
LicenseCount int `json:"license_count"`
UnitPrice float64 `json:"unit_price"`
DiscountRate float64 `json:"discount_rate"`
TotalAmount float64 `json:"total_amount"`
Status string `json:"status"`
AuthorizationCode *string `json:"authorization_code"`
ExpiredAt *time.Time `json:"expired_at"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID string `json:"id"` // 订单ID
OrderNo string `json:"order_no"` // 订单号,格式如ORD202601210123456789
CustomerID string `json:"customer_id"` // 客户ID
CuUserID string `json:"cu_user_id"` // 客户用户ID
PackageID string `json:"package_id"` // 套餐ID
PackageName string `json:"package_name"` // 套餐名称
LicenseCount int `json:"license_count"` // 许可数量
UnitPrice float64 `json:"unit_price"` // 单价,单位元
DiscountRate float64 `json:"discount_rate"` // 折扣率,0.0-1.0之间
TotalAmount float64 `json:"total_amount"` // 订单总金额,单位元
Status string `json:"status"` // 订单状态,pending-待支付/paid-已支付
AuthorizationCode *string `json:"authorization_code"` // 授权码,已支付订单生成
ExpiredAt *time.Time `json:"expired_at"` // 订单过期时间
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedAt time.Time `json:"updated_at"` // 更新时间
// 是否已申请发票(根据 invoices 表判断)
HasInvoiceApplied bool `json:"has_invoice_applied"`
}
// ToResponse 转换为响应结构
@@ -101,6 +116,7 @@ func (o *CuOrder) ToResponse() *CuOrderResponse {
ExpiredAt: o.ExpiredAt,
CreatedAt: o.CreatedAt,
UpdatedAt: o.UpdatedAt,
HasInvoiceApplied: o.HasInvoiceApplied,
}
}
@@ -114,7 +130,7 @@ type CuOrderListResponse struct {
// OrderSummaryResponse 订单汇总响应结构
type OrderSummaryResponse struct {
TotalOrders int64 `json:"total_orders"` // 订单总数
PendingOrders int64 `json:"pending_orders"` // 待支付订单数
PaidOrders int64 `json:"paid_orders"` // 已支付订单数
TotalOrders int64 `json:"total_orders"` // 订单总数
PendingOrders int64 `json:"pending_orders"` // 待支付订单数
PaidOrders int64 `json:"paid_orders"` // 已支付订单数
}
+57 -56
View File
@@ -75,55 +75,56 @@ func (u *CuUser) IsAccountLocked() bool {
return false
}
// CuUserRegisterRequest 客户用户注册请求结构
// CuUserRegisterRequest 客户用户注册请求结构
type CuUserRegisterRequest struct {
Phone string `json:"phone" binding:"required,len=11"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
SmsCode string `json:"sms_code" binding:"required,len=6"`
CustomerID string `json:"customer_id" binding:"omitempty"` // 可选,不提供则自动创建客户
Password string `json:"password" binding:"required,min=8,max=50"`
RealName *string `json:"real_name" binding:"omitempty,max=100"`
Email *string `json:"email" binding:"omitempty,email,max=255"`
Phone string `json:"phone" binding:"required,len=11"` // 手机号,必填,11位手机号
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
SmsCode string `json:"sms_code" binding:"required,len=6"` // 短信验证码,必填,6位数字
CustomerID string `json:"customer_id" binding:"omitempty"` // 客户ID,可选,不提供则自动创建客户
Password string `json:"password" binding:"required,min=8,max=50"` // 密码,必填,8-50位
RealName *string `json:"real_name" binding:"omitempty,max=100"` // 真实姓名,可选,最大100字符
Email *string `json:"email" binding:"omitempty,email,max=255"` // 邮箱,可选,格式为邮箱地址
}
// CuUserLoginRequest 客户用户登录请求结构
type CuUserLoginRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
Password string `json:"password"` // 密码登录时必填
SmsCode string `json:"sms_code"` // 验证码登录时必填
LoginType string `json:"login_type" binding:"required,oneof=password sms"` // 登录类型:password 或 sms
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-20位
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
Password string `json:"password"` // 密码,密码登录时必填
SmsCode string `json:"sms_code"` // 短信验证码,验证码登录时必填
LoginType string `json:"login_type" binding:"required,oneof=password sms"` // 登录类型,必填,password-密码登录/sms-验证码登录
}
// CuUserProfileUpdateRequest 客户用户资料更新请求结构
type CuUserProfileUpdateRequest struct {
RealName *string `json:"real_name" binding:"omitempty,max=100"`
Email *string `json:"email" binding:"omitempty,email,max=255"`
AvatarURL *string `json:"avatar_url" binding:"omitempty,max=500"`
Language *string `json:"language" binding:"omitempty,oneof=zh-CN en-US ja-JP"`
Timezone *string `json:"timezone" binding:"omitempty,max=50"`
AdditionalInfo *string `json:"additional_info" binding:"omitempty"`
Remark *string `json:"remark" binding:"omitempty,max=1000"`
RealName *string `json:"real_name" binding:"omitempty,max=100"` // 真实姓名,可选,最大100字符
Email *string `json:"email" binding:"omitempty,email,max=255"` // 邮箱,可选,格式为邮箱地址
AvatarURL *string `json:"avatar_url" binding:"omitempty,max=500"` // 头像URL,可选,最大500字符
Language *string `json:"language" binding:"omitempty,oneof=zh-CN en-US ja-JP"` // 语言偏好,可选,zh-CN-中文/en-US-英文/ja-JP-日文
Timezone *string `json:"timezone" binding:"omitempty,max=50"` // 时区,可选,最大50字符
AdditionalInfo *string `json:"additional_info" binding:"omitempty"` // 附加信息,可选,JSON格式
Remark *string `json:"remark" binding:"omitempty,max=1000"` // 备注,可选,最大1000字符
}
// CuUserPhoneUpdateRequest 客户用户手机号更新请求结构
type CuUserPhoneUpdateRequest struct {
NewPhone string `json:"new_phone" binding:"required,len=11"`
NewPhoneCountryCode string `json:"new_phone_country_code" binding:"required"`
CurrentSmsCode string `json:"current_sms_code" binding:"required,len=6"` // 当前手机号验证码
NewSmsCode string `json:"new_sms_code" binding:"required,len=6"` // 新手机号验证码
NewPhone string `json:"new_phone" binding:"required,len=11"` // 新手机号,必填,11位手机号
NewPhoneCountryCode string `json:"new_phone_country_code" binding:"required"` // 新手机号国家区号,必填
CurrentSmsCode string `json:"current_sms_code" binding:"required,len=6"` // 当前手机号验证码,必填,6位数字
NewSmsCode string `json:"new_sms_code" binding:"required,len=6"` // 新手机号验证码,必填,6位数字
}
// CuUserSendRegisterSmsRequest 注册发送验证码请求结构
type CuUserSendRegisterSmsRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-20位
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
}
// CuUserSendLoginSmsRequest 发送登录验证码请求结构
type CuUserSendLoginSmsRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-20位
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
}
// CuUserSendCurrentPhoneSmsRequest 发送当前手机号验证码请求结构
@@ -133,51 +134,51 @@ type CuUserSendCurrentPhoneSmsRequest struct {
// CuUserSendNewPhoneSmsRequest 发送新手机号验证码请求结构
type CuUserSendNewPhoneSmsRequest struct {
NewPhone string `json:"new_phone" binding:"required,min=7,max=20"`
NewPhoneCountryCode string `json:"new_phone_country_code" binding:"omitempty"` // 可选,默认+86
NewPhone string `json:"new_phone" binding:"required,min=7,max=20"` // 新手机号,必填,7-20位
NewPhoneCountryCode string `json:"new_phone_country_code" binding:"omitempty"` // 新手机号国家区号,可选,默认+86
}
// CuUserForgotPasswordRequest 忘记密码请求结构(发送重置验证码)
type CuUserForgotPasswordRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-20位
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
}
// CuUserResetPasswordRequest 重置密码请求结构
type CuUserResetPasswordRequest struct {
Phone string `json:"phone" binding:"required,min=7,max=20"`
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 可选,默认+86
SmsCode string `json:"sms_code" binding:"required,len=6"`
NewPassword string `json:"new_password" binding:"required,min=8,max=50"`
Phone string `json:"phone" binding:"required,min=7,max=20"` // 手机号,必填,7-20位
PhoneCountryCode string `json:"phone_country_code" binding:"omitempty"` // 国家区号,可选,默认+86
SmsCode string `json:"sms_code" binding:"required,len=6"` // 短信验证码,必填,6位数字
NewPassword string `json:"new_password" binding:"required,min=8,max=50"` // 新密码,必填,8-50位
}
// CuUserChangePasswordRequest 修改密码请求结构
type CuUserChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8,max=50"`
OldPassword string `json:"old_password" binding:"required"` // 旧密码,必填
NewPassword string `json:"new_password" binding:"required,min=8,max=50"` // 新密码,必填,8-50位
}
// CuUserResponse 用户信息响应结构(用于API,避免暴露敏感字段)
type CuUserResponse struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
Phone string `json:"phone"`
PhoneCountryCode string `json:"phone_country_code"`
UserRole string `json:"user_role"`
RealName *string `json:"real_name"`
Email *string `json:"email"`
Status string `json:"status"`
PhoneVerified bool `json:"phone_verified"`
EmailVerified bool `json:"email_verified"`
LastLoginAt *string `json:"last_login_at,omitempty"`
LastLoginIP *string `json:"last_login_ip,omitempty"`
AvatarURL *string `json:"avatar_url"`
Language string `json:"language"`
Timezone string `json:"timezone"`
AdditionalInfo *string `json:"additional_info"`
Remark *string `json:"remark"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
ID string `json:"id"` // 用户ID
CustomerID string `json:"customer_id"` // 客户ID
Phone string `json:"phone"` // 手机号
PhoneCountryCode string `json:"phone_country_code"` // 国家区号
UserRole string `json:"user_role"` // 用户角色,admin-管理员/member-成员
RealName *string `json:"real_name"` // 真实姓名
Email *string `json:"email"` // 邮箱
Status string `json:"status"` // 账户状态,active-激活/disabled-禁用
PhoneVerified bool `json:"phone_verified"` // 手机号是否已验证
EmailVerified bool `json:"email_verified"` // 邮箱是否已验证
LastLoginAt *string `json:"last_login_at,omitempty"` // 最后登录时间
LastLoginIP *string `json:"last_login_ip,omitempty"` // 最后登录IP
AvatarURL *string `json:"avatar_url"` // 头像URL
Language string `json:"language"` // 语言偏好
Timezone string `json:"timezone"` // 时区
AdditionalInfo *string `json:"additional_info"` // 附加信息,JSON格式
Remark *string `json:"remark"` // 备注
CreatedAt string `json:"created_at"` // 创建时间
UpdatedAt string `json:"updated_at"` // 更新时间
}
// ToResponse 转换为响应结构
+163
View File
@@ -0,0 +1,163 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// LeadStatus 线索状态
type LeadStatus string
const (
LeadStatusPending LeadStatus = "pending" // 待联系
LeadStatusContacted LeadStatus = "contacted" // 已联系
LeadStatusConverted LeadStatus = "converted" // 已成交
LeadStatusInvalid LeadStatus = "invalid" // 已失效
)
// Lead 企业线索
type Lead struct {
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
LeadNo string `gorm:"type:varchar(50);not null;uniqueIndex" json:"lead_no"`
CompanyName string `gorm:"type:varchar(200);not null" json:"company_name"`
ContactName string `gorm:"type:varchar(100);not null" json:"contact_name"`
ContactPhone string `gorm:"type:varchar(20);not null" json:"contact_phone"`
ContactEmail string `gorm:"type:varchar(100)" json:"contact_email"`
Requirement string `gorm:"type:text;not null" json:"requirement"`
ExtraInfo string `gorm:"type:text" json:"extra_info"`
Status string `gorm:"type:varchar(20);not null;default:'pending'" json:"status"`
FollowUpDate *time.Time `gorm:"" json:"follow_up_date"`
FollowUpRecord string `gorm:"type:text" json:"follow_up_record"`
InternalNote string `gorm:"type:text" json:"internal_note"`
CreatedAt time.Time `gorm:"not null" json:"created_at"`
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
}
// TableName 指定表名
func (Lead) TableName() string {
return "leads"
}
// BeforeCreate 创建前自动设置ID和时间戳
func (l *Lead) BeforeCreate(tx *gorm.DB) error {
if l.ID == "" {
l.ID = uuid.New().String()
}
now := time.Now()
if l.CreatedAt.IsZero() {
l.CreatedAt = now
}
if l.UpdatedAt.IsZero() {
l.UpdatedAt = now
}
// 生成线索编号
if l.LeadNo == "" {
l.LeadNo = generateLeadNo()
}
return nil
}
// generateLeadNo 生成线索编号
func generateLeadNo() string {
return "LEAD" + time.Now().Format("20060102150405") + randomString(6)
}
// randomString 生成随机字符串
func randomString(length int) string {
const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
result := make([]byte, length)
now := time.Now().UnixNano()
for i := range result {
result[i] = charset[(now+int64(i)*17)%int64(len(charset))]
}
return string(result)
}
// ToResponse 转换为响应结构
func (l *Lead) ToResponse() *LeadResponse {
return &LeadResponse{
ID: l.ID,
LeadNo: l.LeadNo,
CompanyName: l.CompanyName,
ContactName: l.ContactName,
ContactPhone: l.ContactPhone,
ContactEmail: l.ContactEmail,
Requirement: l.Requirement,
ExtraInfo: l.ExtraInfo,
Status: l.Status,
FollowUpDate: l.FollowUpDate,
FollowUpRecord: l.FollowUpRecord,
InternalNote: l.InternalNote,
CreatedAt: l.CreatedAt,
UpdatedAt: l.UpdatedAt,
}
}
// LeadResponse 线索响应结构
type LeadResponse struct {
ID string `json:"id"` // 线索ID
LeadNo string `json:"lead_no"` // 线索编号
CompanyName string `json:"company_name"` // 公司名称
ContactName string `json:"contact_name"` // 联系人
ContactPhone string `json:"contact_phone"` // 联系电话
ContactEmail string `json:"contact_email"` // 邮箱
Requirement string `json:"requirement"` // 需求描述
ExtraInfo string `json:"extra_info"` // 补充信息
Status string `json:"status"` // 状态
FollowUpDate *time.Time `json:"follow_up_date"` // 跟进日期(RFC3339,如 2026-02-05T10:00:00Z)
FollowUpRecord string `json:"follow_up_record"` // 跟进记录
InternalNote string `json:"internal_note"` // 内部备注
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedAt time.Time `json:"updated_at"` // 更新时间
}
// LeadCreateRequest 创建线索请求
type LeadCreateRequest struct {
CompanyName string `json:"company_name" binding:"required,min=1,max=200"` // 公司名称
ContactName string `json:"contact_name" binding:"required,min=1,max=100"` // 联系人
ContactPhone string `json:"contact_phone" binding:"required,min=1,max=20"` // 联系电话
ContactEmail string `json:"contact_email" binding:"omitempty,email,max=100"` // 邮箱
Requirement string `json:"requirement" binding:"omitempty"` // 需求描述
ExtraInfo string `json:"extra_info"` // 补充信息
}
// LeadUpdateRequest 更新线索请求
type LeadUpdateRequest struct {
CompanyName string `json:"company_name" binding:"omitempty,min=1,max=200"` // 公司名称
ContactName string `json:"contact_name" binding:"omitempty,min=1,max=100"` // 联系人
ContactPhone string `json:"contact_phone" binding:"omitempty,min=1,max=20"` // 联系电话
ContactEmail string `json:"contact_email" binding:"omitempty,email,max=100"` // 邮箱
Requirement string `json:"requirement" binding:"omitempty,min=1"` // 需求描述
ExtraInfo string `json:"extra_info"` // 补充信息
Status string `json:"status" binding:"omitempty,oneof=pending contacted converted invalid"` // 状态
FollowUpDate *time.Time `json:"follow_up_date"` // 跟进日期(RFC3339,如 2026-02-05T10:00:00Z)
FollowUpRecord string `json:"follow_up_record"` // 跟进记录
InternalNote string `json:"internal_note"` // 内部备注
}
// LeadListRequest 线索列表请求
type LeadListRequest struct {
Page int `form:"page" binding:"omitempty,min=1"`
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"`
Search string `form:"search"` // 关键词检索
Status string `form:"status"` // 状态筛选
}
// LeadListResponse 线索列表响应
type LeadListResponse struct {
Leads []*LeadResponse `json:"leads"`
TotalCount int64 `json:"total_count"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// LeadSummaryResponse 线索汇总响应
type LeadSummaryResponse struct {
TotalCount int64 `json:"total_count"` // 线索总数
PendingCount int64 `json:"pending_count"` // 待跟进数量
ContactedCount int64 `json:"contacted_count"` // 已跟进数量
ConvertedCount int64 `json:"converted_count"` // 已成交数量
InvalidCount int64 `json:"invalid_count"` // 已流失数量
}
+33 -22
View File
@@ -63,14 +63,14 @@ func (l *License) BeforeUpdate(tx *gorm.DB) error {
// LicenseListRequest 许可证列表查询请求结构
type LicenseListRequest struct {
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认20,最大100
AuthorizationCodeID string `form:"authorization_code_id" binding:"omitempty"` // 授权码ID筛选
CustomerID string `form:"customer_id" binding:"omitempty"` // 客户ID筛选
Status string `form:"status" binding:"omitempty,oneof=active inactive revoked"` // 状态筛选
IsOnline *bool `form:"is_online" binding:"omitempty"` // 在线状态筛选
Sort string `form:"sort" binding:"omitempty,oneof=created_at updated_at activated_at last_heartbeat"` // 排序字段,默认created_at
Order string `form:"order" binding:"omitempty,oneof=asc desc"` // 排序方向,默认desc
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认20,最大100
AuthorizationCodeID string `form:"authorization_code_id" binding:"omitempty"` // 授权码ID筛选
CustomerID string `form:"customer_id" binding:"omitempty"` // 客户ID筛选
Status string `form:"status" binding:"omitempty,oneof=active inactive revoked"` // 状态筛选
IsOnline *string `form:"is_online" binding:"omitempty"` // 在线状态筛选
Sort string `form:"sort" binding:"omitempty,oneof=created_at updated_at activated_at last_heartbeat"` // 排序字段,默认created_at
Order string `form:"order" binding:"omitempty,oneof=asc desc"` // 排序方向,默认desc
}
// LicenseListItem 许可证列表项结构
@@ -169,28 +169,39 @@ type HeartbeatResponse struct {
HeartbeatInterval int `json:"heartbeat_interval"` // 下次心跳间隔(秒)
}
// StatsOverviewResponse 授权概览统计响应结构
// StatsOverviewResponse stats overview API response
type StatsOverviewResponse struct {
TotalAuthCodes int64 `json:"total_auth_codes"` // 总授权码数量(所有授权码的累计总数)
ActiveLicenses int64 `json:"active_licenses"` // 活跃许可证数量(状态为active的许可证总数)
ExpiringSoon int64 `json:"expiring_soon"` // 即将过期数量(30天内即将过期的授权码,且未被锁定)
AbnormalAlerts int64 `json:"abnormal_alerts"` // 异常告警数量(活跃许可证中心跳超时的数量,超过配置的心跳超时时间未收到心跳)
GrowthRate GrowthRate `json:"growth_rate"` // 增长率(同比上月)
// Stock metrics
TotalAuthCodes int64 `json:"total_auth_codes"` // total authorization codes ever created
ActiveLicenses int64 `json:"active_licenses"` // licenses with status=active
// Flow metrics
TodayNewLicenses int64 `json:"today_new_licenses"` // licenses created today
YesterdayNewLicenses int64 `json:"yesterday_new_licenses"` // licenses created yesterday (for comparison)
MonthNewAuthCodes int64 `json:"month_new_auth_codes"` // auth codes created this calendar month
// Risk metrics
ExpiringIn7Days int64 `json:"expiring_in_7days"` // auth codes expiring within 7 days, not locked
ExpiringIn30Days int64 `json:"expiring_in_30days"` // auth codes expiring within 30 days, not locked
AbnormalAlerts int64 `json:"abnormal_alerts"` // active licenses with heartbeat timeout
// Growth rates shown as sub-text, not standalone cards
GrowthRate GrowthRate `json:"growth_rate"`
}
// GrowthRate 增长率结构(同比上月:当前值相比一个月前同一时刻值的增长率)
// GrowthRate month-over-month growth rates (shown as sub-text on dashboard cards)
type GrowthRate struct {
AuthCodes float64 `json:"auth_codes"` // 授权码增长率(%)(当前总授权码数相比一个月前总授权码数的增长率)
Licenses float64 `json:"licenses"` // 许可证增长率(%)(当前活跃许可证数相比一个月前活跃许可证数的增长率)
AuthCodesMoM float64 `json:"auth_codes_mom"` // auth codes MoM growth rate (%)
LicensesMoM float64 `json:"licenses_mom"` // active licenses MoM growth rate (%)
}
// DeviceListRequest 设备列表查询请求结构(客户用户接口)
type DeviceListRequest struct {
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页数量,默认20,最大100
DeviceName string `form:"device_name" binding:"omitempty,max=100"` // 设备名称模糊搜索(匹配device_info.name)
AuthorizationCodeID string `form:"authorization_code_id" binding:"omitempty,len=36"` // 按授权码ID筛选设备
IsOnline *bool `form:"is_online" binding:"omitempty"` // 是否在线筛选(true在线,false离线;不传不过滤)
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页数量,默认20,最大100
DeviceName string `form:"device_name" binding:"omitempty,max=100"` // 设备名称模糊搜索(匹配device_info.name)
AuthorizationCodeID string `form:"authorization_code_id" binding:"omitempty,len=36"` // 按授权码ID筛选设备
IsOnline *string `form:"is_online" binding:"omitempty"` // 是否在线筛选(true在线,false离线;不传不过滤)
}
// DeviceListItem 设备列表项结构(客户用户接口)
+152
View File
@@ -0,0 +1,152 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// PackageType 套餐类型
type PackageType string
const (
PackageTypeTrial PackageType = "trial" // 试用版
PackageTypeBasic PackageType = "basic" // 基础版
PackageTypeProfessional PackageType = "professional" // 专业版
PackageTypeCustom PackageType = "custom" // 定制版
)
// Package 套餐模型
type Package struct {
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
Name string `gorm:"type:varchar(100);not null" json:"name"`
Type string `gorm:"type:varchar(20);not null" json:"type"`
Price float64 `gorm:"type:decimal(10,2);not null;default:0" json:"price"`
PriceDescription string `gorm:"type:varchar(100);default:''" json:"price_description"`
DurationDescription string `gorm:"type:varchar(200);default:''" json:"duration_description"`
Description string `gorm:"type:varchar(500);default:''" json:"description"`
Features string `gorm:"type:text" json:"features"` // JSON数组格式
Status int `gorm:"type:tinyint(1);not null;default:1" json:"status"`
SortOrder int `gorm:"type:int;not null;default:0" json:"sort_order"`
Remark string `gorm:"type:varchar(500);default:''" json:"remark"`
CreatedAt time.Time `gorm:"not null" json:"created_at"`
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TableName 指定表名
func (Package) TableName() string {
return "packages"
}
// BeforeCreate 创建前自动设置ID和时间戳
func (p *Package) BeforeCreate(tx *gorm.DB) error {
if p.ID == "" {
p.ID = uuid.New().String()
}
now := time.Now()
if p.CreatedAt.IsZero() {
p.CreatedAt = now
}
if p.UpdatedAt.IsZero() {
p.UpdatedAt = now
}
return nil
}
// BeforeUpdate 更新前自动设置时间戳
func (p *Package) BeforeUpdate(tx *gorm.DB) error {
p.UpdatedAt = time.Now()
return nil
}
// ToResponse 转换为响应结构
func (p *Package) ToResponse() *PackageResponse {
return &PackageResponse{
ID: p.ID,
Name: p.Name,
Type: p.Type,
Price: p.Price,
PriceDescription: p.PriceDescription,
DurationDescription: p.DurationDescription,
Description: p.Description,
Features: p.Features,
Status: p.Status,
SortOrder: p.SortOrder,
Remark: p.Remark,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
}
// PackageResponse 套餐响应结构
type PackageResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Price float64 `json:"price"`
PriceDescription string `json:"price_description"`
DurationDescription string `json:"duration_description"`
Description string `json:"description"`
Features string `json:"features"`
Status int `json:"status"`
SortOrder int `json:"sort_order"`
Remark string `json:"remark"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// PackageCreateRequest 创建套餐请求
type PackageCreateRequest struct {
Name string `json:"name" binding:"required,min=1,max=100"`
Type string `json:"type" binding:"required,oneof=trial basic professional custom"`
Price float64 `json:"price" binding:"gte=0"`
PriceDescription string `json:"price_description" binding:"max=100"`
DurationDescription string `json:"duration_description" binding:"max=200"`
Description string `json:"description" binding:"max=500"`
Features string `json:"features"` // JSON格式
Status int `json:"status" binding:"oneof=0 1"`
SortOrder int `json:"sort_order"`
Remark string `json:"remark" binding:"max=500"`
}
// PackageUpdateRequest 更新套餐请求
type PackageUpdateRequest struct {
Name string `json:"name" binding:"omitempty,min=1,max=100"`
Type string `json:"type" binding:"omitempty,oneof=trial basic professional custom"`
Price float64 `json:"price" binding:"omitempty,gte=0"`
PriceDescription string `json:"price_description" binding:"omitempty,max=100"`
DurationDescription string `json:"duration_description" binding:"omitempty,max=200"`
Description string `json:"description" binding:"omitempty,max=500"`
Features string `json:"features"` // JSON格式
Status *int `json:"status" binding:"omitempty,oneof=0 1"`
SortOrder *int `json:"sort_order"`
Remark string `json:"remark" binding:"omitempty,max=500"`
}
// PackageListRequest 套餐列表请求
type PackageListRequest struct {
Type string `form:"type"` // 套餐类型筛选
Status *int `form:"status"` // 状态筛选
}
// PackageListResponse 套餐列表响应
type PackageListResponse struct {
Packages []*PackageResponse `json:"packages"`
TotalCount int64 `json:"total_count"`
}
// CuPackageResponse 用户端套餐响应(兼容原有结构)
type CuPackageResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Price float64 `json:"price"`
DisplayPrice string `json:"display_price"`
PriceDescription string `json:"price_description"`
MaxDevices int `json:"max_devices"` // 从features中解析
Description string `json:"description"`
Features string `json:"features"`
Details string `json:"details"`
}
+14 -13
View File
@@ -61,6 +61,7 @@ type PaymentCreateRequest struct {
Amount float64 `json:"amount" binding:"required,min=0.01"`
Currency string `json:"currency"`
PaymentMethod string `json:"payment_method"`
ExtraData *string `json:"extra_data,omitempty"`
}
// PaymentResponse 支付响应结构
@@ -109,27 +110,27 @@ func (p *Payment) ToResponse() *PaymentResponse {
// PaymentStatusResponse 支付状态响应结构
type PaymentStatusResponse struct {
PaymentNo string `json:"payment_no"`
Status string `json:"status"`
Amount float64 `json:"amount"`
PaymentTime *time.Time `json:"payment_time"`
TradeNo *string `json:"trade_no"`
BusinessOrder *PaymentBusinessOrder `json:"business_order,omitempty"`
PaymentNo string `json:"payment_no"` // 支付单号
Status string `json:"status"` // 支付状态,pending-待支付/paid-已支付/cancelled-已取消/expired-已过期/failed-支付失败
Amount float64 `json:"amount"` // 支付金额,单位元
PaymentTime *time.Time `json:"payment_time"` // 支付完成时间
TradeNo *string `json:"trade_no"` // 第三方交易号
BusinessOrder *PaymentBusinessOrder `json:"business_order,omitempty"` // 关联的业务订单信息
}
// PaymentBusinessOrder 支付关联的业务订单信息
type PaymentBusinessOrder struct {
OrderNo string `json:"order_no"`
AuthorizationCode *string `json:"authorization_code"`
Status string `json:"status"`
OrderNo string `json:"order_no"` // 订单号
AuthorizationCode *string `json:"authorization_code"` // 生成的授权码
Status string `json:"status"` // 订单状态
}
// PaymentListResponse 支付列表响应结构
type PaymentListResponse struct {
Payments []*PaymentResponse `json:"payments"`
TotalCount int64 `json:"total_count"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Payments []*PaymentResponse `json:"payments"` // 支付记录列表
TotalCount int64 `json:"total_count"` // 总记录数
Page int `json:"page"` // 当前页码
PageSize int `json:"page_size"` // 每页条数
}
// 业务类型常量
@@ -0,0 +1,151 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ========== 共享发票数据模型 ==========
// Invoice 发票模型
type Invoice struct {
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
InvoiceNo string `gorm:"type:varchar(50);not null;uniqueIndex" json:"invoice_no"`
OrderID string `gorm:"type:varchar(36);not null" json:"order_id"`
OrderNo string `gorm:"type:varchar(50);not null;index" json:"order_no"`
CustomerID string `gorm:"type:varchar(36);not null;index" json:"customer_id"`
CuUserID string `gorm:"type:varchar(36);not null;index" json:"cu_user_id"`
Amount float64 `gorm:"type:decimal(10,2);not null" json:"amount"`
Status string `gorm:"type:varchar(20);not null;default:'pending';index" json:"status"`
InvoiceType string `gorm:"type:varchar(20);not null" json:"invoice_type"`
Title string `gorm:"type:varchar(200);not null" json:"title"`
TaxpayerID *string `gorm:"type:varchar(50)" json:"taxpayer_id"`
Content string `gorm:"type:varchar(200);not null" json:"content"`
ReceiverEmail string `gorm:"type:varchar(255);not null" json:"receiver_email"`
Remark *string `gorm:"type:varchar(1000)" json:"remark"`
InvoiceFileURL *string `gorm:"type:varchar(500)" json:"invoice_file_url"`
UploadedAt *time.Time `gorm:"" json:"uploaded_at"`
UploadedBy *string `gorm:"type:varchar(36)" json:"uploaded_by"`
IssuedAt *time.Time `gorm:"" json:"issued_at"`
RejectReason *string `gorm:"type:varchar(500)" json:"reject_reason"`
Suggestion *string `gorm:"type:varchar(500)" json:"suggestion"`
RejectedAt *time.Time `gorm:"" json:"rejected_at"`
RejectedBy *string `gorm:"type:varchar(36)" json:"rejected_by"`
DownloadToken *string `gorm:"type:varchar(64)" json:"download_token"`
CreatedAt time.Time `gorm:"not null;index" json:"created_at"`
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
}
// TableName 指定表名
func (Invoice) TableName() string {
return "invoices"
}
// BeforeCreate 创建前自动设置时间戳和ID
func (i *Invoice) BeforeCreate(tx *gorm.DB) error {
// 生成UUID作为主键ID
if i.ID == "" {
i.ID = uuid.New().String()
}
// 设置时间戳
now := time.Now()
if i.CreatedAt.IsZero() {
i.CreatedAt = now
}
if i.UpdatedAt.IsZero() {
i.UpdatedAt = now
}
return nil
}
// BeforeUpdate 更新前自动设置时间戳
func (i *Invoice) BeforeUpdate(tx *gorm.DB) error {
i.UpdatedAt = time.Now()
return nil
}
// ========== 发票枚举常量 ==========
// 发票状态枚举
const (
InvoiceStatusPending = "pending" // 待处理
InvoiceStatusIssued = "issued" // 已开票
InvoiceStatusRejected = "rejected" // 已驳回
)
// 发票类型枚举
const (
InvoiceTypePersonal = "personal" // 个人
InvoiceTypeEnterprise = "enterprise" // 企业普票
InvoiceTypeVATSpecial = "vat_special" // 增值税专用发票
)
// ========== 共享响应结构体 ==========
// InvoiceResponse 发票响应结构
type InvoiceResponse struct {
ID string `json:"id"` // 发票ID
InvoiceNo string `json:"invoice_no"` // 发票申请号,格式如INV202601210123456789
OrderID string `json:"order_id"` // 关联的订单ID
OrderNo string `json:"order_no"` // 关联的订单号
CustomerID string `json:"customer_id"` // 客户ID
CuUserID string `json:"cu_user_id"` // 申请人客户用户ID
CuUserPhone string `json:"cu_user_phone"` // 申请人客户用户手机号
Amount float64 `json:"amount"` // 发票金额,单位元,保留2位小数
Status string `json:"status"` // 发票状态,pending-待处理/issued-已开票/rejected-已驳回
StatusDisplay string `json:"status_display"` // 发票状态显示文本,根据当前语言显示
InvoiceType string `json:"invoice_type"` // 发票类型,personal-个人/enterprise-企业普票/vat_special-增值税专用发票
InvoiceTypeDisplay string `json:"invoice_type_display"` // 发票类型显示文本,根据当前语言显示
Title string `json:"title"` // 发票抬头
TaxpayerID *string `json:"taxpayer_id"` // 纳税人识别号,企业发票填写
Content string `json:"content"` // 开票内容
ReceiverEmail string `json:"receiver_email"` // 收票邮箱
Remark *string `json:"remark"` // 备注信息
InvoiceFileURL *string `json:"invoice_file_url"` // 发票文件下载URL,已开票后填写
UploadedAt *time.Time `json:"uploaded_at"` // 发票文件上传时间
UploadedBy *string `json:"uploaded_by"` // 发票文件上传人ID(管理员)
IssuedAt *time.Time `json:"issued_at"` // 发票开票完成时间
RejectReason *string `json:"reject_reason"` // 驳回原因,已驳回时填写
Suggestion *string `json:"suggestion"` // 修改建议,已驳回时填写
RejectedAt *time.Time `json:"rejected_at"` // 驳回时间
RejectedBy *string `json:"rejected_by"` // 驳回人ID(管理员)
DownloadToken *string `json:"download_token"` // 下载令牌,用于邮件链接下载
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedAt time.Time `json:"updated_at"` // 更新时间
}
// ToResponse 转换为响应结构(不包含多语言显示字段)
func (i *Invoice) ToResponse() *InvoiceResponse {
return &InvoiceResponse{
ID: i.ID,
InvoiceNo: i.InvoiceNo,
OrderID: i.OrderID,
OrderNo: i.OrderNo,
CustomerID: i.CustomerID,
CuUserID: i.CuUserID,
Amount: i.Amount,
Status: i.Status,
InvoiceType: i.InvoiceType,
Title: i.Title,
TaxpayerID: i.TaxpayerID,
Content: i.Content,
ReceiverEmail: i.ReceiverEmail,
Remark: i.Remark,
InvoiceFileURL: i.InvoiceFileURL,
UploadedAt: i.UploadedAt,
UploadedBy: i.UploadedBy,
IssuedAt: i.IssuedAt,
RejectReason: i.RejectReason,
Suggestion: i.Suggestion,
RejectedAt: i.RejectedAt,
RejectedBy: i.RejectedBy,
DownloadToken: i.DownloadToken,
CreatedAt: i.CreatedAt,
UpdatedAt: i.UpdatedAt,
}
}
@@ -0,0 +1,207 @@
package repository
import (
"context"
"license-manager/internal/models"
"strings"
"time"
"gorm.io/gorm"
)
// AdminInvoiceRepository 管理端发票仓储接口
type AdminInvoiceRepository interface {
// ========== 管理端专用方法 ==========
GetByCustomerID(customerID string, offset, limit int) ([]*models.Invoice, int64, error)
GetAdminList(ctx context.Context, req *models.InvoiceListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.Invoice, int64, error)
GetAdminInvoiceSummary(ctx context.Context) (*models.InvoiceSummaryResponse, error)
// ========== 通用工具方法 ==========
GetByID(id string) (*models.Invoice, error)
GetByInvoiceNo(invoiceNo string) (*models.Invoice, error)
GetByOrderID(orderID string) (*models.Invoice, error)
Update(invoice *models.Invoice) error
Delete(id string) error
GetByDownloadToken(token string) (*models.Invoice, error)
UpdateDownloadToken(id string, token *string) error
}
type adminInvoiceRepository struct {
db *gorm.DB
}
func NewAdminInvoiceRepository(db *gorm.DB) AdminInvoiceRepository {
return &adminInvoiceRepository{db: db}
}
func (r *adminInvoiceRepository) GetByID(id string) (*models.Invoice, error) {
var invoice models.Invoice
err := r.db.Where("id = ? AND deleted_at IS NULL", id).First(&invoice).Error
if err != nil {
return nil, err
}
return &invoice, nil
}
func (r *adminInvoiceRepository) GetByInvoiceNo(invoiceNo string) (*models.Invoice, error) {
var invoice models.Invoice
err := r.db.Where("invoice_no = ? AND deleted_at IS NULL", invoiceNo).First(&invoice).Error
if err != nil {
return nil, err
}
return &invoice, nil
}
func (r *adminInvoiceRepository) GetByOrderID(orderID string) (*models.Invoice, error) {
var invoice models.Invoice
err := r.db.Where("order_id = ? AND deleted_at IS NULL", orderID).First(&invoice).Error
if err != nil {
return nil, err
}
return &invoice, nil
}
func (r *adminInvoiceRepository) GetByCustomerID(customerID string, offset, limit int) ([]*models.Invoice, int64, error) {
var invoices []*models.Invoice
var total int64
query := r.db.Model(&models.Invoice{}).Where("customer_id = ? AND deleted_at IS NULL", customerID)
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&invoices).Error
if err != nil {
return nil, 0, err
}
return invoices, total, nil
}
func (r *adminInvoiceRepository) GetAdminList(ctx context.Context, req *models.InvoiceListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.Invoice, int64, error) {
var invoices []*models.Invoice
var total int64
// 默认值
page := 1
pageSize := 10
search := ""
status := ""
customerID := ""
if req != nil {
if req.Page > 0 {
page = req.Page
}
if req.PageSize > 0 {
pageSize = req.PageSize
}
if pageSize > 100 {
pageSize = 100
}
search = strings.TrimSpace(req.Search)
status = strings.TrimSpace(req.Status)
customerID = strings.TrimSpace(req.CustomerID)
}
offset := (page - 1) * pageSize
query := r.db.WithContext(ctx).Model(&models.Invoice{}).Where("deleted_at IS NULL")
if status != "" {
query = query.Where("status = ?", status)
}
if customerID != "" {
query = query.Where("customer_id = ?", customerID)
}
if search != "" {
like := "%" + search + "%"
query = query.Where("(invoice_no LIKE ? OR order_no LIKE ?)", like, like)
}
if createdAtStart != nil && createdAtEnd != nil {
query = query.Where("created_at >= ? AND created_at < ?", *createdAtStart, *createdAtEnd)
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&invoices).Error
if err != nil {
return nil, 0, err
}
return invoices, total, nil
}
func (r *adminInvoiceRepository) Update(invoice *models.Invoice) error {
return r.db.Save(invoice).Error
}
func (r *adminInvoiceRepository) Delete(id string) error {
return r.db.Where("id = ?", id).Delete(&models.Invoice{}).Error
}
func (r *adminInvoiceRepository) GetAdminInvoiceSummary(ctx context.Context) (*models.InvoiceSummaryResponse, error) {
// 查询发票总数
var totalCount int64
err := r.db.Model(&models.Invoice{}).
Where("deleted_at IS NULL").
Count(&totalCount).Error
if err != nil {
return nil, err
}
// 查询待处理发票数
var pendingCount int64
err = r.db.Model(&models.Invoice{}).
Where("status = ? AND deleted_at IS NULL", models.InvoiceStatusPending).
Count(&pendingCount).Error
if err != nil {
return nil, err
}
// 查询已开票数
var issuedCount int64
err = r.db.Model(&models.Invoice{}).
Where("status = ? AND deleted_at IS NULL", models.InvoiceStatusIssued).
Count(&issuedCount).Error
if err != nil {
return nil, err
}
// 查询已驳回数
var rejectedCount int64
err = r.db.Model(&models.Invoice{}).
Where("status = ? AND deleted_at IS NULL", models.InvoiceStatusRejected).
Count(&rejectedCount).Error
if err != nil {
return nil, err
}
return &models.InvoiceSummaryResponse{
TotalCount: totalCount,
PendingCount: pendingCount,
IssuedCount: issuedCount,
RejectedCount: rejectedCount,
}, nil
}
func (r *adminInvoiceRepository) GetByDownloadToken(token string) (*models.Invoice, error) {
var invoice models.Invoice
err := r.db.Where("download_token = ? AND deleted_at IS NULL", token).First(&invoice).Error
if err != nil {
return nil, err
}
return &invoice, nil
}
func (r *adminInvoiceRepository) UpdateDownloadToken(id string, token *string) error {
return r.db.Model(&models.Invoice{}).Where("id = ?", id).Update("download_token", token).Error
}
@@ -4,6 +4,7 @@ import (
"context"
"license-manager/internal/models"
"math"
"strings"
"time"
"gorm.io/gorm"
@@ -64,10 +65,10 @@ func (r *authorizationCodeRepository) GetAuthorizationCodeList(ctx context.Conte
ac.start_date, ac.end_date, ac.max_activations,
COALESCE(l.active_count, 0) AS current_activations,
ac.deployment_type, ac.is_locked, ac.description, ac.created_at,
CASE
CASE
WHEN ac.is_locked = true THEN 'locked'
WHEN ac.end_date < NOW() THEN 'expired'
WHEN ac.start_date <= NOW() AND ac.end_date >= NOW() THEN 'normal'
WHEN ac.end_date >= NOW() THEN 'normal'
ELSE 'expired'
END AS status`).
Joins("LEFT JOIN customers c ON ac.customer_id = c.id AND c.deleted_at IS NULL").
@@ -91,7 +92,7 @@ func (r *authorizationCodeRepository) GetAuthorizationCodeList(ctx context.Conte
case "expired":
query = query.Where("ac.is_locked = false AND ac.end_date < NOW()")
case "normal":
query = query.Where("ac.is_locked = false AND ac.start_date <= NOW() AND ac.end_date >= NOW()")
query = query.Where("ac.is_locked = false AND ac.end_date >= NOW()")
}
}
@@ -101,6 +102,10 @@ func (r *authorizationCodeRepository) GetAuthorizationCodeList(ctx context.Conte
if req.EndDate != "" {
query = query.Where("ac.created_at <= ?", req.EndDate)
}
if strings.TrimSpace(req.Code) != "" {
like := "%" + strings.TrimSpace(req.Code) + "%"
query = query.Where("ac.code LIKE ?", like)
}
// 获取总数
var total int64
@@ -167,6 +172,140 @@ func (r *authorizationCodeRepository) GetAuthorizationCodeList(ctx context.Conte
}, nil
}
// GetCuAuthorizationCodeList 用户端:查询用户授权码列表
func (r *authorizationCodeRepository) GetCuAuthorizationCodeList(ctx context.Context, customerID string, req *models.CuAuthorizationCodeListRequest) (*models.CuAuthorizationCodeListResponse, error) {
page := 1
pageSize := 10
status := ""
search := ""
if req != nil {
if req.Page > 0 {
page = req.Page
}
if req.PageSize > 0 {
pageSize = req.PageSize
}
if pageSize > 100 {
pageSize = 100
}
status = req.Status
search = req.Search
}
query := r.db.WithContext(ctx).Table("authorization_codes ac").
Select(`ac.id, ac.code, ac.created_at, ac.end_date, ac.max_activations,
COALESCE(l.active_count, 0) AS current_activations,
CASE
WHEN ac.is_locked = true THEN 'locked'
WHEN ac.end_date < NOW() THEN 'expired'
WHEN ac.end_date >= NOW() THEN 'normal'
ELSE 'expired'
END AS status`).
Joins(`LEFT JOIN (
SELECT authorization_code_id, COUNT(*) AS active_count
FROM licenses
WHERE status = 'active' AND deleted_at IS NULL
GROUP BY authorization_code_id
) l ON ac.id = l.authorization_code_id`).
Where("ac.customer_id = ?", customerID)
if status != "" {
switch status {
case "locked":
query = query.Where("ac.end_date >= NOW() AND ac.is_locked = true")
case "expired":
query = query.Where("ac.end_date < NOW()")
case "normal":
query = query.Where("ac.end_date >= NOW() AND ac.is_locked = false")
}
}
if search != "" {
like := "%" + search + "%"
query = query.Where("ac.code LIKE ?", like)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
offset := (page - 1) * pageSize
var results []struct {
ID string `json:"id"`
Code string `json:"code"`
CreatedAt time.Time `json:"created_at"`
EndDate time.Time `json:"end_date"`
MaxActivations int `json:"max_activations"`
CurrentActivations int `json:"current_activations" gorm:"column:current_activations"`
Status string `json:"status"`
}
if err := query.Order("ac.created_at DESC").Limit(pageSize).Offset(offset).Scan(&results).Error; err != nil {
return nil, err
}
list := make([]models.CuAuthorizationCodeListItem, len(results))
for i, item := range results {
remaining := item.MaxActivations - item.CurrentActivations
if remaining < 0 {
remaining = 0
}
list[i] = models.CuAuthorizationCodeListItem{
ID: item.ID,
Code: item.Code,
Status: item.Status,
MaxActivations: item.MaxActivations,
CurrentActivations: item.CurrentActivations,
RemainingActivations: remaining,
CreatedAt: item.CreatedAt.Format(time.RFC3339),
EndDate: item.EndDate.Format(time.RFC3339),
}
}
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
return &models.CuAuthorizationCodeListResponse{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}, nil
}
// GetCuAuthorizationCodeSummary 用户端:授权信息统计
func (r *authorizationCodeRepository) GetCuAuthorizationCodeSummary(ctx context.Context, customerID string) (*models.CuAuthorizationCodeSummaryResponse, error) {
var result struct {
TotalCount int64 `gorm:"column:total_count"`
ExpiredCount int64 `gorm:"column:expired_count"`
ValidCount int64 `gorm:"column:valid_count"`
ValidMaxActivationsSum int64 `gorm:"column:valid_max_activations_sum"`
}
// 计算授权码统计信息
err := r.db.WithContext(ctx).Table("authorization_codes ac").
Select(`
COUNT(*) AS total_count,
SUM(CASE WHEN (ac.end_date < NOW() OR ac.is_locked = true) THEN 1 ELSE 0 END) AS expired_count,
SUM(CASE WHEN (ac.end_date >= NOW() AND ac.is_locked = false) THEN 1 ELSE 0 END) AS valid_count,
SUM(CASE WHEN (ac.end_date >= NOW() AND ac.is_locked = false)
THEN ac.max_activations
ELSE 0 END) AS valid_max_activations_sum
`).
Where("ac.customer_id = ?", customerID).
Scan(&result).Error
if err != nil {
return nil, err
}
return &models.CuAuthorizationCodeSummaryResponse{
TotalCount: result.TotalCount,
ExpiredCount: result.ExpiredCount,
ValidCount: result.ValidCount,
ValidMaxActivationsSum: result.ValidMaxActivationsSum,
}, nil
}
// UpdateAuthorizationCode 更新授权码
func (r *authorizationCodeRepository) UpdateAuthorizationCode(ctx context.Context, authCode *models.AuthorizationCode) error {
return r.db.WithContext(ctx).Save(authCode).Error
@@ -0,0 +1,156 @@
package repository
import (
"context"
"license-manager/internal/models"
"strings"
"time"
"gorm.io/gorm"
)
// CuInvoiceRepository 客户端发票仓储接口
type CuInvoiceRepository interface {
// ========== 客户端专用方法 ==========
GetByCuUserID(ctx context.Context, cuUserID string, req *models.InvoiceListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.Invoice, int64, error)
GetCuUserInvoiceSummary(ctx context.Context, cuUserID string) (*models.InvoiceSummaryResponse, error)
// ========== 通用工具方法 ==========
Create(invoice *models.Invoice) error
Update(invoice *models.Invoice) error
GetByID(id string) (*models.Invoice, error)
CheckOrderInvoiceExists(orderID string) (bool, error)
}
type cuInvoiceRepository struct {
db *gorm.DB
}
func NewCuInvoiceRepository(db *gorm.DB) CuInvoiceRepository {
return &cuInvoiceRepository{db: db}
}
func (r *cuInvoiceRepository) Create(invoice *models.Invoice) error {
return r.db.Create(invoice).Error
}
func (r *cuInvoiceRepository) Update(invoice *models.Invoice) error {
// 使用 Select 指定更新字段,确保可以清空某些字段(虽然此处目前主要是业务更新和状态重置)
// 或者直接使用 Save 更新全量字段
return r.db.Save(invoice).Error
}
func (r *cuInvoiceRepository) GetByID(id string) (*models.Invoice, error) {
var invoice models.Invoice
err := r.db.Where("id = ? AND deleted_at IS NULL", id).First(&invoice).Error
if err != nil {
return nil, err
}
return &invoice, nil
}
func (r *cuInvoiceRepository) GetByCuUserID(ctx context.Context, cuUserID string, req *models.InvoiceListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.Invoice, int64, error) {
var invoices []*models.Invoice
var total int64
// 默认值
page := 1
pageSize := 10
search := ""
status := ""
if req != nil {
if req.Page > 0 {
page = req.Page
}
if req.PageSize > 0 {
pageSize = req.PageSize
}
if pageSize > 100 {
pageSize = 100
}
search = strings.TrimSpace(req.Search)
status = strings.TrimSpace(req.Status)
}
offset := (page - 1) * pageSize
query := r.db.WithContext(ctx).Model(&models.Invoice{}).Where("cu_user_id = ? AND deleted_at IS NULL", cuUserID)
if status != "" {
query = query.Where("status = ?", status)
}
if search != "" {
like := "%" + search + "%"
query = query.Where("(invoice_no LIKE ? OR order_no LIKE ?)", like, like)
}
if createdAtStart != nil && createdAtEnd != nil {
query = query.Where("created_at >= ? AND created_at < ?", *createdAtStart, *createdAtEnd)
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取分页数据
err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&invoices).Error
if err != nil {
return nil, 0, err
}
return invoices, total, nil
}
func (r *cuInvoiceRepository) GetCuUserInvoiceSummary(ctx context.Context, cuUserID string) (*models.InvoiceSummaryResponse, error) {
// 查询发票总数
var totalCount int64
err := r.db.Model(&models.Invoice{}).
Where("cu_user_id = ? AND deleted_at IS NULL", cuUserID).
Count(&totalCount).Error
if err != nil {
return nil, err
}
// 查询待处理发票数
var pendingCount int64
err = r.db.Model(&models.Invoice{}).
Where("cu_user_id = ? AND status = ? AND deleted_at IS NULL", cuUserID, models.InvoiceStatusPending).
Count(&pendingCount).Error
if err != nil {
return nil, err
}
// 查询已开票数
var issuedCount int64
err = r.db.Model(&models.Invoice{}).
Where("cu_user_id = ? AND status = ? AND deleted_at IS NULL", cuUserID, models.InvoiceStatusIssued).
Count(&issuedCount).Error
if err != nil {
return nil, err
}
// 查询已驳回数
var rejectedCount int64
err = r.db.Model(&models.Invoice{}).
Where("cu_user_id = ? AND status = ? AND deleted_at IS NULL", cuUserID, models.InvoiceStatusRejected).
Count(&rejectedCount).Error
if err != nil {
return nil, err
}
return &models.InvoiceSummaryResponse{
TotalCount: totalCount,
PendingCount: pendingCount,
IssuedCount: issuedCount,
RejectedCount: rejectedCount,
}, nil
}
func (r *cuInvoiceRepository) CheckOrderInvoiceExists(orderID string) (bool, error) {
var count int64
err := r.db.Model(&models.Invoice{}).
Where("order_id = ? AND deleted_at IS NULL", orderID).
Count(&count).Error
return count > 0, err
}
@@ -3,6 +3,8 @@ package repository
import (
"context"
"license-manager/internal/models"
"strings"
"time"
"gorm.io/gorm"
)
@@ -11,7 +13,7 @@ type CuOrderRepository interface {
Create(order *models.CuOrder) error
GetByID(id string) (*models.CuOrder, error)
GetByOrderNo(orderNo string) (*models.CuOrder, error)
GetByCuUserID(cuUserID string, offset, limit int) ([]*models.CuOrder, int64, error)
GetByCuUserID(ctx context.Context, cuUserID string, req *models.CuOrderListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.CuOrder, int64, error)
GetByCustomerID(customerID string, offset, limit int) ([]*models.CuOrder, int64, error)
GetCustomerOrderSummary(ctx context.Context, customerID string) (*models.OrderSummaryResponse, error)
Update(order *models.CuOrder) error
@@ -49,11 +51,44 @@ func (r *cuOrderRepository) GetByOrderNo(orderNo string) (*models.CuOrder, error
return &order, nil
}
func (r *cuOrderRepository) GetByCuUserID(cuUserID string, offset, limit int) ([]*models.CuOrder, int64, error) {
func (r *cuOrderRepository) GetByCuUserID(ctx context.Context, cuUserID string, req *models.CuOrderListRequest, createdAtStart, createdAtEnd *time.Time) ([]*models.CuOrder, int64, error) {
var orders []*models.CuOrder
var total int64
query := r.db.Model(&models.CuOrder{}).Where("cu_user_id = ? AND deleted_at IS NULL", cuUserID)
// 默认值
page := 1
pageSize := 10
search := ""
status := ""
if req != nil {
if req.Page > 0 {
page = req.Page
}
if req.PageSize > 0 {
pageSize = req.PageSize
}
if pageSize > 100 {
pageSize = 100
}
search = strings.TrimSpace(req.Search)
status = strings.TrimSpace(req.Status)
}
offset := (page - 1) * pageSize
query := r.db.WithContext(ctx).Model(&models.CuOrder{}).Where("cu_user_id = ? AND deleted_at IS NULL", cuUserID)
if status != "" {
query = query.Where("status = ?", status)
}
if search != "" {
like := "%" + search + "%"
query = query.Where("(order_no LIKE ? OR authorization_code LIKE ?)", like, like)
}
if createdAtStart != nil && createdAtEnd != nil {
query = query.Where("created_at >= ? AND created_at < ?", *createdAtStart, *createdAtEnd)
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
@@ -61,7 +96,7 @@ func (r *cuOrderRepository) GetByCuUserID(cuUserID string, offset, limit int) ([
}
// 获取分页数据
err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&orders).Error
err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&orders).Error
if err != nil {
return nil, 0, err
}
@@ -94,7 +129,8 @@ func (r *cuOrderRepository) Update(order *models.CuOrder) error {
}
func (r *cuOrderRepository) Delete(id string) error {
return r.db.Where("id = ?", id).Delete(&models.CuOrder{}).Error
// 使用 Unscoped() 进行物理删除而不是软删除
return r.db.Unscoped().Where("id = ?", id).Delete(&models.CuOrder{}).Error
}
// GetPendingOrders 移除:不再有pending状态的订单
@@ -103,12 +139,17 @@ func (r *cuOrderRepository) Delete(id string) error {
func (r *cuOrderRepository) CheckTrialOrderExists(cuUserID string, currentMonth string) (bool, error) {
var count int64
startOfMonth := currentMonth + "-01"
endOfMonth := currentMonth + "-31"
startOfMonth := currentMonth + "-01 00:00:00"
endOfMonth := currentMonth + "-31 23:59:59"
err := r.db.Model(&models.CuOrder{}).
Where("cu_user_id = ? AND package_id = ? AND status = ? AND created_at >= ? AND created_at <= ? AND deleted_at IS NULL",
cuUserID, "trial", "paid", startOfMonth, endOfMonth).
Where("cu_orders.cu_user_id = ? AND (cu_orders.status = 'paid' OR cu_orders.status = 'pending') AND cu_orders.created_at >= ? AND cu_orders.created_at <= ? AND cu_orders.deleted_at IS NULL",
cuUserID, startOfMonth, endOfMonth).
// We need to join with package to check package type because package_id might be a UUID, not 'trial'.
// But if the order already recorded the type somewhere or if we just assume 'trial' is the package ID?
// Actually, let's just use a subquery or join to verify package type is trial.
Joins("JOIN packages ON packages.id = cu_orders.package_id").
Where("packages.type = ?", string(models.PackageTypeTrial)).
Count(&count).Error
return count > 0, err
@@ -92,6 +92,12 @@ type AuthorizationCodeRepository interface {
// UpdateMaxActivationsWithTx 在事务中更新授权码的最大激活次数
UpdateMaxActivationsWithTx(ctx context.Context, tx interface{}, authCodeID string, newMaxActivations int) error
// 用户端:查询用户授权码列表
GetCuAuthorizationCodeList(ctx context.Context, customerID string, req *models.CuAuthorizationCodeListRequest) (*models.CuAuthorizationCodeListResponse, error)
// 用户端:授权信息统计
GetCuAuthorizationCodeSummary(ctx context.Context, customerID string) (*models.CuAuthorizationCodeSummaryResponse, error)
}
// LicenseRepository 许可证数据访问接口
@@ -0,0 +1,131 @@
package repository
import (
"context"
"license-manager/internal/models"
"gorm.io/gorm"
)
// LeadRepository 线索仓储接口
type LeadRepository interface {
Create(lead *models.Lead) error
Update(lead *models.Lead) error
Delete(id string) error
GetByID(id string) (*models.Lead, error)
GetList(ctx context.Context, req *models.LeadListRequest) ([]*models.Lead, int64, error)
GetSummary(ctx context.Context) (*models.LeadSummaryResponse, error)
}
type leadRepository struct {
db *gorm.DB
}
// NewLeadRepository 创建线索仓储
func NewLeadRepository(db *gorm.DB) LeadRepository {
return &leadRepository{db: db}
}
func (r *leadRepository) Create(lead *models.Lead) error {
return r.db.Create(lead).Error
}
func (r *leadRepository) Update(lead *models.Lead) error {
return r.db.Save(lead).Error
}
func (r *leadRepository) Delete(id string) error {
return r.db.Unscoped().Where("id = ?", id).Delete(&models.Lead{}).Error
}
func (r *leadRepository) GetByID(id string) (*models.Lead, error) {
var lead models.Lead
err := r.db.Where("id = ?", id).First(&lead).Error
if err != nil {
return nil, err
}
return &lead, nil
}
func (r *leadRepository) GetList(ctx context.Context, req *models.LeadListRequest) ([]*models.Lead, int64, error) {
var leads []*models.Lead
var total int64
// 默认值
page := 1
pageSize := 10
search := ""
status := ""
if req != nil {
if req.Page > 0 {
page = req.Page
}
if req.PageSize > 0 {
pageSize = req.PageSize
}
if pageSize > 100 {
pageSize = 100
}
search = req.Search
status = req.Status
}
offset := (page - 1) * pageSize
query := r.db.WithContext(ctx).Model(&models.Lead{})
if status != "" {
query = query.Where("status = ?", status)
}
if search != "" {
like := "%" + search + "%"
query = query.Where("(company_name LIKE ? OR contact_name LIKE ? OR contact_phone LIKE ?)", like, like, like)
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取列表,按创建时间降序
err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&leads).Error
if err != nil {
return nil, 0, err
}
return leads, total, nil
}
func (r *leadRepository) GetSummary(ctx context.Context) (*models.LeadSummaryResponse, error) {
var row struct {
TotalCount int64 `gorm:"column:total_count"`
PendingCount int64 `gorm:"column:pending_count"`
ContactedCount int64 `gorm:"column:contacted_count"`
ConvertedCount int64 `gorm:"column:converted_count"`
InvalidCount int64 `gorm:"column:invalid_count"`
}
err := r.db.WithContext(ctx).Model(&models.Lead{}).Select(
"COUNT(*) as total_count, " +
"SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as pending_count, " +
"SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as contacted_count, " +
"SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as converted_count, " +
"SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as invalid_count",
string(models.LeadStatusPending),
string(models.LeadStatusContacted),
string(models.LeadStatusConverted),
string(models.LeadStatusInvalid),
).Scan(&row).Error
if err != nil {
return nil, err
}
return &models.LeadSummaryResponse{
TotalCount: row.TotalCount,
PendingCount: row.PendingCount,
ContactedCount: row.ContactedCount,
ConvertedCount: row.ConvertedCount,
InvalidCount: row.InvalidCount,
}, nil
}
@@ -66,13 +66,18 @@ func (r *licenseRepository) GetLicenseList(ctx context.Context, req *models.Lice
}
// 在线状态筛选
if req.IsOnline != nil {
// 在线状态:最后心跳时间在5分钟内
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
if *req.IsOnline {
query = query.Where("licenses.last_heartbeat >= ?", fiveMinutesAgo)
} else {
query = query.Where("licenses.last_heartbeat < ? OR licenses.last_heartbeat IS NULL", fiveMinutesAgo)
if req.IsOnline != nil && *req.IsOnline != "" {
cfg := config.GetConfig()
heartbeatTimeoutSeconds := cfg.License.HeartbeatTimeout
if heartbeatTimeoutSeconds <= 0 {
heartbeatTimeoutSeconds = 300 // 默认5分钟
}
onlineThreshold := time.Now().Add(-time.Duration(heartbeatTimeoutSeconds) * time.Second)
if *req.IsOnline == "true" {
query = query.Where("licenses.last_heartbeat >= ?", onlineThreshold)
} else if *req.IsOnline == "false" {
query = query.Where("licenses.last_heartbeat < ? OR licenses.last_heartbeat IS NULL", onlineThreshold)
}
}
@@ -106,12 +111,19 @@ func (r *licenseRepository) GetLicenseList(ctx context.Context, req *models.Lice
// 转换为响应格式
list := make([]models.LicenseListItem, len(licenses))
cfg := config.GetConfig()
heartbeatTimeoutSeconds := cfg.License.HeartbeatTimeout
if heartbeatTimeoutSeconds <= 0 {
heartbeatTimeoutSeconds = 300 // 默认5分钟
}
onlineThreshold := time.Now().Add(-time.Duration(heartbeatTimeoutSeconds) * time.Second)
for i, license := range licenses {
// 计算在线状态
isOnline := false
if license.LastHeartbeat != nil {
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
isOnline = license.LastHeartbeat.After(fiveMinutesAgo)
isOnline = license.LastHeartbeat.After(onlineThreshold)
}
// 格式化时间
@@ -169,8 +181,13 @@ func (r *licenseRepository) GetLicenseByID(ctx context.Context, id string) (*mod
// 计算在线状态
if license.LastHeartbeat != nil {
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
license.IsOnline = license.LastHeartbeat.After(fiveMinutesAgo)
cfg := config.GetConfig()
heartbeatTimeoutSeconds := cfg.License.HeartbeatTimeout
if heartbeatTimeoutSeconds <= 0 {
heartbeatTimeoutSeconds = 300 // 默认5分钟
}
onlineThreshold := time.Now().Add(-time.Duration(heartbeatTimeoutSeconds) * time.Second)
license.IsOnline = license.LastHeartbeat.After(onlineThreshold)
}
return &license, nil
@@ -238,8 +255,13 @@ func (r *licenseRepository) GetLicenseByKey(ctx context.Context, licenseKey stri
// 计算在线状态
if license.LastHeartbeat != nil {
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
license.IsOnline = license.LastHeartbeat.After(fiveMinutesAgo)
cfg := config.GetConfig()
heartbeatTimeoutSeconds := cfg.License.HeartbeatTimeout
if heartbeatTimeoutSeconds <= 0 {
heartbeatTimeoutSeconds = 300 // 默认5分钟
}
onlineThreshold := time.Now().Add(-time.Duration(heartbeatTimeoutSeconds) * time.Second)
license.IsOnline = license.LastHeartbeat.After(onlineThreshold)
}
return &license, nil
@@ -299,10 +321,10 @@ func (r *licenseRepository) GetCustomerDeviceList(ctx context.Context, customerI
}
// 在线状态筛选
if req.IsOnline != nil {
if *req.IsOnline {
if req.IsOnline != nil && *req.IsOnline != "" {
if *req.IsOnline == "true" {
query = query.Where("licenses.last_heartbeat > ?", onlineThreshold)
} else {
} else if *req.IsOnline == "false" {
query = query.Where("(licenses.last_heartbeat <= ? OR licenses.last_heartbeat IS NULL)", onlineThreshold)
}
}
@@ -355,11 +377,11 @@ func (r *licenseRepository) GetCustomerDeviceList(ctx context.Context, customerI
// 格式化时间字段
var activatedAtStr, lastHeartbeatStr *string
if result.ActivatedAt != nil {
str := result.ActivatedAt.Format("2006-01-02T15:04:05Z")
str := result.ActivatedAt.Format(time.RFC3339)
activatedAtStr = &str
}
if result.LastHeartbeat != nil {
str := result.LastHeartbeat.Format("2006-01-02T15:04:05Z")
str := result.LastHeartbeat.Format(time.RFC3339)
lastHeartbeatStr = &str
}
@@ -379,7 +401,7 @@ func (r *licenseRepository) GetCustomerDeviceList(ctx context.Context, customerI
AuthorizationInfo: models.AuthorizationInfo{
AuthorizationCode: result.AuthorizationCode,
AuthorizationCodeID: result.AuthorizationCodeID,
EndDate: result.EndDate.Format("2006-01-02T15:04:05Z"),
EndDate: result.EndDate.Format(time.RFC3339),
Description: description,
},
}
@@ -0,0 +1,101 @@
package repository
import (
"context"
"license-manager/internal/models"
"gorm.io/gorm"
)
// PackageRepository 套餐仓储接口
type PackageRepository interface {
Create(pkg *models.Package) error
Update(pkg *models.Package) error
Delete(id string) error
GetByID(id string) (*models.Package, error)
GetList(ctx context.Context, req *models.PackageListRequest) ([]*models.Package, int64, error)
GetEnabledList(ctx context.Context) ([]*models.Package, error)
GetByType(ctx context.Context, pkgType string) ([]*models.Package, error)
}
type packageRepository struct {
db *gorm.DB
}
// NewPackageRepository 创建套餐仓储
func NewPackageRepository(db *gorm.DB) PackageRepository {
return &packageRepository{db: db}
}
func (r *packageRepository) Create(pkg *models.Package) error {
return r.db.Create(pkg).Error
}
func (r *packageRepository) Update(pkg *models.Package) error {
return r.db.Save(pkg).Error
}
func (r *packageRepository) Delete(id string) error {
return r.db.Model(&models.Package{}).Where("id = ?", id).Update("deleted_at", gorm.Expr("NOW()")).Error
}
func (r *packageRepository) GetByID(id string) (*models.Package, error) {
var pkg models.Package
err := r.db.Where("id = ? AND deleted_at IS NULL", id).First(&pkg).Error
if err != nil {
return nil, err
}
return &pkg, nil
}
func (r *packageRepository) GetList(ctx context.Context, req *models.PackageListRequest) ([]*models.Package, int64, error) {
var packages []*models.Package
var total int64
query := r.db.WithContext(ctx).Model(&models.Package{}).Where("deleted_at IS NULL")
if req.Type != "" {
query = query.Where("type = ?", req.Type)
}
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
// 获取总数
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// 获取列表,按sort_order降序排序
err := query.Order("sort_order DESC, created_at DESC").Find(&packages).Error
if err != nil {
return nil, 0, err
}
return packages, total, nil
}
func (r *packageRepository) GetEnabledList(ctx context.Context) ([]*models.Package, error) {
var packages []*models.Package
err := r.db.WithContext(ctx).
Where("status = ? AND deleted_at IS NULL", 1).
Order("sort_order ASC, created_at ASC").
Find(&packages).Error
if err != nil {
return nil, err
}
return packages, nil
}
func (r *packageRepository) GetByType(ctx context.Context, pkgType string) ([]*models.Package, error) {
var packages []*models.Package
err := r.db.WithContext(ctx).
Where("type = ? AND status = ? AND deleted_at IS NULL", pkgType, 1).
Order("sort_order DESC").
Find(&packages).Error
if err != nil {
return nil, err
}
return packages, nil
}
@@ -39,7 +39,11 @@ func (r *paymentRepository) GetByPaymentNo(ctx context.Context, paymentNo string
func (r *paymentRepository) GetByBusinessID(ctx context.Context, businessType, businessID string) (*models.Payment, error) {
var payment models.Payment
err := r.db.WithContext(ctx).Where("business_type = ? AND business_id = ?", businessType, businessID).First(&payment).Error
// 按创建时间倒序,获取最新的支付单
err := r.db.WithContext(ctx).
Where("business_type = ? AND business_id = ?", businessType, businessID).
Order("created_at DESC").
First(&payment).Error
if err != nil {
return nil, err
}
@@ -0,0 +1,335 @@
package service
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"license-manager/internal/models"
"license-manager/internal/repository"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
"strings"
"time"
"gorm.io/gorm"
)
// AdminInvoiceService 管理端发票服务接口
type AdminInvoiceService interface {
// ========== 管理端发票操作 ==========
RejectInvoice(ctx context.Context, invoiceID string, adminID string, req *models.InvoiceRejectRequest) (*models.Invoice, error)
IssueInvoice(ctx context.Context, invoiceID string, adminID string, req *models.InvoiceIssueRequest) (*models.Invoice, error)
GetAdminInvoices(ctx context.Context, req *models.InvoiceListRequest) (*models.InvoiceListResponse, error)
GetAdminInvoiceSummary(ctx context.Context) (*models.InvoiceSummaryResponse, error)
GetInvoiceDetail(ctx context.Context, invoiceID string, isAdmin bool, userID string) (*models.InvoiceDetailResponse, error)
GenerateDownloadToken(ctx context.Context, invoiceID string) (string, error)
// ========== 公共发票操作 ==========
DownloadByToken(ctx context.Context, token string) (string, error)
}
type adminInvoiceService struct {
repo repository.AdminInvoiceRepository
orderRepo repository.CuOrderRepository
userRepo repository.UserRepository
cuUserRepo repository.CuUserRepository
db *gorm.DB
}
func NewAdminInvoiceService(repo repository.AdminInvoiceRepository, orderRepo repository.CuOrderRepository, userRepo repository.UserRepository, cuUserRepo repository.CuUserRepository, db *gorm.DB) AdminInvoiceService {
return &adminInvoiceService{
repo: repo,
orderRepo: orderRepo,
userRepo: userRepo,
cuUserRepo: cuUserRepo,
db: db,
}
}
// ========== 管理端发票操作实现 ==========
func (s *adminInvoiceService) RejectInvoice(ctx context.Context, invoiceID string, adminID string, req *models.InvoiceRejectRequest) (*models.Invoice, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
invoice, err := s.repo.GetByID(invoiceID)
if err != nil {
return nil, i18n.NewI18nError("700001", lang)
}
// 验证状态
if invoice.Status != models.InvoiceStatusPending {
return nil, i18n.NewI18nError("900001", lang)
}
now := time.Now()
invoice.Status = models.InvoiceStatusRejected
invoice.RejectReason = &req.RejectReason
invoice.Suggestion = req.Suggestion
invoice.RejectedAt = &now
invoice.RejectedBy = &adminID
invoice.UpdatedAt = now
err = s.repo.Update(invoice)
if err != nil {
return nil, err
}
return invoice, nil
}
func (s *adminInvoiceService) IssueInvoice(ctx context.Context, invoiceID string, adminID string, req *models.InvoiceIssueRequest) (*models.Invoice, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
invoice, err := s.repo.GetByID(invoiceID)
if err != nil {
return nil, i18n.NewI18nError("700001", lang)
}
prevStatus := invoice.Status
issuedAt, err := time.Parse(time.RFC3339, req.IssuedAt)
if err != nil {
return nil, i18n.NewI18nError("900001", lang)
}
now := time.Now()
invoice.Status = models.InvoiceStatusIssued
invoice.InvoiceFileURL = &req.InvoiceFileURL
invoice.IssuedAt = &issuedAt
invoice.UploadedAt = &now
invoice.UploadedBy = &adminID
invoice.UpdatedAt = now
// Clear reject info when issuing from rejected status.
if prevStatus == models.InvoiceStatusRejected {
invoice.RejectReason = nil
invoice.Suggestion = nil
invoice.RejectedAt = nil
invoice.RejectedBy = nil
}
err = s.repo.Update(invoice)
if err != nil {
return nil, err
}
return invoice, nil
}
func (s *adminInvoiceService) GetAdminInvoices(ctx context.Context, req *models.InvoiceListRequest) (*models.InvoiceListResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 解析日期筛选
var createdAtStart, createdAtEnd *time.Time
if req.ApplyDate != "" {
start, end, err := s.parseDateRange(req.ApplyDate)
if err != nil {
return nil, i18n.NewI18nError("900001", lang)
}
createdAtStart = start
createdAtEnd = end
}
invoices, total, err := s.repo.GetAdminList(ctx, req, createdAtStart, createdAtEnd)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 转换为响应结构并填充多语言显示字段和用户手机号
invoiceResponses := make([]*models.InvoiceResponse, len(invoices))
for i, invoice := range invoices {
invoiceResponses[i] = invoice.ToResponse()
s.fillDisplayFields(invoiceResponses[i], lang)
// 填充申请人手机号
cuUser, err := s.cuUserRepo.GetByID(invoice.CuUserID)
if err == nil {
invoiceResponses[i].CuUserPhone = cuUser.Phone
}
}
return &models.InvoiceListResponse{
Invoices: invoiceResponses,
TotalCount: total,
Page: req.Page,
PageSize: req.PageSize,
}, nil
}
func (s *adminInvoiceService) GetAdminInvoiceSummary(ctx context.Context) (*models.InvoiceSummaryResponse, error) {
return s.repo.GetAdminInvoiceSummary(ctx)
}
func (s *adminInvoiceService) GetInvoiceDetail(ctx context.Context, invoiceID string, isAdmin bool, userID string) (*models.InvoiceDetailResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
invoice, err := s.repo.GetByID(invoiceID)
if err != nil {
return nil, i18n.NewI18nError("700001", lang)
}
// 权限验证
if !isAdmin && invoice.CuUserID != userID {
return nil, i18n.NewI18nError("700001", lang)
}
response := &models.InvoiceDetailResponse{
InvoiceResponse: invoice.ToResponse(),
}
// 填充多语言显示字段
s.fillDetailDisplayFields(response, lang)
// 获取关联信息
if isAdmin {
// 管理端需要更多关联信息
order, err := s.orderRepo.GetByID(invoice.OrderID)
if err == nil {
response.OrderPackageName = order.PackageName
}
cuUser, err := s.cuUserRepo.GetByID(invoice.CuUserID)
if err == nil && cuUser.RealName != nil {
response.ApplicantName = *cuUser.RealName
response.ApplicantPhone = cuUser.Phone
}
if invoice.UploadedBy != nil {
user, err := s.userRepo.GetUserByID(ctx, *invoice.UploadedBy)
if err == nil {
response.UploaderName = user.FullName
}
}
if invoice.RejectedBy != nil {
user, err := s.userRepo.GetUserByID(ctx, *invoice.RejectedBy)
if err == nil {
response.RejecterName = user.FullName
}
}
}
return response, nil
}
func (s *adminInvoiceService) GenerateDownloadToken(ctx context.Context, invoiceID string) (string, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
invoice, err := s.repo.GetByID(invoiceID)
if err != nil {
return "", i18n.NewI18nError("700001", lang)
}
if invoice.Status != models.InvoiceStatusIssued {
return "", i18n.NewI18nError("700005", lang)
}
if invoice.InvoiceFileURL == nil || *invoice.InvoiceFileURL == "" {
return "", i18n.NewI18nError("700005", lang)
}
// 生成随机token
tokenBytes := make([]byte, 32)
_, err = rand.Read(tokenBytes)
if err != nil {
return "", err
}
token := hex.EncodeToString(tokenBytes)
// 更新token
err = s.repo.UpdateDownloadToken(invoiceID, &token)
if err != nil {
return "", err
}
return token, nil
}
// ========== 公共发票操作实现 ==========
func (s *adminInvoiceService) DownloadByToken(ctx context.Context, token string) (string, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
invoice, err := s.repo.GetByDownloadToken(token)
if err != nil {
return "", i18n.NewI18nError("700001", lang)
}
if invoice.Status != models.InvoiceStatusIssued {
return "", i18n.NewI18nError("700005", lang)
}
if invoice.InvoiceFileURL == nil || *invoice.InvoiceFileURL == "" {
return "", i18n.NewI18nError("700005", lang)
}
return *invoice.InvoiceFileURL, nil
}
// ========== 私有辅助方法 ==========
// fillDisplayFields 填充响应结构的多语言显示字段
func (s *adminInvoiceService) fillDisplayFields(response *models.InvoiceResponse, lang string) {
response.StatusDisplay = i18n.GetEnumMessage("invoice_status", response.Status, lang)
response.InvoiceTypeDisplay = i18n.GetEnumMessage("invoice_type", response.InvoiceType, lang)
}
// fillDetailDisplayFields 填充详情响应结构的多语言显示字段
func (s *adminInvoiceService) fillDetailDisplayFields(response *models.InvoiceDetailResponse, lang string) {
response.StatusDisplay = i18n.GetEnumMessage("invoice_status", response.Status, lang)
response.InvoiceTypeDisplay = i18n.GetEnumMessage("invoice_type", response.InvoiceType, lang)
}
// parseDateRange 解析日期范围
func (s *adminInvoiceService) parseDateRange(dateStr string) (*time.Time, *time.Time, error) {
parts := strings.Split(dateStr, ",")
if len(parts) == 1 {
start, _, err := s.parseFlexibleTime(strings.TrimSpace(dateStr))
if err != nil {
return nil, nil, err
}
end := start.AddDate(0, 0, 1)
return &start, &end, nil
}
if len(parts) != 2 {
return nil, nil, fmt.Errorf("invalid date range")
}
startText := strings.TrimSpace(parts[0])
endText := strings.TrimSpace(parts[1])
if startText == "" || endText == "" {
return nil, nil, fmt.Errorf("invalid date range")
}
start, _, err := s.parseFlexibleTime(startText)
if err != nil {
return nil, nil, err
}
end, endDateOnly, err := s.parseFlexibleTime(endText)
if err != nil {
return nil, nil, err
}
if endDateOnly {
end = end.AddDate(0, 0, 1)
}
if end.Before(start) {
start, end = end, start
}
return &start, &end, nil
}
func (s *adminInvoiceService) parseFlexibleTime(value string) (time.Time, bool, error) {
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
return t, false, nil
}
if t, err := time.Parse(time.RFC3339, value); err == nil {
return t, false, nil
}
t, err := time.Parse("2006-01-02", value)
if err != nil {
return time.Time{}, false, err
}
return t, true, nil
}
@@ -760,13 +760,8 @@ func (s *authorizationCodeService) ShareAuthorizationCode(ctx context.Context, a
return nil, i18n.NewI18nError("900001", lang)
}
// 验证不能分享给自己
if req.TargetUserID == userID {
return nil, i18n.NewI18nError("300105", lang) // 不能分享给自己
}
// 验证目标用户是否存在
targetUser, err := s.cuUserRepo.GetByID(req.TargetUserID)
// 根据联系方式查找目标用户
targetUser, err := s.findUserByContact(req.TargetContact)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, i18n.NewI18nError("300104", lang) // 目标用户不存在
@@ -774,6 +769,11 @@ func (s *authorizationCodeService) ShareAuthorizationCode(ctx context.Context, a
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 验证不能分享给自己
if targetUser.ID == userID {
return nil, i18n.NewI18nError("300105", lang) // 不能分享给自己
}
// 验证目标用户状态
if targetUser.Status != "active" {
return nil, i18n.NewI18nError("300104", lang) // 目标用户不存在或状态异常
@@ -844,7 +844,7 @@ func (s *authorizationCodeService) ShareAuthorizationCode(ctx context.Context, a
newAuthCode = &models.AuthorizationCode{
Code: code,
CustomerID: targetUser.CustomerID, // 使用目标用户的客户ID
CreatedBy: req.TargetUserID, // 记录为目标用户创建的
CreatedBy: targetUser.ID, // 记录为目标用户创建的
SoftwareID: authCode.SoftwareID,
Description: authCode.Description,
StartDate: now, // 从分享时刻开始
@@ -894,3 +894,75 @@ func (s *authorizationCodeService) ShareAuthorizationCode(ctx context.Context, a
return response, nil
}
// findUserByContact 根据联系方式查找用户(手机号或邮箱)
func (s *authorizationCodeService) findUserByContact(contact string) (*models.CuUser, error) {
// 判断是手机号还是邮箱
if strings.Contains(contact, "@") {
// 邮箱
return s.cuUserRepo.GetByEmail(contact)
} else {
// 手机号:如果没有国家代码,默认使用 +86
phone := contact
countryCode := "+86" // 默认国家代码
// 检查是否已经包含国家代码
if strings.HasPrefix(contact, "+") {
// 如果以 + 开头,分离国家代码和手机号
parts := strings.SplitN(contact, " ", 2)
if len(parts) == 2 {
countryCode = parts[0]
phone = parts[1]
} else {
// 可能是直接的格式如 +8613800000000,需要分离
// 简单处理:假设国家代码是 + 开头的1-4位数字
for i := 1; i <= 4 && i < len(contact); i++ {
if contact[i] >= '0' && contact[i] <= '9' {
continue
}
countryCode = contact[:i]
phone = contact[i:]
break
}
}
}
return s.cuUserRepo.GetByPhone(phone, countryCode)
}
}
// GetCuAuthorizationCodeList 用户端:获取当前用户授权码列表
func (s *authorizationCodeService) GetCuAuthorizationCodeList(ctx context.Context, customerID string, req *models.CuAuthorizationCodeListRequest) (*models.CuAuthorizationCodeListResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
if customerID == "" {
return nil, i18n.NewI18nError("100004", lang)
}
result, err := s.authCodeRepo.GetCuAuthorizationCodeList(ctx, customerID, req)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
for i := range result.List {
result.List[i].StatusDisplay = i18n.GetEnumMessage("authorization_code_status", result.List[i].Status, lang)
}
return result, nil
}
// GetCuAuthorizationCodeSummary 用户端:授权信息统计
func (s *authorizationCodeService) GetCuAuthorizationCodeSummary(ctx context.Context, customerID string) (*models.CuAuthorizationCodeSummaryResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
if customerID == "" {
return nil, i18n.NewI18nError("100004", lang)
}
result, err := s.authCodeRepo.GetCuAuthorizationCodeSummary(ctx, customerID)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
return result, nil
}
@@ -0,0 +1,317 @@
package service
import (
"context"
"fmt"
"license-manager/internal/models"
"license-manager/internal/repository"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
"strings"
"time"
"gorm.io/gorm"
)
// CuInvoiceService 客户端发票服务接口
type CuInvoiceService interface {
// ========== 客户端发票操作 ==========
CreateInvoice(ctx context.Context, cuUserID string, req *models.InvoiceCreateRequest) (*models.Invoice, error)
GetInvoice(ctx context.Context, invoiceID, cuUserID string) (*models.Invoice, error)
GetUserInvoices(ctx context.Context, cuUserID string, req *models.InvoiceListRequest) (*models.InvoiceListResponse, error)
GetInvoiceSummary(ctx context.Context, cuUserID string) (*models.InvoiceSummaryResponse, error)
DownloadInvoice(ctx context.Context, invoiceID, cuUserID string) (string, error)
UpdateInvoice(ctx context.Context, invoiceID, cuUserID string, req *models.InvoiceUpdateRequest) (*models.Invoice, error)
}
type cuInvoiceService struct {
repo repository.CuInvoiceRepository
orderRepo repository.CuOrderRepository
db *gorm.DB
}
func NewCuInvoiceService(repo repository.CuInvoiceRepository, orderRepo repository.CuOrderRepository, db *gorm.DB) CuInvoiceService {
return &cuInvoiceService{
repo: repo,
orderRepo: orderRepo,
db: db,
}
}
// ========== 客户端发票操作实现 ==========
func (s *cuInvoiceService) CreateInvoice(ctx context.Context, cuUserID string, req *models.InvoiceCreateRequest) (*models.Invoice, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 验证订单是否存在且属于当前用户
order, err := s.orderRepo.GetByID(req.OrderID)
if err != nil {
return nil, i18n.NewI18nError("700002", lang)
}
if order.CuUserID != cuUserID {
return nil, i18n.NewI18nError("700002", lang)
}
// 验证订单状态必须为已支付
if order.Status != "paid" {
return nil, i18n.NewI18nError("700002", lang)
}
// 检查是否已存在发票
exists, err := s.repo.CheckOrderInvoiceExists(req.OrderID)
if err != nil {
return nil, err
}
if exists {
return nil, i18n.NewI18nError("700003", lang)
}
// 验证发票类型和纳税人识别号
if req.InvoiceType != models.InvoiceTypePersonal && (req.TaxpayerID == nil || *req.TaxpayerID == "") {
return nil, i18n.NewI18nError("700004", lang)
}
// 生成发票申请号
invoiceNo := s.generateInvoiceNo()
// 创建发票记录
now := time.Now()
invoice := &models.Invoice{
InvoiceNo: invoiceNo,
OrderID: req.OrderID,
OrderNo: order.OrderNo,
CustomerID: order.CustomerID,
CuUserID: cuUserID,
Amount: order.TotalAmount,
Status: models.InvoiceStatusPending,
InvoiceType: req.InvoiceType,
Title: req.Title,
TaxpayerID: req.TaxpayerID,
Content: req.Content,
ReceiverEmail: req.ReceiverEmail,
Remark: req.Remark,
CreatedAt: now,
UpdatedAt: now,
}
err = s.repo.Create(invoice)
if err != nil {
return nil, err
}
return invoice, nil
}
func (s *cuInvoiceService) GetInvoice(ctx context.Context, invoiceID, cuUserID string) (*models.Invoice, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
invoice, err := s.repo.GetByID(invoiceID)
if err != nil {
return nil, i18n.NewI18nError("700001", lang)
}
// 验证权限:只能查看自己的发票
if invoice.CuUserID != cuUserID {
return nil, i18n.NewI18nError("700001", lang)
}
return invoice, nil
}
func (s *cuInvoiceService) GetUserInvoices(ctx context.Context, cuUserID string, req *models.InvoiceListRequest) (*models.InvoiceListResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 解析日期筛选
var createdAtStart, createdAtEnd *time.Time
if req.ApplyDate != "" {
start, end, err := s.parseDateRange(req.ApplyDate)
if err != nil {
return nil, i18n.NewI18nError("900001", lang)
}
createdAtStart = start
createdAtEnd = end
}
invoices, total, err := s.repo.GetByCuUserID(ctx, cuUserID, req, createdAtStart, createdAtEnd)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 转换为响应结构并填充多语言显示字段
invoiceResponses := make([]*models.InvoiceResponse, len(invoices))
for i, invoice := range invoices {
invoiceResponses[i] = invoice.ToResponse()
s.fillDisplayFields(invoiceResponses[i], lang)
}
return &models.InvoiceListResponse{
Invoices: invoiceResponses,
TotalCount: total,
Page: req.Page,
PageSize: req.PageSize,
}, nil
}
func (s *cuInvoiceService) GetInvoiceSummary(ctx context.Context, cuUserID string) (*models.InvoiceSummaryResponse, error) {
return s.repo.GetCuUserInvoiceSummary(ctx, cuUserID)
}
func (s *cuInvoiceService) DownloadInvoice(ctx context.Context, invoiceID, cuUserID string) (string, error) {
invoice, err := s.GetInvoice(ctx, invoiceID, cuUserID)
if err != nil {
return "", err
}
lang := pkgcontext.GetLanguageFromContext(ctx)
if invoice.Status != models.InvoiceStatusIssued {
return "", i18n.NewI18nError("700005", lang)
}
if invoice.InvoiceFileURL == nil || *invoice.InvoiceFileURL == "" {
return "", i18n.NewI18nError("700005", lang)
}
return *invoice.InvoiceFileURL, nil
}
func (s *cuInvoiceService) UpdateInvoice(ctx context.Context, invoiceID, cuUserID string, req *models.InvoiceUpdateRequest) (*models.Invoice, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 获取原发票
invoice, err := s.repo.GetByID(invoiceID)
if err != nil {
return nil, i18n.NewI18nError("700001", lang)
}
// 权限校验
if invoice.CuUserID != cuUserID {
return nil, i18n.NewI18nError("700001", lang)
}
// 状态校验:只有被驳回的发票可以修改
if invoice.Status != models.InvoiceStatusRejected {
return nil, i18n.NewI18nError("700004", lang)
}
// 业务逻辑校验:发票类型和纳税人识别号
// 如果传入了新的 InvoiceType 或者 TaxpayerID,我们要校验最终的结果
finalInvoiceType := invoice.InvoiceType
if req.InvoiceType != nil {
finalInvoiceType = *req.InvoiceType
}
finalTaxpayerID := invoice.TaxpayerID
if req.TaxpayerID != nil {
finalTaxpayerID = req.TaxpayerID // This handles explicitly setting it to nil or "" as well if needed? Wait, if they set *req.TaxpayerID == "" it will just replace it.
}
if finalInvoiceType != models.InvoiceTypePersonal && (finalTaxpayerID == nil || *finalTaxpayerID == "") {
return nil, i18n.NewI18nError("700004", lang)
}
// 更新字段(仅更新非空字段)
if req.InvoiceType != nil {
invoice.InvoiceType = *req.InvoiceType
}
if req.Title != nil {
invoice.Title = *req.Title
}
if req.TaxpayerID != nil {
invoice.TaxpayerID = req.TaxpayerID
}
if req.Content != nil {
invoice.Content = *req.Content
}
if req.ReceiverEmail != nil {
invoice.ReceiverEmail = *req.ReceiverEmail
}
if req.Remark != nil {
invoice.Remark = req.Remark
}
// 重置状态和驳回信息
invoice.Status = models.InvoiceStatusPending
invoice.RejectReason = nil
invoice.Suggestion = nil
invoice.RejectedAt = nil
invoice.RejectedBy = nil
// 更新数据库
err = s.repo.Update(invoice)
if err != nil {
return nil, err
}
return invoice, nil
}
// ========== 私有辅助方法 ==========
// fillDisplayFields 填充响应结构的多语言显示字段
func (s *cuInvoiceService) fillDisplayFields(response *models.InvoiceResponse, lang string) {
response.StatusDisplay = i18n.GetEnumMessage("invoice_status", response.Status, lang)
response.InvoiceTypeDisplay = i18n.GetEnumMessage("invoice_type", response.InvoiceType, lang)
}
// generateInvoiceNo 生成发票申请号
func (s *cuInvoiceService) generateInvoiceNo() string {
now := time.Now()
timestamp := now.UnixNano() / int64(time.Millisecond) % 1000000000 // 取纳秒后9位
return fmt.Sprintf("INV%04d%02d%02d%09d",
now.Year(), now.Month(), now.Day(), timestamp)
}
// parseDateRange 解析日期范围
func (s *cuInvoiceService) parseDateRange(dateStr string) (*time.Time, *time.Time, error) {
parts := strings.Split(dateStr, ",")
if len(parts) == 1 {
start, _, err := s.parseFlexibleTime(strings.TrimSpace(dateStr))
if err != nil {
return nil, nil, err
}
end := start.AddDate(0, 0, 1)
return &start, &end, nil
}
if len(parts) != 2 {
return nil, nil, fmt.Errorf("invalid date range")
}
startText := strings.TrimSpace(parts[0])
endText := strings.TrimSpace(parts[1])
if startText == "" || endText == "" {
return nil, nil, fmt.Errorf("invalid date range")
}
start, _, err := s.parseFlexibleTime(startText)
if err != nil {
return nil, nil, err
}
end, endDateOnly, err := s.parseFlexibleTime(endText)
if err != nil {
return nil, nil, err
}
if endDateOnly {
end = end.AddDate(0, 0, 1)
}
if end.Before(start) {
start, end = end, start
}
return &start, &end, nil
}
func (s *cuInvoiceService) parseFlexibleTime(value string) (time.Time, bool, error) {
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
return t, false, nil
}
if t, err := time.Parse(time.RFC3339, value); err == nil {
return t, false, nil
}
t, err := time.Parse("2006-01-02", value)
if err != nil {
return time.Time{}, false, err
}
return t, true, nil
}
@@ -0,0 +1,39 @@
package service
import (
"fmt"
"strings"
"time"
)
func parseCuOrderTimeRange(value string, now time.Time) (*time.Time, *time.Time, error) {
raw := strings.TrimSpace(value)
if raw == "" {
return nil, nil, nil
}
loc := now.Location()
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
startOfTomorrow := startOfToday.AddDate(0, 0, 1)
switch strings.ToLower(raw) {
case "today", "今天":
return &startOfToday, &startOfTomorrow, nil
case "week", "this_week", "本周":
// 默认按周一作为一周开始
daysSinceMonday := (int(now.Weekday()) + 6) % 7
start := startOfToday.AddDate(0, 0, -daysSinceMonday)
end := start.AddDate(0, 0, 7)
return &start, &end, nil
case "month", "this_month", "本月":
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
end := start.AddDate(0, 1, 0)
return &start, &end, nil
case "three_months", "3months", "last_3_months", "近三个月":
threeMonthsAgo := now.AddDate(0, -3, 0)
start := time.Date(threeMonthsAgo.Year(), threeMonthsAgo.Month(), threeMonthsAgo.Day(), 0, 0, 0, 0, loc)
return &start, &startOfTomorrow, nil
default:
return nil, nil, fmt.Errorf("invalid time filter: %s", raw)
}
}
@@ -0,0 +1,76 @@
package service
import (
"testing"
"time"
)
func TestParseCuOrderTimeRange(t *testing.T) {
loc := time.FixedZone("CST", 8*3600)
now := time.Date(2026, 1, 14, 15, 4, 5, 0, loc) // Wednesday
t.Run("empty", func(t *testing.T) {
start, end, err := parseCuOrderTimeRange("", now)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
if start != nil || end != nil {
t.Fatalf("expected nil start/end, got %v %v", start, end)
}
})
t.Run("today", func(t *testing.T) {
start, end, err := parseCuOrderTimeRange("today", now)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
wantStart := time.Date(2026, 1, 14, 0, 0, 0, 0, loc)
wantEnd := time.Date(2026, 1, 15, 0, 0, 0, 0, loc)
if start == nil || end == nil || !start.Equal(wantStart) || !end.Equal(wantEnd) {
t.Fatalf("unexpected range: start=%v end=%v", start, end)
}
})
t.Run("this week", func(t *testing.T) {
start, end, err := parseCuOrderTimeRange("week", now)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
wantStart := time.Date(2026, 1, 12, 0, 0, 0, 0, loc) // Monday
wantEnd := time.Date(2026, 1, 19, 0, 0, 0, 0, loc)
if start == nil || end == nil || !start.Equal(wantStart) || !end.Equal(wantEnd) {
t.Fatalf("unexpected range: start=%v end=%v", start, end)
}
})
t.Run("this month", func(t *testing.T) {
start, end, err := parseCuOrderTimeRange("month", now)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
wantStart := time.Date(2026, 1, 1, 0, 0, 0, 0, loc)
wantEnd := time.Date(2026, 2, 1, 0, 0, 0, 0, loc)
if start == nil || end == nil || !start.Equal(wantStart) || !end.Equal(wantEnd) {
t.Fatalf("unexpected range: start=%v end=%v", start, end)
}
})
t.Run("last three months", func(t *testing.T) {
start, end, err := parseCuOrderTimeRange("three_months", now)
if err != nil {
t.Fatalf("expected nil error, got %v", err)
}
wantStart := time.Date(2025, 10, 14, 0, 0, 0, 0, loc)
wantEnd := time.Date(2026, 1, 15, 0, 0, 0, 0, loc)
if start == nil || end == nil || !start.Equal(wantStart) || !end.Equal(wantEnd) {
t.Fatalf("unexpected range: start=%v end=%v", start, end)
}
})
t.Run("invalid", func(t *testing.T) {
_, _, err := parseCuOrderTimeRange("nope", now)
if err == nil {
t.Fatalf("expected error, got nil")
}
})
}
+311 -63
View File
@@ -3,6 +3,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"license-manager/internal/models"
"license-manager/internal/repository"
@@ -19,10 +20,23 @@ type CuOrderService interface {
CreateOrder(ctx context.Context, cuUserID, customerID string, req *models.CuOrderCreateRequest) (*models.CuOrder, error)
CreatePendingOrder(ctx context.Context, cuUserID, customerID string, req *models.CuOrderCreateRequest) (*models.CuOrder, error)
GetOrder(ctx context.Context, orderID, cuUserID string) (*models.CuOrder, error)
GetUserOrders(ctx context.Context, cuUserID string, offset, limit int) ([]*models.CuOrder, int64, error)
GetUserOrders(ctx context.Context, cuUserID string, req *models.CuOrderListRequest) ([]*models.CuOrder, int64, error)
GetOrderSummary(ctx context.Context, customerID string) (*models.OrderSummaryResponse, error)
CalculatePrice(ctx context.Context, packageID string, licenseCount int) (*PriceCalculationResult, error)
CancelOrder(ctx context.Context, orderID, cuUserID string) (*models.CuOrder, error)
DeleteOrder(ctx context.Context, orderID, cuUserID string) error
UpdateOrderStatus(ctx context.Context, orderID string, status string) error
ContinuePay(ctx context.Context, orderID, cuUserID, customerID, paymentMethod string) (*ContinuePayResponse, error)
}
// ContinuePayResponse 继续支付响应
type ContinuePayResponse struct {
OrderID string `json:"order_id"` // 订单ID
OrderNo string `json:"order_no"` // 订单号
PaymentNo string `json:"payment_no"` // 支付单号
PaymentURL *string `json:"payment_url"` // 支付链接
TotalAmount float64 `json:"total_amount"` // 订单总金额
ExpireTime time.Time `json:"expire_time"` // 支付过期时间
}
type PriceCalculationResult struct {
@@ -33,31 +47,6 @@ type PriceCalculationResult struct {
DiscountDesc string `json:"discount_description"`
}
// 套餐配置(临时写死,后续可改为配置化)
var packageConfigs = map[string]PackageConfig{
"trial": {
Name: "试用版",
Price: 0,
MaxDevices: 1, // 试用版限制1个许可
},
"basic": {
Name: "基础版",
Price: 300,
MaxDevices: 1000, // 基础版最多1000个许可
},
"professional": {
Name: "专业版",
Price: 2000,
MaxDevices: 1000, // 专业版最多1000个许可
},
}
type PackageConfig struct {
Name string
Price float64
MaxDevices int
}
// 折扣规则配置
var discountRules = []DiscountRule{
{MinQuantity: 50, MaxQuantity: 99, Rate: 0.9, Description: "50-99许可9折优惠"},
@@ -76,41 +65,86 @@ type cuOrderService struct {
repo repository.CuOrderRepository
cuUserRepo repository.CuUserRepository
authCodeRepo repository.AuthorizationCodeRepository
packageRepo repository.PackageRepository
paymentRepo repository.PaymentRepository
invoiceRepo repository.CuInvoiceRepository
db *gorm.DB
}
func NewCuOrderService(repo repository.CuOrderRepository, cuUserRepo repository.CuUserRepository, authCodeRepo repository.AuthorizationCodeRepository, db *gorm.DB) CuOrderService {
func NewCuOrderService(
repo repository.CuOrderRepository,
cuUserRepo repository.CuUserRepository,
authCodeRepo repository.AuthorizationCodeRepository,
packageRepo repository.PackageRepository,
paymentRepo repository.PaymentRepository,
invoiceRepo repository.CuInvoiceRepository,
db *gorm.DB,
) CuOrderService {
return &cuOrderService{
repo: repo,
cuUserRepo: cuUserRepo,
authCodeRepo: authCodeRepo,
packageRepo: packageRepo,
paymentRepo: paymentRepo,
invoiceRepo: invoiceRepo,
db: db,
}
}
func (s *cuOrderService) CreatePendingOrder(ctx context.Context, cuUserID, customerID string, req *models.CuOrderCreateRequest) (*models.CuOrder, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 计算价格
priceResult, err := s.CalculatePrice(ctx, req.PackageID, req.LicenseCount)
if err != nil {
return nil, err
}
// 从数据库获取套餐配置
pkgEntity, maxDevices, err := s.getPackageForOrder(ctx, req.PackageID)
if err != nil {
return nil, err
}
// 对于试用版,进行特殊检查
if pkgEntity.Type == string(models.PackageTypeTrial) {
// 试用版不支持批量购买
if req.LicenseCount != 1 {
return nil, i18n.NewI18nError("600004", lang) // 试用版不支持批量购买
}
// 检查购买时间限制
now := time.Now()
if now.Day() > 25 {
return nil, i18n.NewI18nError("600003", lang) // 试用版购买时间限制
}
// 每个用户每月只能有一个试用版(包括pending的)
currentMonth := now.Format("2006-01")
exists, err := s.repo.CheckTrialOrderExists(cuUserID, currentMonth)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
if exists {
return nil, i18n.NewI18nError("600005", lang) // 当月已购买试用版
}
}
// 再次校验数量(防御性校验)
if req.LicenseCount > maxDevices {
return nil, i18n.NewI18nError("601005", lang)
}
// 生成订单号
orderNo := s.generateOrderNo()
// 获取套餐配置
packageConfig, exists := packageConfigs[req.PackageID]
if !exists {
return nil, i18n.NewI18nError("600001", "套餐不存在")
}
// 创建订单(pending状态)
order := &models.CuOrder{
OrderNo: orderNo,
CustomerID: customerID,
CuUserID: cuUserID,
PackageID: req.PackageID,
PackageName: packageConfig.Name,
PackageName: pkgEntity.Name,
LicenseCount: req.LicenseCount,
UnitPrice: priceResult.UnitPrice,
DiscountRate: priceResult.DiscountRate,
@@ -118,8 +152,8 @@ func (s *cuOrderService) CreatePendingOrder(ctx context.Context, cuUserID, custo
Status: "pending", // 待支付状态
}
// 对于试用版,设置过期时间
if req.PackageID == "trial" {
// 对于试用版,设置过期时间(根据套餐类型判断)
if pkgEntity.Type == string(models.PackageTypeTrial) {
expiredAt := s.getTrialExpiryTime()
order.ExpiredAt = &expiredAt
}
@@ -152,19 +186,19 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s
return nil, i18n.NewI18nError("900001", lang)
}
// 检查套餐是否存在
pkg, exists := packageConfigs[req.PackageID]
if !exists {
return nil, i18n.NewI18nError("600001", lang) // 套餐不存在
// 检查套餐是否存在(从数据库)
pkgEntity, maxDevices, err := s.getPackageForOrder(ctx, req.PackageID)
if err != nil {
return nil, err
}
// 验证许可数量
if req.LicenseCount > pkg.MaxDevices {
if req.LicenseCount > maxDevices {
return nil, i18n.NewI18nError("601005", lang) // 许可数量超出限制
}
// 试用版特殊检查
if req.PackageID == "trial" {
// 试用版特殊检查(根据套餐类型判断)
if pkgEntity.Type == string(models.PackageTypeTrial) {
// 试用版不支持批量购买
if req.LicenseCount != 1 {
return nil, i18n.NewI18nError("600004", lang) // 试用版不支持批量购买
@@ -202,7 +236,7 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s
CustomerID: customerID,
CuUserID: cuUserID,
PackageID: req.PackageID,
PackageName: pkg.Name,
PackageName: pkgEntity.Name,
LicenseCount: req.LicenseCount,
UnitPrice: priceResult.UnitPrice,
DiscountRate: priceResult.DiscountRate,
@@ -232,7 +266,7 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s
// 计算授权时间
var startDate, endDate time.Time
var expiredAt *time.Time
if req.PackageID == "trial" {
if pkgEntity.Type == string(models.PackageTypeTrial) {
// 试用版:从今天到当月25日
trialExpiry := time.Date(now.Year(), now.Month(), 25, 23, 59, 59, 0, now.Location())
if now.Day() > 25 {
@@ -254,7 +288,7 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s
var featureConfigMap, usageLimitsMap map[string]interface{}
// 根据套餐类型设置配置信息
if req.PackageID == "basic" {
if pkgEntity.Type == string(models.PackageTypeBasic) {
usageLimitsMap = map[string]interface{}{
"type": "standard",
}
@@ -262,7 +296,7 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s
customParametersMap := map[string]interface{}{
"package_id": req.PackageID,
"package_name": pkg.Name,
"package_name": pkgEntity.Name,
"license_count": req.LicenseCount,
}
@@ -274,7 +308,7 @@ func (s *cuOrderService) CreateOrder(ctx context.Context, cuUserID, customerID s
}
// 构建订单描述
description := fmt.Sprintf("%s - %d个许可", pkg.Name, req.LicenseCount)
description := fmt.Sprintf("%s - %d个许可", pkgEntity.Name, req.LicenseCount)
// 处理JSON字段(用于数据库存储与后续生成产品激活码 payload)
var featureConfig, usageLimits, customParameters models.JSON
@@ -363,7 +397,93 @@ func (s *cuOrderService) GetOrder(ctx context.Context, orderID, cuUserID string)
return order, nil
}
func (s *cuOrderService) GetUserOrders(ctx context.Context, cuUserID string, offset, limit int) ([]*models.CuOrder, int64, error) {
func (s *cuOrderService) CancelOrder(ctx context.Context, orderID, cuUserID string) (*models.CuOrder, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
if orderID == "" || cuUserID == "" {
return nil, i18n.NewI18nError("900001", lang)
}
order, err := s.repo.GetByID(orderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, i18n.NewI18nError("601001", lang) // 订单不存在
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 检查权限:只能取消自己的订单
if order.CuUserID != cuUserID {
return nil, i18n.NewI18nError("100005", lang) // 权限不足
}
switch order.Status {
case "cancelled":
// 幂等:已取消直接返回成功
return order, nil
case "paid":
return nil, i18n.NewI18nError("601006", lang) // 订单已支付,无法取消
case "pending":
order.Status = "cancelled"
if err := s.repo.Update(order); err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
return order, nil
default:
// 兜底:未知状态按不可取消处理
return nil, i18n.NewI18nError("601006", lang)
}
}
func (s *cuOrderService) DeleteOrder(ctx context.Context, orderID, cuUserID string) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
if orderID == "" || cuUserID == "" {
return i18n.NewI18nError("900001", lang)
}
order, err := s.repo.GetByID(orderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return i18n.NewI18nError("601001", lang) // 订单不存在
}
return i18n.NewI18nError("900004", lang, err.Error())
}
// 检查权限:只能删除自己的订单
if order.CuUserID != cuUserID {
return i18n.NewI18nError("100005", lang) // 权限不足
}
// 开启事务,确保订单和相关支付记录一起删除
tx := s.db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 删除相关的支付记录
if err := tx.Unscoped().Where("business_type = ? AND business_id = ?", models.BusinessTypePackageOrder, orderID).Delete(&models.Payment{}).Error; err != nil {
tx.Rollback()
return i18n.NewI18nError("900004", lang, err.Error())
}
// 删除订单记录(物理删除)
if err := tx.Unscoped().Where("id = ?", orderID).Delete(&models.CuOrder{}).Error; err != nil {
tx.Rollback()
return i18n.NewI18nError("900004", lang, err.Error())
}
// 提交事务
if err := tx.Commit().Error; err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
return nil
}
func (s *cuOrderService) GetUserOrders(ctx context.Context, cuUserID string, req *models.CuOrderListRequest) ([]*models.CuOrder, int64, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 参数验证
@@ -371,11 +491,40 @@ func (s *cuOrderService) GetUserOrders(ctx context.Context, cuUserID string, off
return nil, 0, i18n.NewI18nError("900001", lang)
}
orders, total, err := s.repo.GetByCuUserID(cuUserID, offset, limit)
if req == nil {
req = &models.CuOrderListRequest{}
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 10
}
if req.PageSize > 100 {
req.PageSize = 100
}
createdAtStart, createdAtEnd, err := parseCuOrderTimeRange(req.Time, time.Now())
if err != nil {
return nil, 0, i18n.NewI18nError("900001", lang, err.Error())
}
orders, total, err := s.repo.GetByCuUserID(ctx, cuUserID, req, createdAtStart, createdAtEnd)
if err != nil {
return nil, 0, i18n.NewI18nError("900004", lang, err.Error())
}
// 为订单填充“是否已申请发票”标记
if s.invoiceRepo != nil && len(orders) > 0 {
for _, order := range orders {
exists, err := s.invoiceRepo.CheckOrderInvoiceExists(order.ID)
if err != nil {
return nil, 0, i18n.NewI18nError("900004", lang, err.Error())
}
order.HasInvoiceApplied = exists
}
}
return orders, total, nil
}
@@ -395,20 +544,14 @@ func (s *cuOrderService) GetOrderSummary(ctx context.Context, customerID string)
func (s *cuOrderService) CalculatePrice(ctx context.Context, packageID string, licenseCount int) (*PriceCalculationResult, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 检查套餐是否存在
pkg, exists := packageConfigs[packageID]
if !exists {
return nil, i18n.NewI18nError("600001", lang) // 套餐不存在
// 从数据库获取套餐配置
pkgEntity, maxDevices, err := s.getPackageForOrder(ctx, packageID)
if err != nil {
return nil, err
}
// 检查套餐是否已下架(虽然当前是写死配置,但保留这个检查)
// TODO: 如果将来添加status字段,可以在这里检查
// if pkg.Status != "active" {
// return nil, i18n.NewI18nError("600002", lang) // 套餐已下架
// }
// 验证许可数量
if licenseCount > pkg.MaxDevices {
if licenseCount > maxDevices {
return nil, i18n.NewI18nError("601005", lang) // 许可数量超出限制
}
@@ -424,10 +567,10 @@ func (s *cuOrderService) CalculatePrice(ctx context.Context, packageID string, l
}
// 计算总价
totalAmount := pkg.Price * float64(licenseCount) * discountRate
totalAmount := pkgEntity.Price * float64(licenseCount) * discountRate
return &PriceCalculationResult{
UnitPrice: pkg.Price,
UnitPrice: pkgEntity.Price,
LicenseCount: licenseCount,
DiscountRate: discountRate,
TotalAmount: totalAmount,
@@ -446,3 +589,108 @@ func (s *cuOrderService) generateOrderNo() string {
return fmt.Sprintf("ORD%s%09d", dateStr, nano)
}
// getPackageForOrder 从数据库获取套餐配置,并解析最大许可数量
func (s *cuOrderService) getPackageForOrder(ctx context.Context, packageID string) (*models.Package, int, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
if packageID == "" {
return nil, 0, i18n.NewI18nError("600001", lang)
}
pkgEntity, err := s.packageRepo.GetByID(packageID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, 0, i18n.NewI18nError("600001", lang) // 套餐不存在
}
return nil, 0, i18n.NewI18nError("900004", lang, err.Error())
}
// 检查套餐是否启用
if pkgEntity.Status != 1 {
return nil, 0, i18n.NewI18nError("600002", lang) // 套餐已下架
}
maxDevices := parseMaxDevicesFromFeatures(pkgEntity.Features)
return pkgEntity, maxDevices, nil
}
// parseMaxDevicesFromFeatures 从 features JSON 中解析 "X个许可"
func parseMaxDevicesFromFeatures(features string) int {
// 默认最大许可数,与原始写死配置保持一致
maxDevices := 1000
if features == "" {
return maxDevices
}
var featureList []string
if err := json.Unmarshal([]byte(features), &featureList); err != nil {
return maxDevices
}
for _, f := range featureList {
var n int
if _, err := fmt.Sscanf(f, "%d个许可", &n); err == nil && n > 0 {
maxDevices = n
break
}
}
return maxDevices
}
// ContinuePay 继续支付:获取订单的支付信息,如果支付单已过期则返回订单信息供重新创建支付单
func (s *cuOrderService) ContinuePay(ctx context.Context, orderID, cuUserID, customerID, paymentMethod string) (*ContinuePayResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 1. 验证订单
order, err := s.repo.GetByID(orderID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, i18n.NewI18nError("601001", lang) // 订单不存在
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 2. 检查权限:只能支付自己的订单
if order.CuUserID != cuUserID {
return nil, i18n.NewI18nError("100005", lang) // 权限不足
}
// 3. 检查订单状态:只有待支付订单才能继续支付
if order.Status != "pending" {
return nil, i18n.NewI18nError("601007", lang) // 订单状态不允许继续支付
}
// 4. 查询订单的最新支付单
payment, err := s.paymentRepo.GetByBusinessID(ctx, models.BusinessTypePackageOrder, orderID)
if err != nil && err != gorm.ErrRecordNotFound {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 5. 如果支付单存在且可用(pending 且未过期),直接返回
if payment != nil && err == nil {
now := time.Now()
if payment.Status == models.PaymentStatusPending && payment.ExpireTime.After(now) {
return &ContinuePayResponse{
OrderID: order.ID,
OrderNo: order.OrderNo,
PaymentNo: payment.PaymentNo,
PaymentURL: payment.PaymentURL,
TotalAmount: order.TotalAmount,
ExpireTime: payment.ExpireTime,
}, nil
}
}
// 6. 支付单不存在或已过期,返回订单信息(让 handler 层创建新的支付单)
// 这里返回一个特殊标记,表示需要创建新的支付单
return &ContinuePayResponse{
OrderID: order.ID,
OrderNo: order.OrderNo,
PaymentNo: "", // 空字符串表示需要创建新的支付单
PaymentURL: nil,
TotalAmount: order.TotalAmount,
ExpireTime: time.Time{}, // 零值表示需要创建新的支付单
}, nil
}
+2 -2
View File
@@ -185,7 +185,7 @@ func (s *cuUserService) Login(ctx context.Context, req *models.CuUserLoginReques
// 密码登录(默认行为)
// 检查是否被锁定
if user.IsAccountLocked() {
return nil, "", i18n.NewI18nError("500003", lang) // 账号已被锁定
return nil, "", i18n.NewI18nError("500018", lang) // 账号已被锁定
}
// 验证密码
@@ -210,7 +210,7 @@ func (s *cuUserService) Login(ctx context.Context, req *models.CuUserLoginReques
if err := s.repo.LockAccount(user.ID, lockUntil); err != nil {
// 记录错误但不影响登录流程
}
return nil, "", i18n.NewI18nError("500003", lang) // 账号已被锁定
return nil, "", i18n.NewI18nError("500018", lang) // 账号已被锁定
}
return nil, "", i18n.NewI18nError("500003", lang) // 手机号或密码错误
+2 -2
View File
@@ -240,7 +240,7 @@ func (s *customerService) DeleteCustomer(ctx context.Context, id string) error {
return i18n.NewI18nError("900004", lang, err.Error())
}
if hasAuthCodes {
return i18n.NewI18nError("200006", lang)
return i18n.NewI18nError("200008", lang)
}
// 检查是否有关联的许可证
@@ -249,7 +249,7 @@ func (s *customerService) DeleteCustomer(ctx context.Context, id string) error {
return i18n.NewI18nError("900004", lang, err.Error())
}
if hasLicenses {
return i18n.NewI18nError("200006", lang)
return i18n.NewI18nError("200008", lang)
}
// 委托给Repository层进行物理删除
+4
View File
@@ -50,6 +50,10 @@ type AuthorizationCodeService interface {
// 用户端获取产品激活码
GetProductActivationCode(ctx context.Context, customerID string, req *models.ProductActivationCodeRequest) (*models.ProductActivationCodeResponse, error)
// 用户端授权码列表与统计
GetCuAuthorizationCodeList(ctx context.Context, customerID string, req *models.CuAuthorizationCodeListRequest) (*models.CuAuthorizationCodeListResponse, error)
GetCuAuthorizationCodeSummary(ctx context.Context, customerID string) (*models.CuAuthorizationCodeSummaryResponse, error)
}
// LicenseService 许可证服务接口
+177
View File
@@ -0,0 +1,177 @@
package service
import (
"context"
"license-manager/internal/models"
"license-manager/internal/repository"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
"time"
"gorm.io/gorm"
)
// LeadService 线索服务接口
type LeadService interface {
CreateLead(ctx context.Context, req *models.LeadCreateRequest) (*models.Lead, error)
UpdateLead(ctx context.Context, id string, req *models.LeadUpdateRequest) (*models.Lead, error)
DeleteLead(ctx context.Context, id string) error
GetLead(ctx context.Context, id string) (*models.Lead, error)
GetLeadList(ctx context.Context, req *models.LeadListRequest) (*models.LeadListResponse, error)
GetLeadSummary(ctx context.Context) (*models.LeadSummaryResponse, error)
}
type leadService struct {
repo repository.LeadRepository
db *gorm.DB
}
// NewLeadService 创建线索服务
func NewLeadService(repo repository.LeadRepository, db *gorm.DB) LeadService {
return &leadService{
repo: repo,
db: db,
}
}
func (s *leadService) CreateLead(ctx context.Context, req *models.LeadCreateRequest) (*models.Lead, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 如果 requirement 为空,设置默认值 "Empty"
requirement := req.Requirement
if requirement == "" {
requirement = "Empty"
}
lead := &models.Lead{
CompanyName: req.CompanyName,
ContactName: req.ContactName,
ContactPhone: req.ContactPhone,
ContactEmail: req.ContactEmail,
Requirement: requirement,
ExtraInfo: req.ExtraInfo,
Status: string(models.LeadStatusPending),
}
if err := s.repo.Create(lead); err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
return lead, nil
}
func (s *leadService) UpdateLead(ctx context.Context, id string, req *models.LeadUpdateRequest) (*models.Lead, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
lead, err := s.repo.GetByID(id)
if err != nil {
return nil, i18n.NewI18nError("900002", lang)
}
// 更新字段
if req.CompanyName != "" {
lead.CompanyName = req.CompanyName
}
if req.ContactName != "" {
lead.ContactName = req.ContactName
}
if req.ContactPhone != "" {
lead.ContactPhone = req.ContactPhone
}
if req.ContactEmail != "" {
lead.ContactEmail = req.ContactEmail
}
if req.Requirement != "" {
lead.Requirement = req.Requirement
}
if req.ExtraInfo != "" {
lead.ExtraInfo = req.ExtraInfo
}
if req.Status != "" {
lead.Status = req.Status
}
lead.FollowUpDate = req.FollowUpDate
if req.FollowUpRecord != "" {
lead.FollowUpRecord = req.FollowUpRecord
}
if req.InternalNote != "" {
lead.InternalNote = req.InternalNote
}
lead.UpdatedAt = time.Now()
if err := s.repo.Update(lead); err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
return lead, nil
}
func (s *leadService) DeleteLead(ctx context.Context, id string) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 检查是否存在
_, err := s.repo.GetByID(id)
if err != nil {
return i18n.NewI18nError("900002", lang)
}
if err := s.repo.Delete(id); err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
return nil
}
func (s *leadService) GetLead(ctx context.Context, id string) (*models.Lead, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
lead, err := s.repo.GetByID(id)
if err != nil {
return nil, i18n.NewI18nError("900002", lang)
}
return lead, nil
}
func (s *leadService) GetLeadList(ctx context.Context, req *models.LeadListRequest) (*models.LeadListResponse, error) {
leads, total, err := s.repo.GetList(ctx, req)
if err != nil {
lang := pkgcontext.GetLanguageFromContext(ctx)
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 转换为响应
leadResponses := make([]*models.LeadResponse, len(leads))
for i, lead := range leads {
leadResponses[i] = lead.ToResponse()
}
page := 1
pageSize := 10
if req != nil {
if req.Page > 0 {
page = req.Page
}
if req.PageSize > 0 {
pageSize = req.PageSize
}
}
return &models.LeadListResponse{
Leads: leadResponses,
TotalCount: total,
Page: page,
PageSize: pageSize,
}, nil
}
func (s *leadService) GetLeadSummary(ctx context.Context) (*models.LeadSummaryResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
summary, err := s.repo.GetSummary(ctx)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
return summary, nil
}
+45 -22
View File
@@ -716,76 +716,99 @@ func parseJSONField(raw models.JSON) map[string]interface{} {
return result
}
// GetStatsOverview 获取授权概览统计
// GetStatsOverview returns dashboard overview statistics
func (s *licenseService) GetStatsOverview(ctx context.Context) (*models.StatsOverviewResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 获取配置信息
// Load heartbeat timeout config
cfg := config.GetConfig()
offlineTimeout := time.Duration(cfg.License.HeartbeatTimeout) * time.Second
// 计算时间点
now := time.Now()
onlineThreshold := now.Add(-offlineTimeout)
// Time boundaries
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
yesterdayStart := todayStart.AddDate(0, 0, -1)
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
expire7d := now.AddDate(0, 0, 7)
expire30d := now.AddDate(0, 0, 30)
lastMonth := now.AddDate(0, -1, 0)
var stats models.StatsOverviewResponse
// 执行统计查询
err := s.db.Transaction(func(tx *gorm.DB) error {
// 1. 总授权码数量
// 1. Total auth codes (stock)
if err := tx.Model(&models.AuthorizationCode{}).Count(&stats.TotalAuthCodes).Error; err != nil {
return err
}
// 2. 活跃许可证数量(状态为active)
// 2. Active licenses (stock)
if err := tx.Model(&models.License{}).Where("status = ?", "active").Count(&stats.ActiveLicenses).Error; err != nil {
return err
}
// 3. 即将过期数量(授权码在30天内过期,且状态为active)
expireThreshold := now.AddDate(0, 0, 30)
if err := tx.Model(&models.AuthorizationCode{}).
Where("end_date <= ? AND end_date > ? AND is_locked = false", expireThreshold, now).
Count(&stats.ExpiringSoon).Error; err != nil {
// 3. Licenses created today (flow)
if err := tx.Model(&models.License{}).Where("created_at >= ?", todayStart).Count(&stats.TodayNewLicenses).Error; err != nil {
return err
}
// 4. 异常告警数量(许可证超时未心跳)
// 4. Licenses created yesterday (flow, for comparison)
if err := tx.Model(&models.License{}).Where("created_at >= ? AND created_at < ?", yesterdayStart, todayStart).Count(&stats.YesterdayNewLicenses).Error; err != nil {
return err
}
// 5. Auth codes created this calendar month (flow)
if err := tx.Model(&models.AuthorizationCode{}).Where("created_at >= ?", monthStart).Count(&stats.MonthNewAuthCodes).Error; err != nil {
return err
}
// 6. Expiring within 7 days (risk, not locked)
if err := tx.Model(&models.AuthorizationCode{}).
Where("end_date <= ? AND end_date > ? AND is_locked = false", expire7d, now).
Count(&stats.ExpiringIn7Days).Error; err != nil {
return err
}
// 7. Expiring within 30 days (risk, not locked)
if err := tx.Model(&models.AuthorizationCode{}).
Where("end_date <= ? AND end_date > ? AND is_locked = false", expire30d, now).
Count(&stats.ExpiringIn30Days).Error; err != nil {
return err
}
// 8. Abnormal alerts: active licenses with heartbeat timeout
if err := tx.Model(&models.License{}).
Where("status = 'active' AND (last_heartbeat < ? OR last_heartbeat IS NULL)", onlineThreshold).
Count(&stats.AbnormalAlerts).Error; err != nil {
return err
}
// 5. 计算同比上月的授权码和许可证数量(用于计算增长率)
// 9. MoM growth rates (sub-text only, not standalone cards)
var lastMonthAuthCodes, lastMonthActiveLicenses int64
// 上月同期授权码总数(一个月前同一时刻的累计总数)
if err := tx.Model(&models.AuthorizationCode{}).
Where("created_at <= ?", lastMonth).
Count(&lastMonthAuthCodes).Error; err != nil {
return err
}
// 上月同期活跃许可证总数(一个月前同一时刻的状态为active的许可证数)
if err := tx.Model(&models.License{}).
Where("status = ? AND created_at <= ?", "active", lastMonth).
Count(&lastMonthActiveLicenses).Error; err != nil {
return err
}
// 计算增长率(同比上月:当前总数相比一个月前总数的增长率)
if lastMonthAuthCodes > 0 {
stats.GrowthRate.AuthCodes = float64(stats.TotalAuthCodes-lastMonthAuthCodes) / float64(lastMonthAuthCodes) * 100
} else {
stats.GrowthRate.AuthCodes = 0
stats.GrowthRate.AuthCodesMoM = float64(stats.TotalAuthCodes-lastMonthAuthCodes) / float64(lastMonthAuthCodes) * 100
} else if stats.TotalAuthCodes > 0 {
stats.GrowthRate.AuthCodesMoM = 100.0
}
if lastMonthActiveLicenses > 0 {
stats.GrowthRate.Licenses = float64(stats.ActiveLicenses-lastMonthActiveLicenses) / float64(lastMonthActiveLicenses) * 100
} else {
stats.GrowthRate.Licenses = 0
stats.GrowthRate.LicensesMoM = float64(stats.ActiveLicenses-lastMonthActiveLicenses) / float64(lastMonthActiveLicenses) * 100
} else if stats.ActiveLicenses > 0 {
stats.GrowthRate.LicensesMoM = 100.0
}
return nil
+230
View File
@@ -0,0 +1,230 @@
package service
import (
"context"
"encoding/json"
"fmt"
"license-manager/internal/models"
"license-manager/internal/repository"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
"time"
"gorm.io/gorm"
)
// PackageService 套餐服务接口
type PackageService interface {
// 管理端接口
CreatePackage(ctx context.Context, req *models.PackageCreateRequest) (*models.Package, error)
UpdatePackage(ctx context.Context, id string, req *models.PackageUpdateRequest) (*models.Package, error)
DeletePackage(ctx context.Context, id string) error
GetPackageList(ctx context.Context, req *models.PackageListRequest) (*models.PackageListResponse, error)
GetPackageDetail(ctx context.Context, id string) (*models.Package, error)
// 用户端接口
GetCuPackageList(ctx context.Context) ([]*models.CuPackageResponse, error)
}
type packageService struct {
repo repository.PackageRepository
db *gorm.DB
}
// NewPackageService 创建套餐服务
func NewPackageService(repo repository.PackageRepository, db *gorm.DB) PackageService {
return &packageService{
repo: repo,
db: db,
}
}
// ========== 管理端接口实现 ==========
func (s *packageService) CreatePackage(ctx context.Context, req *models.PackageCreateRequest) (*models.Package, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
pkg := &models.Package{
Name: req.Name,
Type: req.Type,
Price: req.Price,
PriceDescription: req.PriceDescription,
DurationDescription: req.DurationDescription,
Description: req.Description,
Features: req.Features,
Status: req.Status,
SortOrder: req.SortOrder,
Remark: req.Remark,
}
if err := s.repo.Create(pkg); err != nil {
return nil, i18n.NewI18nError("800001", lang)
}
return pkg, nil
}
func (s *packageService) UpdatePackage(ctx context.Context, id string, req *models.PackageUpdateRequest) (*models.Package, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
pkg, err := s.repo.GetByID(id)
if err != nil {
return nil, i18n.NewI18nError("800002", lang)
}
// 更新字段
if req.Name != "" {
pkg.Name = req.Name
}
if req.Type != "" {
pkg.Type = req.Type
}
if req.Price >= 0 {
pkg.Price = req.Price
}
if req.PriceDescription != "" {
pkg.PriceDescription = req.PriceDescription
}
if req.DurationDescription != "" {
pkg.DurationDescription = req.DurationDescription
}
if req.Description != "" {
pkg.Description = req.Description
}
if req.Features != "" {
pkg.Features = req.Features
}
if req.Status != nil {
pkg.Status = *req.Status
}
if req.SortOrder != nil {
pkg.SortOrder = *req.SortOrder
}
if req.Remark != "" {
pkg.Remark = req.Remark
}
pkg.UpdatedAt = time.Now()
if err := s.repo.Update(pkg); err != nil {
return nil, i18n.NewI18nError("800003", lang)
}
return pkg, nil
}
func (s *packageService) DeletePackage(ctx context.Context, id string) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 检查是否存在
_, err := s.repo.GetByID(id)
if err != nil {
return i18n.NewI18nError("800002", lang)
}
if err := s.repo.Delete(id); err != nil {
return i18n.NewI18nError("800004", lang)
}
return nil
}
func (s *packageService) GetPackageList(ctx context.Context, req *models.PackageListRequest) (*models.PackageListResponse, error) {
packages, total, err := s.repo.GetList(ctx, req)
if err != nil {
lang := pkgcontext.GetLanguageFromContext(ctx)
return nil, i18n.NewI18nError("800005", lang)
}
responses := make([]*models.PackageResponse, len(packages))
for i, pkg := range packages {
responses[i] = pkg.ToResponse()
}
return &models.PackageListResponse{
Packages: responses,
TotalCount: total,
}, nil
}
func (s *packageService) GetPackageDetail(ctx context.Context, id string) (*models.Package, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
pkg, err := s.repo.GetByID(id)
if err != nil {
return nil, i18n.NewI18nError("800002", lang)
}
return pkg, nil
}
// ========== 用户端接口实现 ==========
func (s *packageService) GetCuPackageList(ctx context.Context) ([]*models.CuPackageResponse, error) {
packages, err := s.repo.GetEnabledList(ctx)
if err != nil {
return nil, err
}
responses := make([]*models.CuPackageResponse, len(packages))
for i, pkg := range packages {
responses[i] = s.toCuPackageResponse(pkg)
}
return responses, nil
}
// toCuPackageResponse 转换为用户端套餐响应(兼容原有结构)
func (s *packageService) toCuPackageResponse(pkg *models.Package) *models.CuPackageResponse {
// 从features中解析max_devices
maxDevices := 1000 // 默认值
if pkg.Features != "" {
var features []string
if err := json.Unmarshal([]byte(pkg.Features), &features); err == nil {
for _, f := range features {
// 尝试解析"X个许可"格式
var n int
if _, err := fmt.Sscanf(f, "%d个许可", &n); err == nil {
maxDevices = n
break
}
}
}
}
// 构建details字符串
details := pkg.Description
if pkg.DurationDescription != "" {
if details != "" {
details += "|"
}
details += pkg.DurationDescription
}
if maxDevices > 0 {
if details != "" {
details += "|"
}
details += fmt.Sprintf("%d个许可", maxDevices)
}
// 计算 display_price
displayPrice := ""
if pkg.Price != 0 {
displayPrice = fmt.Sprintf("¥%g", pkg.Price)
} else {
displayPrice = pkg.PriceDescription
}
return &models.CuPackageResponse{
ID: pkg.ID,
Name: pkg.Name,
Type: pkg.Type,
Price: pkg.Price,
DisplayPrice: displayPrice,
PriceDescription: pkg.PriceDescription,
MaxDevices: maxDevices,
Description: pkg.Description,
Features: pkg.Features,
Details: details,
}
}
@@ -127,6 +127,7 @@ func (s *paymentService) CreatePayment(ctx context.Context, req *models.PaymentC
PaymentProvider: req.PaymentMethod,
Status: models.PaymentStatusPending,
ExpireTime: expireTime,
ExtraData: req.ExtraData,
}
// 如果是支付宝,生成支付URL
@@ -279,6 +280,42 @@ func (s *paymentService) ProcessAlipayCallback(ctx context.Context, values url.V
}
// 支付成功:根据业务类型处理
if payment.BusinessType == models.BusinessTypeLicenseExtend {
if payment.ExtraData == nil || strings.TrimSpace(*payment.ExtraData) == "" {
return nil
}
var renewalData struct {
AuthorizationCodeID string `json:"authorization_code_id"`
RenewDays int `json:"renew_days"`
}
if err := json.Unmarshal([]byte(*payment.ExtraData), &renewalData); err != nil {
return err
}
if strings.TrimSpace(renewalData.AuthorizationCodeID) == "" || renewalData.RenewDays <= 0 {
return nil
}
var authCode models.AuthorizationCode
if err := tx.Where("id = ?", renewalData.AuthorizationCodeID).First(&authCode).Error; err != nil {
return err
}
baseDate := authCode.EndDate
now := time.Now()
if baseDate.Before(now) {
baseDate = now
}
newEndDate := baseDate.AddDate(0, 0, renewalData.RenewDays)
return tx.Model(&models.AuthorizationCode{}).
Where("id = ?", authCode.ID).
Updates(map[string]interface{}{
"end_date": newEndDate,
"updated_at": now,
}).Error
}
if payment.BusinessType != models.BusinessTypePackageOrder || payment.BusinessID == nil || strings.TrimSpace(*payment.BusinessID) == "" {
return nil
}
@@ -0,0 +1,64 @@
-- 发票管理表
-- 支持订单开票申请、管理员处理、文件上传等完整发票管理流程
-- MySQL版本
-- 创建发票表
CREATE TABLE `invoices` (
`id` varchar(36) NOT NULL,
`invoice_no` varchar(50) NOT NULL COMMENT '发票申请号',
`order_id` varchar(36) NOT NULL COMMENT '订单ID(唯一)',
`order_no` varchar(50) NOT NULL COMMENT '订单号(冗余快照,便于检索)',
`customer_id` varchar(36) NOT NULL COMMENT '客户ID',
`cu_user_id` varchar(36) NOT NULL COMMENT '申请人(客户用户)ID',
`amount` decimal(10,2) NOT NULL COMMENT '发票金额(取订单total_amount)',
`status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/issued/rejected',
`invoice_type` varchar(20) NOT NULL COMMENT 'personal/enterprise/vat_special',
`title` varchar(200) NOT NULL COMMENT '发票抬头',
`taxpayer_id` varchar(50) DEFAULT NULL COMMENT '纳税人识别号',
`content` varchar(200) NOT NULL COMMENT '开票内容',
`receiver_email` varchar(255) NOT NULL COMMENT '收票邮箱',
`remark` varchar(1000) DEFAULT NULL COMMENT '备注',
`invoice_file_url` varchar(500) DEFAULT NULL COMMENT '发票文件URL(PDF)',
`uploaded_at` datetime(3) DEFAULT NULL COMMENT '上传时间',
`uploaded_by` varchar(36) DEFAULT NULL COMMENT '上传人(管理员)ID',
`issued_at` datetime(3) DEFAULT NULL COMMENT '开票完成时间',
`reject_reason` varchar(500) DEFAULT NULL COMMENT '驳回原因',
`suggestion` varchar(500) DEFAULT NULL COMMENT '建议修改内容',
`rejected_at` datetime(3) DEFAULT NULL COMMENT '驳回时间',
`rejected_by` varchar(36) DEFAULT NULL COMMENT '驳回人(管理员)ID',
`download_token` varchar(64) DEFAULT NULL COMMENT '下载token(可选)',
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_invoices_invoice_no` (`invoice_no`),
UNIQUE KEY `idx_invoices_order_id` (`order_id`),
KEY `idx_invoices_customer_id` (`customer_id`),
KEY `idx_invoices_cu_user_id` (`cu_user_id`),
KEY `idx_invoices_status` (`status`),
KEY `idx_invoices_created_at` (`created_at`),
KEY `idx_invoices_deleted_at` (`deleted_at`),
KEY `idx_invoices_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='发票管理表 - 支持订单开票申请和处理流程';
-- 外键约束
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_order_id` FOREIGN KEY (`order_id`) REFERENCES `cu_orders` (`id`) ON DELETE CASCADE;
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_customer_id` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE CASCADE;
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_cu_user_id` FOREIGN KEY (`cu_user_id`) REFERENCES `cu_users` (`id`) ON DELETE CASCADE;
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_uploaded_by` FOREIGN KEY (`uploaded_by`) REFERENCES `users` (`id`) ON DELETE SET NULL;
ALTER TABLE `invoices` ADD CONSTRAINT `fk_invoices_rejected_by` FOREIGN KEY (`rejected_by`) REFERENCES `users` (`id`) ON DELETE SET NULL;
-- 注意:
-- 1. 时间戳由Go应用程序管理,不依赖数据库默认值
-- 2. invoice_no全局唯一,作为发票申请的主要标识
-- 3. order_id唯一,确保一个订单最多一张发票
-- 4. 使用软删除,通过deleted_at字段实现
-- 5. 外键约束确保数据一致性,管理员字段允许为NULL
@@ -0,0 +1,36 @@
-- 套餐表
-- 用于管理员配置产品套餐,用户端从数据库查询
CREATE TABLE packages (
-- 基础信息
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
name VARCHAR(100) NOT NULL COMMENT '套餐名称',
type VARCHAR(20) NOT NULL COMMENT '套餐类型: trial-试用版, basic-基础版, professional-专业版, custom-定制版',
price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
price_description VARCHAR(100) DEFAULT '' COMMENT '价格描述,如"定制报价"',
duration_description VARCHAR(200) DEFAULT '' COMMENT '期限描述,如"永久有效"、"当月25日到期"',
description VARCHAR(500) DEFAULT '' COMMENT '套餐说明',
features TEXT DEFAULT '' COMMENT '功能项,JSON格式: ["功能1", "功能2"]',
-- 状态信息
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 1-启用, 0-禁用',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序,数字越大越靠前',
remark VARCHAR(500) DEFAULT '' COMMENT '备注',
-- 时间信息
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME NULL,
-- 索引
INDEX idx_packages_type (type),
INDEX idx_packages_status (status),
INDEX idx_packages_sort_order (sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品套餐表';
-- 插入初始数据
INSERT INTO packages (id, name, type, price, price_description, duration_description, description, features, status, sort_order, created_at, updated_at) VALUES
("DEMO-TRIAL", '试用版', 'trial', 0, '¥0', '当月25日到期', '免费体验全部功能', '["全部功能", "1个许可"]', 1, 1, NOW(), NOW()),
("DEMO-BASIC", '基础版', 'basic', 300, '300元/许可', '永久有效', '小型企业最佳选择', '["基础功能", "批量许可购买"]', 1, 2, NOW(), NOW()),
("DEMO-PRO", '专业版', 'professional', 2000, '2000元/许可', '永久有效', '企业级完整解决方案', '["全部功能", "技术支持", "数据分析"]', 1, 3, NOW(), NOW()),
("DEMO-CUSTOM", '企业定制版', 'custom', 0, '定制报价', '按需定制', '按需定制解决方案', '["按需定制", "专属服务"]', 1, 4, NOW(), NOW());
@@ -0,0 +1,20 @@
-- 企业线索表
CREATE TABLE leads (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
lead_no VARCHAR(50) NOT NULL COMMENT '线索编号,系统生成',
company_name VARCHAR(200) NOT NULL COMMENT '企业/机构名称',
contact_name VARCHAR(100) NOT NULL COMMENT '联系人',
contact_phone VARCHAR(20) NOT NULL COMMENT '联系电话',
contact_email VARCHAR(100) COMMENT '邮箱地址',
requirement TEXT NOT NULL COMMENT '需求描述',
extra_info TEXT COMMENT '补充信息',
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending-待联系, contacted-已联系, converted-已成交, invalid-已失效',
follow_up_date DATETIME COMMENT '跟进日期',
follow_up_record TEXT COMMENT '跟进记录',
internal_note TEXT COMMENT '内部备注',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_leads_status (status),
INDEX idx_leads_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业线索表';
+12
View File
@@ -126,6 +126,18 @@ func getHTTPStatusByCode(code string) int {
return StatusOK
case "30": // 授权模块错误,默认200(业务错误通过响应体区分)
return StatusOK
case "40": // 仪表盘模块错误,默认200(业务错误通过响应体区分)
return StatusOK
case "50": // 客户用户模块错误,默认200(业务错误通过响应体区分)
return StatusOK
case "60": // 套餐订单模块错误,默认200(业务错误通过响应体区分)
return StatusOK
case "61": // 订单模块错误,默认200(业务错误通过响应体区分)
return StatusOK
case "62": // 设备模块错误,默认200(业务错误通过响应体区分)
return StatusOK
case "70": // 发票模块错误,默认200(业务错误通过响应体区分)
return StatusOK
case "90": // 系统错误,默认500
return StatusInternalServerError
}
-105
View File
@@ -1,105 +0,0 @@
package utils
import (
"context"
"testing"
"license-manager/internal/config"
"license-manager/pkg/cache"
)
func TestSMSServiceInitialization(t *testing.T) {
// 创建内存缓存用于测试
memCache := cache.NewMemoryCache(100)
// 创建SMS配置
smsConfig := &config.SMSConfig{
Enabled: false, // 测试禁用状态
AccessKeyID: "test-key-id",
AccessKeySecret: "test-key-secret",
SignName: "Test Sign",
RegionID: "cn-hangzhou",
Endpoint: "dysmsapi.aliyuncs.com",
}
// 测试创建SMS服务
smsService, err := NewSMSService(smsConfig, memCache, nil)
if err != nil {
t.Fatalf("Failed to create SMS service: %v", err)
}
if smsService == nil {
t.Fatal("SMS service should not be nil")
}
// 测试禁用状态下的方法调用
ctx := context.Background()
// 这些方法在禁用状态下应该返回错误
err = smsService.SendVerificationCode(ctx, "+8613800012345", "+86", "test-template")
if err == nil {
t.Error("SendVerificationCode should return error when SMS is disabled")
}
_, err = smsService.VerifyCode(ctx, "+8613800012345", "+86", "123456")
if err == nil {
t.Error("VerifyCode should return error when SMS is disabled")
}
exists := smsService.IsRateLimited(ctx, "+8613800012345", "+86")
if !exists { // 禁用状态下应该总是限流
t.Error("IsRateLimited should return true when SMS is disabled")
}
}
func TestSMSEnabledService(t *testing.T) {
// 创建内存缓存用于测试
memCache := cache.NewMemoryCache(100)
// 创建SMS配置(启用状态)
smsConfig := &config.SMSConfig{
Enabled: true,
AccessKeyID: "test-key-id",
AccessKeySecret: "test-key-secret",
SignName: "Test Sign",
RegionID: "cn-hangzhou",
Endpoint: "dysmsapi.aliyuncs.com",
Templates: config.SMSTemplates{
Register: "SMS_111111111",
ResetPwd: "SMS_222222222",
Login: "SMS_333333333",
CurrentPhone: "SMS_444444444",
NewPhone: "SMS_555555555",
},
}
// 测试创建SMS服务
smsService, err := NewSMSService(smsConfig, memCache, nil)
if err != nil {
t.Fatalf("Failed to create SMS service: %v", err)
}
if smsService == nil {
t.Fatal("SMS service should not be nil")
}
// 测试模板配置是否正确应用
// 注意:由于smsService是内部类型,我们通过接口方法间接验证
// 在实际应用中,SendCurrentPhoneCode会使用配置中的current_phone模板
ctx := context.Background()
// 测试频率限制(应该允许第一次发送)
isLimited := smsService.IsRateLimited(ctx, "+8613800012345", "+86")
if isLimited {
t.Error("First SMS should not be rate limited")
}
// 测试发送登录验证码(会因为无效的AccessKey而失败,但不应该panic)
err = smsService.SendLoginCode(ctx, "+8613800012345", "+86")
if err == nil {
t.Log("SMS send succeeded (unexpected with test credentials)")
} else {
t.Logf("SMS send failed as expected: %v", err)
}
}
+2 -2
View File
@@ -32,7 +32,7 @@ services:
# Backend service - using GitHub registry image
backend:
image: ghcr.io/cedar-v/license-manager-backend:v1.0.4
image: ghcr.io/cedar-v/license-manager-backend:v1.1.0
container_name: license-manager-backend-git
restart: unless-stopped
environment:
@@ -69,7 +69,7 @@ services:
# Frontend service - using GitHub registry image
frontend:
image: ghcr.io/cedar-v/license-manager-frontend:v1.0.4
image: ghcr.io/cedar-v/license-manager-frontend:v1.1.0
container_name: license-manager-frontend-git
restart: unless-stopped
environment:
+40
View File
@@ -0,0 +1,40 @@
version: '3.8'
services:
backend:
image: ${REGISTRY:-localhost:9988}/${NAMESPACE:-leeexin}/license-manager-backend:${VERSION:-latest}
container_name: license-manager-backend
restart: unless-stopped
environment:
- TZ=Asia/Shanghai
- GIN_MODE=release
ports:
- "18888:18888"
volumes:
- ./backend/configs:/app/backend/configs
- ./logs:/app/logs
- ./backend/configs/config.prod.yaml:/app/backend/cmd/config.yaml:ro
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:18888/health || exit 1"]
interval: 10s
timeout: 10s
retries: 6
start_period: 60s
frontend:
image: ${REGISTRY:-localhost:9988}/${NAMESPACE:-leeexin}/license-manager-frontend:${VERSION:-latest}
container_name: license-manager-frontend
restart: unless-stopped
environment:
- TZ=Asia/Shanghai
ports:
- "18080:80"
depends_on:
backend:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost/ || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
@@ -1,345 +0,0 @@
# 客户属性设计与Web显示方案
## 1. 核心客户属性
### 基础标识
- `id` - 唯一标识符
- **作用**: 数据库主键,系统内部引用
- **生成**: UUID自动生成
- **必填**: 是
- `customer_code` - 客户编码 (如: CUS-2025-001)
- **作用**: 业务识别码,便于客服沟通和查找
- **生成**: CUS-YYYY-NNNN格式,系统自动递增
- **必填**: 是
- **唯一**: 是
- **意义**: 比数据库ID更有业务意义,支持按年度统计
- `customer_name` - 客户名称/公司名
- **作用**: 主要识别信息,合同签署主体
- **示例**: "北京科技有限公司"、"张三"
- **必填**: 是
- **搜索**: 支持模糊搜索
- `customer_type` - 客户类型 (个人/企业/政府/教育)
- **作用**: 区分客户类别,影响服务策略和定价
- **枚举**: individual(个人) | enterprise(企业) | government(政府) | education(教育)
- **必填**: 是
- **默认**: enterprise
### 联系信息
- `contact_person` - 联系人姓名
- **作用**: 授权通知和技术支持的对接人
- **必填**: 是
- **示例**: "张三"
- `contact_title` - 联系人职务
- **作用**: 了解联系人在组织中的角色,便于沟通
- **必填**: 否
- **示例**: "技术总监"、"IT经理"
- `email` - 主邮箱
- **作用**: 授权激活通知、过期提醒、技术支持的主要渠道
- **必填**: 是
- **唯一**: 建议唯一
- **验证**: 邮箱格式验证
- `phone` - 电话号码
- **作用**: 紧急联系、电话技术支持
- **必填**: 否
- **格式**: 支持手机和固话
### 地址信息
- `address` - 地址
- **作用**: 联系地址,邮寄材料或现场服务时使用
- **必填**: 否
- **示例**: "北京市朝阳区科技园区100号"
- **格式**: 自由文本,用户可详细可简略
### 商业属性
- `company_size` - 企业规模 (小型/中型/大型/集团)
- **作用**: 影响定价策略和服务等级
- **枚举**: small(小型) | medium(中型) | large(大型) | enterprise(集团)
- **必填**: 否
- **用途**: 制定差异化授权策略
### 授权配置
- `preferred_license_type` - 首选授权类型 (在线/离线/混合)
- **作用**: 影响授权码生成策略和验证方式
- **枚举**: online(在线) | offline(离线) | hybrid(混合)
- **必填**: 否
- **默认**: online
- **意义**: 在线需要联网验证,离线可本地验证
### 客户分级
- `customer_level` - 客户等级 (普通/VIP/企业/战略)
- **作用**: 影响服务优先级、授权数量限制、技术支持等级
- **枚举**: normal(普通) | vip(VIP) | enterprise(企业) | strategic(战略)
- **必填**: 否
- **默认**: normal
- **影响**: 高等级客户可获得更多授权、更快响应
### 状态管理
- `status` - 客户状态 (正常/禁用)
- **作用**: 控制是否允许为该客户生成新授权
- **枚举**: active(正常) | disabled(禁用)
- **必填**: 是
- **默认**: active
- **影响**: 禁用状态下不能生成新授权,但现有授权仍可使用
- `description` - 客户描述/备注
- **作用**: 记录客户特殊情况、业务背景、注意事项
- **必填**: 否
- **示例**: "重要客户,IoT平台采购方,需要优先技术支持"
### 时间字段
- `created_at` - 创建时间
- **作用**: 记录客户注册时间,用于统计分析
- **自动**: 系统自动记录
- **不可修改**: 是
- `updated_at` - 更新时间
- **作用**: 记录最后修改时间,追踪数据变更
- **自动**: 系统自动更新
- **触发**: 任何字段修改都会更新
- `created_by` - 创建人
- **作用**: 记录是哪个管理员创建的客户
- **必填**: 是
- **关联**: 管理员用户ID
- `updated_by` - 更新人
- **作用**: 记录最后修改人,便于问题追溯
- **自动**: 系统自动记录当前操作员
---
## 2. Web列表页显示信息
### 主要显示列
```
| 客户编码 | 客户名称 | 客户类型 | 联系人 | 邮箱 | 客户等级 | 状态 | 创建时间 | 操作 |
```
### 具体显示内容与作用
**客户编码** (`customer_code`)
- 显示: CUS-2025-001
- 作用: 快速识别客户,支持精确搜索,客服沟通时的标准引用
- 交互: 点击可复制到剪贴板
**客户名称** (`customer_name`)
- 显示: 北京科技有限公司
- 作用: 主要识别信息,最重要的显示内容
- 交互: 点击进入客户详情页
- 样式: 可截断显示,悬停显示完整名称
**客户类型** (`customer_type`)
- 显示: 🏢企业 | 👤个人 | 🏛️政府 | 🎓教育
- 作用: 快速区分客户类别,影响服务策略
- 样式: 用图标+文字+颜色区分
- 筛选: 支持按类型筛选
**联系人** (`contact_person`)
- 显示: 张三
- 作用: 知道具体联系谁,便于沟通对接
- 交互: 悬停显示联系人职务信息
**邮箱** (`email`)
- 显示: zhangsan@example.com
- 作用: 重要联系方式,授权通知的主要渠道
- 交互: 点击可发送邮件
- 验证: 显示邮箱验证状态(已验证/未验证)
**客户等级** (`customer_level`)
- 显示: 🏢企业 | 👤普通 | ⭐VIP | 💼战略
- 作用: 显示客户重要程度,影响服务优先级
- 样式: 不同等级用不同颜色和图标
- 排序: 支持按等级排序
**状态** (`status`)
- 显示: ✅正常 | ❌禁用
- 作用: 显示客户是否可以生成新授权
- 交互: 可直接点击切换状态(需要确认)
- 样式: 正常为绿色,禁用为红色
**创建时间** (`created_at`)
- 显示: 2025-01-15
- 作用: 了解客户注册时间,便于分析客户生命周期
- 格式: YYYY-MM-DD
- 排序: 默认按创建时间倒序
**操作**
- 查看详情: 进入客户详情页
- 编辑: 修改客户信息
- 授权管理: 查看该客户的所有授权
- 生成授权: 快速为该客户生成新授权
- 更多操作: 禁用/启用、删除等
---
## 3. Web详情页显示信息
### 基本信息卡片
```
客户编码: CUS-2025-001 客户类型: 🏢企业
客户名称: 北京科技有限公司 客户等级: ⭐VIP客户
联系人: 张三 (技术总监) 当前状态: ✅正常
邮箱: zhangsan@example.com 电话: 13800138000
```
**用途**: 核心信息一览,便于快速了解客户基本情况
### 地址信息卡片
```
地址: 北京市朝阳区科技园区100号
```
**用途**: 联系地址,邮寄材料或现场服务参考
### 商业信息卡片
```
企业规模: 中型企业 首选授权: 在线授权
备注描述: 重要客户,IoT平台采购方,需要优先技术支持
```
**用途**: 业务背景了解,制定针对性服务策略
### 状态信息卡片
```
当前状态: ✅正常 创建时间: 2025-01-15 10:30:00
创建人: 李四(管理员) 更新时间: 2025-01-20 15:45:00
更新人: 王五(客服)
```
**用途**: 跟踪客户生命周期,了解操作历史
### 授权统计卡片
```
总授权数: 3个 活跃授权: 2个
过期授权: 1个 最近授权: 2025-01-18
即将过期: 1个(7天内) 授权设备总数: 85台
```
**用途**: 快速了解客户授权使用情况,识别续费机会
---
## 4. 列表页功能特性
### 搜索筛选
- **全文搜索框**: 支持客户编码、名称、联系人、邮箱的模糊搜索
- **作用**: 快速定位目标客户
- **实现**: 实时搜索,输入即显示结果
- **客户类型筛选**: 全部 | 企业 | 个人 | 政府 | 教育
- **作用**: 按业务类型分类查看
- **场景**: 针对不同类型客户制定营销策略
- **客户等级筛选**: 全部 | 普通 | VIP | 企业 | 战略
- **作用**: 重点关注高价值客户
- **场景**: 优先处理VIP和战略客户的需求
- **状态筛选**: 全部 | 正常 | 禁用
- **作用**: 识别问题客户,及时处理
- **场景**: 定期清理禁用客户,分析禁用原因
### 排序功能
- **按创建时间排序** (默认)
- **作用**: 了解客户注册趋势,关注新客户
- **按客户名称排序**
- **作用**: 字母序查找,便于浏览
- **按客户等级排序**
- **作用**: 优先显示重要客户
- **按更新时间排序**
- **作用**: 查看最近活跃的客户
### 批量操作
- **批量启用/禁用**
- **作用**: 批量处理客户状态变更
- **场景**: 批量处理欠费客户、恢复客户服务
- **批量导出**
- **作用**: 导出客户数据进行分析
- **格式**: Excel、CSV格式
- **批量删除**
- **作用**: 清理无效客户数据
- **安全**: 需要二次确认,软删除机制
### 分页显示
- **每页条数**: 10/20/50/100条可选
- **默认**: 20条
- **作用**: 平衡加载速度和浏览效率
- **总数统计**: 显示"共1,234个客户"
- **作用**: 了解客户总体规模
- **页码导航**: 支持跳转到指定页
- **作用**: 快速浏览大量数据
---
## 5. 显示优化建议
### 响应式设计
- **桌面端(>1200px)**: 显示所有列
- **作用**: 完整信息展示,提高工作效率
- **平板端(768-1200px)**: 隐藏地址、电话等次要信息
- **作用**: 保持核心信息,适应中等屏幕
- **手机端(<768px)**: 卡片式布局,只显示客户名称、等级、状态
- **作用**: 移动端快速浏览,支持现场客户查询
### 数据可视化(仪表盘)
- **客户等级分布饼图**
- **作用**: 了解客户结构,制定等级提升策略
- **客户类型统计柱图**
- **作用**: 分析目标市场,调整营销重点
- **月度新增客户趋势图**
- **作用**: 监控业务增长,评估营销效果
### 交互优化
- **悬停显示完整信息**
- **场景**: 客户名称过长时显示完整名称
- **内容**: 联系人悬停显示职务和电话
- **点击客户名称进入详情页**
- **作用**: 快速访问详细信息
- **状态切换快捷操作**
- **实现**: 点击状态图标直接切换,需要确认
- **作用**: 快速处理客户状态变更
- **右键菜单快速操作**
- **功能**: 编辑、查看授权、生成授权、禁用等
- **作用**: 提高操作效率,减少页面跳转
### 性能优化
- **虚拟滚动**: 大数据量时采用虚拟滚动
- **触发**: 客户数>1000时
- **作用**: 保证页面流畅性
- **延迟加载**: 图片和非关键信息延迟加载
- **作用**: 提高首屏加载速度
- **缓存策略**: 客户列表数据缓存5分钟
- **作用**: 减少不必要的服务器请求
## 6. 业务价值总结
### 提高工作效率
- **快速识别**: 通过客户编码和等级快速识别重要客户
- **精准搜索**: 多维度搜索快速定位目标客户
- **批量操作**: 减少重复性操作,提高处理效率
### 优化客户服务
- **服务分级**: 根据客户等级提供差异化服务
- **及时响应**: 通过状态管理及时识别问题客户
- **个性化**: 根据客户偏好(授权类型)提供合适的解决方案
### 支撑业务决策
- **客户分析**: 通过等级、类型分布了解客户结构
- **趋势监控**: 通过创建时间分析客户增长趋势
- **风险控制**: 通过状态管理防范业务风险
-127
View File
@@ -1,127 +0,0 @@
# 软件授权管理平台 - 目录结构说明
## 项目整体结构
```
license-manager/
├── README.md # 项目说明文档
├── LICENSE # 开源许可证
├── .gitignore # Git忽略文件配置
├── Dockerfile # Docker构建配置
├── docker-compose.yml # Docker编排配置
├── Makefile # 构建自动化脚本
│
├── backend/ # 后端服务目录
├── frontend/ # 前端应用目录
├── docs/ # 项目文档目录
├── scripts/ # 项目级脚本目录
├── tests/ # 端到端测试目录
└── bin/ # 编译产物目录
```
## 前端目录 (frontend/)
```
frontend/
├── public/ # 静态资源目录
│ ├── index.html # HTML模板
│ └── favicon.ico # 网站图标
├── src/ # 前端源代码
│ ├── components/ # Vue组件
│ ├── views/ # 页面视图
│ ├── router/ # 路由配置
│ ├── store/ # 状态管理(Pinia)
│ ├── api/ # API请求封装
│ ├── utils/ # 工具函数
│ ├── assets/ # 资源文件
│ └── main.js # 应用入口
├── package.json # NPM依赖配置
├── vite.config.js # Vite构建配置
└── .env.example # 环境变量模板
```
## 文档目录 (docs/)
```
docs/
├── api/ # API接口文档
├── architecture/ # 架构设计文档
│ ├── 需求说明书.md # 项目需求规格
│ └── 目录结构说明.md # 本文档
└── deployment/ # 部署相关文档
```
## 脚本目录 (scripts/)
```
scripts/
├── build.sh # 项目构建脚本
├── deploy.sh # 部署脚本
└── dev.sh # 开发环境启动脚本
```
## 测试目录 (tests/)
```
tests/
├── integration/ # 集成测试
└── e2e/ # 端到端测试
```
## 编译产物目录 (bin/)
```
bin/
└── license-manager # 编译后的可执行文件
```
## 目录设计原则
### 1. 前后端分离
- backend/ 和 frontend/ 完全独立,可以分别开发和部署
- 各自拥有独立的依赖管理和构建配置
### 2. 职责清晰
- internal/ 存放内部业务逻辑,不对外暴露
- pkg/ 存放可重用的公共代码
- cmd/ 只包含应用启动代码
### 3. 标准化
- 遵循Go项目标准布局
- 遵循Vue.js项目最佳实践
- 符合开源项目规范
### 4. 易于维护
- 配置文件集中管理
- 脚本统一放置
- 文档结构清晰
## 开发流程
### 开发环境启动
```bash
# 启动开发环境(前后端同时启动)
./scripts/dev.sh
```
### 项目构建
```bash
# 构建整个项目
./scripts/build.sh
```
### Docker部署
```bash
# 构建Docker镜像
docker build -t license-manager .
# 运行容器
docker run -p 8080:8080 license-manager
```
## 注意事项
1. 权限管理:确保脚本文件具有执行权限
2. 配置文件:不要提交包含敏感信息的配置文件
3. 依赖管理:定期更新依赖版本,注意安全漏洞
4. 代码规范:前后端代码都要遵循各自的编码规范
-126
View File
@@ -1,126 +0,0 @@
# 软件授权管理平台需求规格说明书
## 1. 项目概述
### 1.1 项目背景
为了保护软件知识产权,规范软件使用权限,需要开发一个独立的软件授权管理平台,为IoT平台等软件系统提供授权码生成、分发、验证和管理服务。
### 1.2 项目目标
- 建立统一的软件授权管理系统
- 实现基于硬件绑定的授权机制
- 提供在线和离线两种授权模式
- 支持授权状态实时监控和管理
- 提供完整的客户管理功能
### 1.3 系统定位
本系统作为独立的授权管理平台,通过Web管理界面和RESTful API为其他软件系统提供授权服务。
## 2. 系统功能需求
### 2.1 客户管理模块
- 支持客户基本信息的增删改查(姓名、公司、联系人、邮箱、电话、地址、状态、描述)
- 支持客户状态管理(激活/停用)
- 提供客户列表查看、详情查看和JSON API接口
### 2.2 授权生成模块
#### 2.2.1 授权类型支持
- **在线授权码模式**:生成授权码,客户端激活时绑定硬件信息
- **离线许可证模式**:直接生成绑定硬件信息的软件许可证
#### 2.2.2 授权生成功能
- 支持为指定客户生成授权码或软件许可证
- 硬件指纹基于机器序列号、MAC地址、CPU ID生成
- 支持自定义有效期(1-36500天,预设选项:30天、90天、365天、永久)
- 支持功能列表配置和部署模式选择(在线/离线)
- 使用HMAC-SHA256签名和Base64编码保证安全性
- 可扩展额外功能,软件系统可以从生成的license里解析出来,比如{"device_number":20}
### 2.3 授权管理模块
- 显示所有授权记录列表,支持按客户筛选
- 授权状态监控(激活、过期、吊销),支持手动吊销和删除
- 提供授权详细信息查看(客户信息、硬件信息、过期时间、最后心跳时间)
- 实时显示授权过期和激活状态
### 2.4 部署包生成模块
- 为每个授权自动生成ZIP格式部署包
- 包含配置文件(config.yaml)、安装脚本(install.sh)、部署文档等
- 根据客户和授权信息自动命名
### 2.5 在线验证模块
- **授权验证API**:验证授权码和软件许可证,支持硬件指纹验证
- **授权激活API**:支持授权码激活为软件许可证,防止重复激活
- **心跳监控API**:记录客户端活跃时间,提供在线状态统计
- **授权信息查询API**:返回脱敏的授权信息,支持状态和过期检查
### 2.6 系统管理模块
- **用户认证**:管理员登录、状态保持、安全退出
- **权限控制**:Web界面需要认证,API接口独立于管理界面
- **系统监控**:运行状态仪表盘、授权统计信息、资源监控
### 2.7 工具支持模块
- 提供Windows和Linux平台的硬件信息获取工具
- 支持获取机器序列号、MAC地址、CPU ID
- 提供工具下载接口
## 3. 系统非功能性需求
### 3.1 性能需求
- 支持并发用户数不少于100个
- API响应时间不超过2秒
- 数据库连接池管理和查询性能优化
### 3.2 安全需求
- 授权码和软件许可证使用HMAC-SHA256签名
- 硬件指纹绑定防止盗用
- 敏感信息保护和HTTPS传输加密
### 3.3 可靠性需求
- 系统可用性达到99.9%
- 数据备份恢复、异常优雅降级
- 完善的错误处理和日志记录
### 3.4 可扩展性需求
- 支持水平扩展和模块化设计
- 支持多种数据库(当前使用PostgreSQL)
- RESTful API设计便于集成
### 3.5 兼容性需求
- 支持Windows、Linux、macOS操作系统
- 支持主流浏览器和跨平台客户端工具
## 4. 系统架构需求
### 4.1 技术架构
- **架构模式**:B/S架构
- **后端技术**:Go 1.23+,Gorilla Mux路由,Logrus日志
- **数据库**:PostgreSQL 12+,mysql12+
- **前端**:Vue.js 3+ 配合Vite,响应式UI组件
- **配置**:YAML格式配置文件
### 4.2 部署要求
- 支持单机部署、Docker容器化部署
- 支持系统服务形式运行
- 提供启动脚本和配置模板
## 5. 数据需求
### 5.1 数据模型
- **客户数据表**:基本信息、状态、创建时间,支持软删除和审计
- **授权数据表**:授权码/许可证信息、客户关联、硬件信息、状态时间、功能列表
### 5.2 数据管理
- 支持定期数据备份和导入导出功能
- 提供数据迁移工具
## 6. 接口需求
### 6.1 Web管理界面
- 客户管理、授权生成、授权列表和详情页面
- 系统仪表盘、用户登录页面
+35
View File
@@ -0,0 +1,35 @@
# Docker 镜像推送说明
项目已经补充了镜像推送脚本 `scripts/docker-push.sh`,默认面向本地 Gitea/私有仓库地址 `localhost:9988`。
## 1. 登录镜像仓库
```bash
docker login localhost:9988
```
如果你的镜像仓库命名空间不是 `leeexin`,推送前修改环境变量 `NAMESPACE`。
## 2. 构建并推送镜像
```bash
chmod +x scripts/docker-push.sh
REGISTRY=localhost:9988 NAMESPACE=leeexin VERSION=v1.1.1 ./scripts/docker-push.sh
```
推送完成后会得到两份镜像:
- `localhost:9988/leeexin/license-manager-backend:v1.1.1`
- `localhost:9988/leeexin/license-manager-frontend:v1.1.1`
## 3. 使用推送后的镜像部署
```bash
REGISTRY=localhost:9988 NAMESPACE=leeexin VERSION=v1.1.1 docker compose -f docker-compose.registry.yml up -d
```
## 4. 支付配置提醒
- 生产环境镜像启动时,支付配置以 `backend/configs/config.prod.yaml` 为准。
- 当前已经保留支付宝正式环境字段,请把 `app_id`、密钥、回调域名替换为你的正式配置。
- 免登录购买与续期入口为 `/public-renewal`,支付成功后可通过授权码查询和续期结果页面查看状态。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 364 KiB

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 474 KiB

After

Width:  |  Height:  |  Size: 283 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

After

Width:  |  Height:  |  Size: 1.7 MiB

+74
View File
@@ -0,0 +1,74 @@
## 一次性设置(首次推送)
### 第一步:克隆开源项目
```bash
# 克隆开源项目到本地
git clone https://github.com/cedar-v/license-manager.git
# 进入项目目录
cd license-manager
```
### 第二步:添加私有仓库作为新的远程仓库
```bash
# 查看当前远程仓库(应该只有 origin 指向开源项目)
git remote -v
# 添加你的私有仓库(命名为 enterprise)
git remote add enterprise https://github.com/cedar-v/license-manager-enterprise.git
# 验证(现在应该有两个远程仓库)
git remote -v
```
应该显示:
```
origin https://github.com/cedar-v/license-manager.git (fetch)
origin https://github.com/cedar-v/license-manager.git (push)
enterprise https://github.com/cedar-v/license-manager-enterprise.git (fetch)
enterprise https://github.com/cedar-v/license-manager-enterprise.git (push)
```
### 第三步:创建并切换到 license-manager 分支
```bash
# 创建 license-manager 分支
git checkout -b license-manager
# 推送到私有仓库
git push -u enterprise license-manager
```
---
## 后续更新流程
当开源项目有更新时,你可以这样同步:
```bash
# 1. 切换到 license-manager 分支
git checkout license-manager
# 2. 从开源项目拉取最新代码(origin 是开源项目)
git pull origin main
# 或者如果开源项目的默认分支是 master
# git pull origin master
# 3. 推送到你的私有仓库
git push enterprise license-manager
```
---
## 一键同步脚本(可选)
你可以创建一个脚本方便以后使用:
**sync.sh** (Mac/Linux) 或 **sync.bat** (Windows)
```bash
#!/bin/bash
git checkout license-manager
git pull origin main
git push enterprise license-manager
echo "同步完成!"
```
+48
View File
@@ -0,0 +1,48 @@
# 🔥License Manager v1.1.0 发布,三天完成产品货币化
**三天快速对接,完成产品货币化** 🚀
---
## 💡 产品定位
软件商业化是一场系统性工程。从产品定价到用户获取,从试用到转化,从续费到升级,每一个环节都至关重要。License Manager作为您的商业化基石,提供完整的货币化技术栈。
License Manager支持企业级管理后台和用户端自助服务,实现B2B+B2C双轨运营,企业客户和个人用户均可享受便捷的授权体验。
## ✨ 核心功能
### 👥 用户管理系统
- 手机号注册登录,完全独立于管理员体系
- 支持企业管理员和普通成员角色管理
- 安全的认证隔离设计
### 💰 产品套餐与支付
- 三种套餐类型:试用版、基础版、专业版
- 支付宝在线支付集成,支持沙箱和生产环境
- 阶梯定价折扣,按许可数量自动计算优惠
### 🔑 授权码系统
- 离线激活支持,无网络环境也可使用
- 授权码分享功能,支持部分激活次数转让
- RSA数字签名,确保授权码安全防篡改
### 📱 设备管理
- 设备绑定与解绑,实时在线状态监控
- 设备信息展示,支持批量设备管理
## 🎯 商业化能力
- **📊 定价策略数字化**:多维度定价,按用户数、功能模块、使用时长灵活定价
- **🔄 订阅经济支撑**:月付/年付/多年付,自动续费提醒
- **🏢 企业批量采购**:批量许可折扣,集中化企业管理
- **🎯 试用转化机制**:试用期管理,分阶段功能解锁引导升级
## 🐛 优化改进
- 授权码格式统一与生成优化
- 支付流程用户体验提升
- 缓存模块稳定性增强
---
**立即体验**:https://docs.lm.cedar-v.com/
+859
View File
@@ -0,0 +1,859 @@
# 测试用例
## 目录
1. [认证授权模块](#1-认证授权模块)
2. [客户管理模块](#2-客户管理模块)
3. [授权码管理模块](#3-授权码管理模块)
4. [许可证管理模块](#4-许可证管理模块)
5. [套餐管理模块](#5-套餐管理模块)
6. [订单管理模块](#6-订单管理模块)
7. [支付模块](#7-支付模块)
8. [发票管理模块](#8-发票管理模块)
9. [设备管理模块](#9-设备管理模块)
10. [线索管理模块](#10-线索管理模块)
11. [仪表盘/统计模块](#11-仪表盘统计模块)
---
## 1. 认证授权模块
### 1.1 管理员登录
| 用例编号 | TC-AUTH-001 |
|---------|-------------|
| 用例名称 | 管理员正常登录 |
| 测试目的 | 验证管理员使用正确用户名密码登录成功 |
| 优先级 | P0 |
| 前置条件 | 系统存在管理员账号,用户名: admin,密码: admin123 |
| 测试步骤 | 1. 打开登录页面<br>2. 输入用户名 admin<br>3. 输入密码 admin123<br>4. 点击登录按钮 |
| 预期结果 | 1. 登录成功,跳转至首页<br>2. 返回 token<br>3. 页面显示管理员用户名 |
| 用例编号 | TC-AUTH-002 |
|---------|-------------|
| 用例名称 | 管理员密码错误登录 |
| 测试目的 | 验证密码错误时登录失败 |
| 优先级 | P0 |
| 前置条件 | 系统存在管理员账号 |
| 测试步骤 | 1. 输入正确的用户名<br>2. 输入错误的密码<br>3. 点击登录按钮 |
| 预期结果 | 1. 登录失败<br>2. 显示错误提示"用户名或密码错误" |
| 用例编号 | TC-AUTH-003 |
|---------|-------------|
| 用例名称 | 管理员用户名不存在 |
| 测试目的 | 验证用户名不存在时登录失败 |
| 优先级 | P0 |
| 前置条件 | 无 |
| 测试步骤 | 1. 输入不存在的用户名<br>2. 输入任意密码<br>3. 点击登录按钮 |
| 预期结果 | 1. 登录失败<br>2. 显示错误提示"用户名或密码错误" |
| 用例编号 | TC-AUTH-004 |
|---------|-------------|
| 用例名称 | 管理员用户名密码为空 |
| 测试目的 | 验证输入为空时登录失败 |
| 优先级 | P1 |
| 前置条件 | 无 |
| 测试步骤 | 1. 不输入用户名<br>2. 不输入密码<br>3. 点击登录按钮 |
| 预期结果 | 1. 登录失败<br>2. 显示错误提示"用户名不能为空" |
### 1.2 Token刷新
| 用例编号 | TC-AUTH-005 |
|---------|-------------|
| 用例名称 | 正常刷新Token |
| 测试目的 | 验证Token过期前可以正常刷新 |
| 优先级 | P1 |
| 前置条件 | 用户已登录,获取有效Token |
| 测试步骤 | 1. 调用刷新Token接口 /api/v1/auth/refresh |
| 预期结果 | 1. 返回新的Token<br>2. 新Token有效时间重置 |
### 1.3 客户用户注册
| 用例编号 | TC-AUTH-006 |
|---------|-------------|
| 用例名称 | 客户用户正常注册 |
| 测试目的 | 验证新用户可以成功注册 |
| 优先级 | P0 |
| 前置条件 | 无 |
| 测试步骤 | 1. 打开注册页面<br>2. 输入手机号<br>3. 获取并输入短信验证码<br>4. 设置密码<br>5. 确认密码<br>6. 点击注册按钮 |
| 预期结果 | 1. 注册成功<br>2. 自动登录并跳转至首页<br>3. 返回用户信息 |
| 用例编号 | TC-AUTH-007 |
|---------|-------------|
| 用例名称 | 客户用户注册-手机号已存在 |
| 测试目的 | 验证已注册手机号无法重复注册 |
| 优先级 | P0 |
| 前置条件 | 手机号 13800138000 已注册 |
| 测试步骤 | 1. 输入已注册手机号 13800138000<br>2. 获取短信验证码<br>3. 填写其他必填信息<br>4. 点击注册按钮 |
| 预期结果 | 1. 注册失败<br>2. 显示错误提示"该手机号已注册" |
| 用例编号 | TC-AUTH-008 |
|---------|-------------|
| 用例名称 | 客户用户注册-无效验证码 |
| 测试目的 | 验证无效验证码无法完成注册 |
| 优先级 | P0 |
| 前置条件 | 无 |
| 测试步骤 | 1. 输入手机号<br>2. 输入错误的短信验证码<br>3. 填写密码<br>4. 点击注册按钮 |
| 预期结果 | 1. 注册失败<br>2. 显示错误提示"验证码错误" |
### 1.4 客户用户登录
| 用例编号 | TC-AUTH-009 |
|---------|-------------|
| 用例名称 | 客户用户密码登录 |
| 测试目的 | 验证客户用户使用密码登录 |
| 优先级 | P0 |
| 前置条件 | 已存在客户用户账号 |
| 测试步骤 | 1. 输入手机号<br>2. 输入密码<br>3. 点击登录按钮 |
| 预期结果 | 1. 登录成功<br>2. 返回Token |
| 用例编号 | TC-AUTH-010 |
|---------|-------------|
| 用例名称 | 客户用户短信登录 |
| 测庺目的 | 验证客户用户使用短信验证码登录 |
| 优先级 | P0 |
| 前置条件 | 已存在客户用户账号 |
| 测试步骤 | 1. 输入手机号<br>2. 点击发送验证码<br>3. 输入收到的短信验证码<br>4. 点击登录按钮 |
| 预期结果 | 1. 登录成功<br>2. 返回Token |
---
## 2. 客户管理模块
### 2.1 创建客户
| 用例编号 | TC-CUS-001 |
|---------|-------------|
| 用例名称 | 创建客户-必填字段完整 |
| 测试目的 | 验证必填字段填写完整时可成功创建客户 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员 |
| 测试步骤 | 1. 进入客户管理页面<br>2. 点击"新增客户"按钮<br>3. 填写客户名称:测试公司<br>4. 选择客户类型:企业<br>5. 填写联系人:张三<br>6. 选择客户等级:VIP<br>7. 选择状态:启用<br>8. 点击保存按钮 |
| 预期结果 | 1. 创建成功<br>2. 客户列表显示新客户<br>3. 自动生成客户编码 |
| 用例编号 | TC-CUS-002 |
|---------|-------------|
| 用例名称 | 创建客户-缺少必填字段 |
| 测试目的 | 验证缺少必填字段时创建失败 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员 |
| 测试步骤 | 1. 进入客户管理页面<br>2. 点击"新增客户"按钮<br>3. 填写部分字段<br>4. 点击保存按钮 |
| 预期结果 | 1. 创建失败<br>2. 显示错误提示,提示必填字段 |
| 用例编号 | TC-CUS-003 |
|---------|-------------|
| 用例名称 | 创建客户-邮箱格式错误 |
| 测试目的 | 验证邮箱格式错误时创建失败 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员 |
| 测试步骤 | 1. 填写客户信息<br>2. 输入格式错误的邮箱 abc@<br>3. 点击保存按钮 |
| 预期结果 | 1. 创建失败<br>2. 显示错误提示"邮箱格式不正确" |
### 2.2 查询客户
| 用例编号 | TC-CUS-004 |
|---------|-------------|
| 用例名称 | 查询客户-列表展示 |
| 测试目的 | 验证客户列表正确展示 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员,系统存在多个客户 |
| 测试步骤 | 1. 进入客户管理页面 |
| 预期结果 | 1. 显示客户列表<br>2. 每页显示默认20条<br>3. 显示客户名称、编码、联系人、状态等信息 |
| 用例编号 | TC-CUS-005 |
|---------|-------------|
| 用例名称 | 查询客户-搜索功能 |
| 测试目的 | 验证搜索功能可正确过滤客户 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员,系统存在客户"测试公司" |
| 测试步骤 | 1. 在搜索框输入"测试公司"<br>2. 点击搜索按钮 |
| 预期结果 | 1. 列表只显示匹配的客户的 |
| 用例编号 | TC-CUS-006 |
|---------|-------------|
| 用例名称 | 查询客户-分页功能 |
| 测试目的 | 验证分页功能正常 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员,客户数量超过20 |
| 测试步骤 | 1. 进入客户列表<br>2. 点击第2页 |
| 预期结果 | 1. 显示第2页数据<br>2. 分页信息正确 |
### 2.3 更新客户
| 用例编号 | TC-CUS-007 |
|---------|-------------|
| 用例名称 | 更新客户信息 |
| 测试目的 | 验证客户信息可以正常更新 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员,存在客户"测试公司" |
| 测试步骤 | 1. 在客户列表找到"测试公司"<br>2. 点击编辑按钮<br>3. 修改客户名称为"测试公司2"<br>4. 点击保存按钮 |
| 预期结果 | 1. 更新成功<br>2. 列表显示新名称 |
| 用例编号 | TC-CUS-008 |
|---------|-------------|
| 用例名称 | 更新客户状态 |
| 测试目的 | 验证客户状态可以正常切换 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员,存在客户 |
| 测试步骤 | 1. 找到目标客户<br>2. 点击启用/禁用按钮或编辑页面修改状态 |
| 预期结果 | 1. 状态更新成功<br>2. 列表显示新状态 |
### 2.4 删除客户
| 用例编号 | TC-CUS-009 |
|---------|-------------|
| 用例名称 | 删除客户 |
| 测试目的 | 验证客户可以被删除 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员,存在客户 |
| 测试步骤 | 1. 找到目标客户<br>2. 点击删除按钮<br>3. 在确认弹窗中点击确认 |
| 预期结果 | 1. 删除成功<br>2. 列表不再显示该客户 |
---
## 3. 授权码管理模块
### 3.1 创建授权码
| 用例编号 | TC-AUTHCODE-001 |
|---------|-------------|
| 用例名称 | 创建授权码-必填字段完整 |
| 测试目的 | 验证必填字段填写完整时可成功创建授权码 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员,系统存在客户 |
| 测试步骤 | 1. 进入授权码管理页面<br>2. 点击"新建授权码"按钮<br>3. 选择客户<br>4. 填写有效天数:365<br>5. 选择部署类型:独立部署<br>6. 填写最大激活次数:5<br>7. 点击保存按钮 |
| 预期结果 | 1. 创建成功<br>2. 生成授权码<br>3. 显示授权码详情 |
| 用例编号 | TC-AUTHCODE-002 |
|---------|-------------|
| 用例名称 | 创建授权码-缺少必填字段 |
| 测试目的 | 验证缺少必填字段时创建失败 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员 |
| 测试步骤 | 1. 进入授权码管理页面<br>2. 点击"新建授权码"按钮<br>3. 部分填写字段<br>4. 点击保存按钮 |
| 预期结果 | 1. 创建失败<br>2. 显示错误提示 |
| 用例编号 | TC-AUTHCODE-003 |
|---------|-------------|
| 用例名称 | 创建授权码-永久有效 |
| 测试目的 | 验证可以创建永久有效的授权码 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员 |
| 测试步骤 | 1. 填写有效天数为365000(永久)<br>2. 其他必填字段正常填写<br>3. 点击保存 |
| 预期结果 | 1. 创建成功<br>2. 显示"永久有效"而非具体过期日期 |
### 3.2 查询授权码
| 用例编号 | TC-AUTHCODE-004 |
|---------|-------------|
| 用例名称 | 查询授权码列表 |
| 测试目的 | 验证授权码列表正确展示 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员,系统存在多个授权码 |
| 测试步骤 | 1. 进入授权码管理页面 |
| 预期结果 | 1. 显示授权码列表<br>2. 显示授权码、客户名称、状态、生效日期、过期日期、激活次数 |
| 用例编号 | TC-AUTHCODE-005 |
|---------|-------------|
| 用例名称 | 查询授权码-状态筛选 |
| 测试目的 | 验证按状态筛选功能正常 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员 |
| 测试步骤 | 1. 选择状态筛选:已锁定<br>2. 点击筛选 |
| 预期结果 | 1. 只显示锁定的授权码 |
| 用例编号 | TC-AUTHCODE-006 |
|---------|-------------|
| 用例名称 | 查询授权码详情 |
| 测试目的 | 验证授权码详情页展示完整信息 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员,存在授权码 |
| 测试步骤 | 1. 点击任意授权码条目 |
| 预期结果 | 1. 显示授权码详情<br>2. 显示关联客户信息、授权码信息、功能配置等 |
### 3.3 更新授权码
| 用例编号 | TC-AUTHCODE-007 |
|---------|-------------|
| 用例名称 | 更新授权码-延长时间 |
| 测试目的 | 验证可以延长授权码有效期 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员,存在授权码 |
| 测试步骤 | 1. 找到目标授权码<br>2. 点击编辑按钮<br>3. 修改有效天数为730<br>4. 选择变更类型:续期<br>5. 填写原因<br>6. 点击保存 |
| 预期结果 | 1. 更新成功<br>2. 过期日期延后<br>3. 生成变更历史记录 |
| 用例编号 | TC-AUTHCODE-008 |
|---------|-------------|
| 用例名称 | 锁定授权码 |
| 测试目的 | 验证可以锁定授权码 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员,存在授权码 |
| 测试步骤 | 1. 找到目标授权码<br>2. 点击锁定按钮<br>3. 填写锁定原因<br>4. 点击确认 |
| 预期结果 | 1. 授权码被锁定<br>2. 状态显示为锁定<br>3. 无法进行激活 |
| 用例编号 | TC-AUTHCODE-009 |
|---------|-------------|
| 用例名称 | 解锁授权码 |
| 测试目的 | 验证可以解锁授权码 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员,存在锁定的授权码 |
| 测试步骤 | 1. 找到锁定的授权码<br>2. 点击解锁按钮<br>3. 确认解锁 |
| 预期结果 | 1. 授权码被解锁<br>2. 状态恢复正常 |
### 3.4 删除授权码
| 用例编号 | TC-AUTHCODE-010 |
|---------|-------------|
| 用例名称 | 删除授权码 |
| 测试目的 | 验证授权码可以被删除 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员,存在未使用的授权码 |
| 测试步骤 | 1. 找到目标授权码<br>2. 点击删除按钮<br>3. 确认删除 |
| 预期结果 | 1. 删除成功<br>2. 列表不再显示该授权码 |
### 3.5 授权码变更历史
| 用例编号 | TC-AUTHCODE-011 |
|---------|-------------|
| 用例名称 | 查看授权码变更历史 |
| 测试目的 | 验证变更历史记录完整 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员,授权码有变更记录 |
| 测试步骤 | 1. 进入授权码详情<br>2. 点击"变更历史"标签页 |
| 预期结果 | 1. 显示所有变更记录<br>2. 包含变更类型、操作人、时间、原因 |
---
## 4. 许可证管理模块
### 4.1 激活许可证
| 用例编号 | TC-LICENSE-001 |
|---------|-------------|
| 用例名称 | 客户端激活许可证-成功 |
| 测试目的 | 验证客户端使用授权码可以成功激活 |
| 优先级 | P0 |
| 前置条件 | 存在有效的授权码,客户端未激活过 |
| 测试步骤 | 1. 打开客户端软件<br>2. 输入授权码<br>3. 输入硬件指纹<br>4. 点击激活按钮 |
| 预期结果 | 1. 激活成功<br>2. 返回许可证密钥<br>3. 生成许可证文件 |
| 用例编号 | TC-LICENSE-002 |
|---------|-------------|
| 用例名称 | 客户端激活许可证-授权码无效 |
| 测试目的 | 验证无效授权码无法激活 |
| 优先级 | P0 |
| 前置条件 | 无 |
| 测试步骤 | 1. 打开客户端软件<br>2. 输入无效授权码<br>3. 输入硬件指纹<br>4. 点击激活按钮 |
| 预期结果 | 1. 激活失败<br>2. 显示错误提示"授权码无效" |
| 用例编号 | TC-LICENSE-003 |
|---------|-------------|
| 用例名称 | 客户端激活许可证-授权码已过期 |
| 测试目的 | 验证过期授权码无法激活 |
| 优先级 | P0 |
| 前置条件 | 存在已过期的授权码 |
| 测试步骤 | 1. 打开客户端软件<br>2. 输入过期授权码<br>3. 输入硬件指纹<br>4. 点击激活按钮 |
| 预期结果 | 1. 激活失败<br>2. 显示错误提示"授权码已过期" |
| 用例编号 | TC-LICENSE-004 |
|---------|-------------|
| 用例名称 | 客户端激活许可证-授权码已锁定 |
| 测试目的 | 验证锁定授权码无法激活 |
| 优先级 | P0 |
| 前置条件 | 存在已锁定的授权码 |
| 测试步骤 | 1. 打开客户端软件<br>2. 输入锁定授权码<br>3. 输入硬件指纹<br>4. 点击激活按钮 |
| 预期结果 | 1. 激活失败<br>2. 显示错误提示"授权码已锁定" |
| 用例编号 | TC-LICENSE-005 |
|---------|-------------|
| 用例名称 | 客户端激活许可证-激活次数超限 |
| 测试目的 | 验证超过最大激活次数无法激活 |
| 优先级 | P0 |
| 前置条件 | 授权码已达到最大激活次数 |
| 测试步骤 | 1. 打开客户端软件<br>2. 输入授权码<br>3. 输入硬件指纹<br>4. 点击激活按钮 |
| 预期结果 | 1. 激活失败<br>2. 显示错误提示"激活次数已达上限" |
### 4.2 心跳检测
| 用例编号 | TC-LICENSE-006 |
|---------|-------------|
| 用例名称 | 客户端心跳检测-在线 |
| 测试目的 | 验证客户端心跳正常,状态在线 |
| 优先级 | P0 |
| 前置条件 | 许可证已激活,客户端在线 |
| 测试步骤 | 1. 客户端发送心跳请求<br>2. 携带许可证密钥和硬件指纹 |
| 预期结果 | 1. 返回成功<br>2. 许可证状态为在线<br>3. 更新最后心跳时间 |
| 用例编号 | TC-LICENSE-007 |
|---------|-------------|
| 用例名称 | 客户端心跳检测-许可证被撤销 |
| 测试目的 | 验证许可证被撤销后心跳失败 |
| 优先级 | P0 |
| 前置条件 | 许可证已被管理员撤销 |
| 测试步骤 | 1. 客户端发送心跳请求 |
| 预期结果 | 1. 返回失败<br>2. 错误提示"许可证已撤销" |
| 用例编号 | TC-LICENSE-008 |
|---------|-------------|
| 用例名称 | 客户端心跳检测-许可证过期 |
| 测试目的 | 验证许可证过期后心跳失败 |
| 优先级 | P0 |
| 前置条件 | 授权码已过期 |
| 测试步骤 | 1. 客户端发送心跳请求 |
| 预期结果 | 1. 返回失败<br>2. 错误提示"授权码已过期" |
### 4.3 管理员手动创建许可证
| 用例编号 | TC-LICENSE-009 |
|---------|-------------|
| 用例名称 | 管理员手动创建许可证 |
| 测试目的 | 验证管理员可以手动创建许可证 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员,存在授权码和客户 |
| 测试步骤 | 1. 进入许可证管理页面<br>2. 点击"新建许可证"<br>3. 选择授权码<br>4. 填写硬件指纹<br>5. 点击保存 |
| 预期结果 | 1. 创建成功<br>2. 生成许可证密钥 |
### 4.4 撤销许可证
| 用例编号 | TC-LICENSE-010 |
|---------|-------------|
| 用例名称 | 管理员撤销许可证 |
| 测试目的 | 验证管理员可以撤销许可证 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员,存在激活的许可证 |
| 测试步骤 | 1. 进入许可证管理页面<br>2. 找到目标许可证<br>3. 点击撤销按钮<br>4. 填写撤销原因<br>5. 确认撤销 |
| 预期结果 | 1. 撤销成功<br>2. 许可证状态变为已撤销<br>3. 客户端无法使用 |
### 4.5 许可证列表查询
| 用例编号 | TC-LICENSE-011 |
|---------|-------------|
| 用例名称 | 查询许可证列表 |
| 测试目的 | 验证许可证列表正确展示 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员,系统存在多个许可证 |
| 测试步骤 | 1. 进入许可证管理页面 |
| 预期结果 | 1. 显示许可证列表<br>2. 显示许可证密钥、客户名称、状态、激活时间、在线状态等 |
| 用例编号 | TC-LICENSE-012 |
|---------|-------------|
| 用例名称 | 查询许可证-状态筛选 |
| 测试目的 | 验证按状态筛选功能正常 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员 |
| 测试步骤 | 1. 选择状态筛选:已激活<br>2. 点击筛选 |
| 预期结果 | 1. 只显示激活的许可证 |
---
## 5. 套餐管理模块
### 5.1 创建套餐
| 用例编号 | TC-PACKAGE-001 |
|---------|-------------|
| 用例名称 | 创建套餐 |
| 测试目的 | 验证可以创建新套餐 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员 |
| 测试步骤 | 1. 进入套餐管理页面<br>2. 点击"新建套餐"<br>3. 填写套餐名称:基础版<br>4. 填写价格:99<br>5. 填写有效天数:365<br>6. 填写最大激活次数:10<br>7. 填写描述<br>8. 点击保存 |
| 预期结果 | 1. 创建成功<br>2. 列表显示新套餐 |
### 5.2 查询套餐
| 用例编号 | TC-PACKAGE-002 |
|---------|-------------|
| 用例名称 | 客户查看可用套餐 |
| 测试目的 | 验证客户可查看可选套餐 |
| 优先级 | P0 |
| 前置条件 | 系统存在启用的套餐,客户已登录 |
| 测试步骤 | 1. 客户登录后进入套餐选择页面 |
| 预期结果 | 1. 显示所有启用的套餐<br>2. 显示套餐名称、价格、权益等 |
### 5.3 更新套餐
| 用例编号 | TC-PACKAGE-003 |
|---------|-------------|
| 用例名称 | 更新套餐信息 |
| 测试目的 | 验证套餐信息可以更新 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员,存在套餐 |
| 测试步骤 | 1. 找到目标套餐<br>2. 点击编辑<br>3. 修改价格或描述<br>4. 保存 |
| 预期结果 | 1. 更新成功<br>2. 列表显示新信息 |
### 5.4 套餐上下架
| 用例编号 | TC-PACKAGE-004 |
|---------|-------------|
| 用例名称 | 套餐下架 |
| 测试目的 | 验证可以下架套餐 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员,存在启用的套餐 |
| 测试步骤 | 1. 找到目标套餐<br>2. 点击下架按钮 |
| 预期结果 | 1. 下架成功<br>2. 套餐状态变为禁用<br>3. 客户不可见 |
---
## 6. 订单管理模块
### 6.1 创建订单
| 用例编号 | TC-ORDER-001 |
|---------|-------------|
| 用例名称 | 客户创建订单-选择套餐 |
| 测试目的 | 验证客户可以选择套餐创建订单 |
| 优先级 | P0 |
| 前置条件 | 客户已登录,系统存在启用的套餐 |
| 测试步骤 | 1. 进入套餐页面<br>2. 选择目标套餐<br>3. 点击立即购买 |
| 预期结果 | 1. 进入确认订单页面<br>2. 显示套餐信息、价格 |
| 用例编号 | TC-ORDER-002 |
|---------|-------------|
| 用例名称 | 客户创建订单-自定义授权码 |
| 测试目的 | 验证客户可以输入自定义授权码 |
| 优先级 | P1 |
| 前置条件 | 客户已登录 |
| 测试步骤 | 1. 选择"使用已有授权码"<br>2. 输入授权码<br>3. 点击下一步 |
| 预期结果 | 1. 验证授权码有效<br>2. 进入确认订单页面 |
| 用例编号 | TC-ORDER-003 |
|---------|-------------|
| 用例名称 | 订单价格计算 |
| 测试目的 | 验证订单价格计算正确 |
| 优先级 | P0 |
| 前置条件 | 客户已登录 |
| 测试步骤 | 1. 选择套餐<br>2. 查看价格明细 |
| 预期结果 | 1. 显示单价、总价、优惠等 |
### 6.2 订单支付
| 用例编号 | TC-ORDER-004 |
|---------|-------------|
| 用例名称 | 订单支付-支付宝 |
| 测试目的 | 验证可以使用支付宝支付订单 |
| 优先级 | P0 |
| 前置条件 | 客户已登录,存在待支付订单 |
| 测试步骤 | 1. 进入订单详情<br>2. 选择支付宝支付<br>3. 点击去支付<br>4. 完成支付宝支付流程 |
| 预期结果 | 1. 跳转至支付宝页面<br>2. 支付成功后订单状态变为已支付 |
| 用例编号 | TC-ORDER-005 |
|---------|-------------|
| 用例名称 | 订单支付-支付超时 |
| 测试目的 | 验证订单支付超时后状态正确 |
| 优先级 | P1 |
| 前置条件 | 客户发起支付但未完成 |
| 测试步骤 | 1. 发起支付<br>2. 等待超过支付超时时间(15分钟)<br>3. 查看订单状态 |
| 预期结果 | 1. 订单状态变为已取消<br>2. 客户可重新发起支付 |
### 6.3 取消订单
| 用例编号 | TC-ORDER-006 |
|---------|-------------|
| 用例名称 | 取消待支付订单 |
| 测试目的 | 验证客户可以取消未支付的订单 |
| 优先级 | P1 |
| 前置条件 | 客户存在待支付订单 |
| 测试步骤 | 1. 进入我的订单<br>2. 找到待支付订单<br>3. 点击取消订单<br>4. 确认取消 |
| 预期结果 | 1. 订单取消成功<br>2. 订单状态变为已取消 |
### 6.4 订单列表查询
| 用例编号 | TC-ORDER-007 |
|---------|-------------|
| 用例名称 | 查询客户订单列表 |
| 测试目的 | 验证客户可查看自己的订单 |
| 优先级 | P0 |
| 前置条件 | 客户已登录,存在订单 |
| 测试步骤 | 1. 进入我的订单页面 |
| 预期结果 | 1. 显示订单列表<br>2. 显示订单号、套餐名称、金额、状态、时间等 |
| 用例编号 | TC-ORDER-008 |
|---------|-------------|
| 用例名称 | 订单状态筛选 |
| 测试目的 | 验证按状态筛选订单功能正常 |
| 优先级 | P1 |
| 前置条件 | 客户已登录,存在多种状态订单 |
| 测试步骤 | 1. 选择状态筛选:已完成<br>2. 点击筛选 |
| 预期结果 | 1. 只显示已完成的订单 |
---
## 7. 支付模块
### 7.1 支付回调
| 用例编号 | TC-PAY-001 |
|---------|-------------|
| 用例名称 | 支付宝支付回调-成功 |
| 测试目的 | 验证支付成功后回调处理正确 |
| 优先级 | P0 |
| 前置条件 | 客户发起支付宝支付 |
| 测试步骤 | 1. 客户完成支付宝支付<br>2. 支付宝发送异步回调 |
| 预期结果 | 1. 回调处理成功<br>2. 订单状态更新为已支付<br>3. 授权码生成/激活成功 |
| 用例编号 | TC-PAY-002 |
|---------|-------------|
| 用例名称 | 支付宝支付回调-签名验证 |
| 测试目的 | 验证回调签名验证正确,防止伪造回调 |
| 优先级 | P0 |
| 前置条件 | 无 |
| 测试步骤 | 1. 发送伪造的支付回调请求 |
| 预期结果 | 1. 签名验证失败<br>2. 不处理回调 |
### 7.2 支付状态查询
| 用例编号 | TC-PAY-003 |
|---------|-------------|
| 用例名称 | 查询支付状态 |
| 测试目的 | 验证可以查询支付结果 |
| 优先级 | P0 |
| 前置条件 | 存在支付记录 |
| 测试步骤 | 1. 进入支付记录<br>2. 查看支付详情 |
| 预期结果 | 1. 显示支付状态:待支付/支付中/已支付/已取消 |
---
## 8. 发票管理模块
### 8.1 客户申请发票
| 用例编号 | TC-INVOICE-001 |
|---------|-------------|
| 用例名称 | 客户申请发票 |
| 测试目的 | 验证客户可以申请开具发票 |
| 优先级 | P0 |
| 前置条件 | 客户存在已完成的订单 |
| 测试步骤 | 1. 进入发票申请页面<br>2. 选择订单<br>3. 填写发票抬头<br>4. 选择发票类型:普通/增值税专用<br>5. 填写税号等信息<br>6. 提交申请 |
| 预期结果 | 1. 申请成功<br>2. 发票状态变为申请中 |
| 用例编号 | TC-INVOICE-002 |
|---------|-------------|
| 用例名称 | 客户查看发票列表 |
| 测试目的 | 验证客户可查看自己的发票 |
| 优先级 | P0 |
| 前置条件 | 客户已登录 |
| 测试步骤 | 1. 进入我的发票页面 |
| 预期结果 | 1. 显示发票列表<br>2. 显示发票状态、抬头、金额等 |
### 8.2 管理员处理发票
| 用例编号 | TC-INVOICE-003 |
|---------|-------------|
| 用例名称 | 管理员开具发票 |
| 测试目的 | 验证管理员可以开具发票 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员,存在申请中的发票 |
| 测试步骤 | 1. 进入发票管理页面<br>2. 找到待处理发票<br>3. 上传发票文件<br>4. 点击开具 |
| 预期结果 | 1. 发票状态变为已开具<br>2. 生成发票文件 |
| 用例编号 | TC-INVOICE-004 |
|---------|-------------|
| 用例名称 | 管理员拒绝发票 |
| 测试目的 | 验证管理员可以拒绝发票申请 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员,存在申请中的发票 |
| 测试步骤 | 1. 找到目标发票<br>2. 点击拒绝<br>3. 填写拒绝原因<br>4. 确认 |
| 预期结果 | 1. 发票状态变为已拒绝<br>2. 显示拒绝原因 |
### 8.3 发票下载
| 用例编号 | TC-INVOICE-005 |
|---------|-------------|
| 用例名称 | 客户下载发票 |
| 测试目的 | 验证客户可以下载已开具的发票 |
| 优先级 | P0 |
| 前置条件 | 发票已开具 |
| 测试步骤 | 1. 进入我的发票<br>2. 找到已开具的发票<br>3. 点击下载 |
| 预期结果 | 1. 发票PDF文件开始下载 |
---
## 9. 设备管理模块
### 9.1 设备列表
| 用例编号 | TC-DEVICE-001 |
|---------|-------------|
| 用例名称 | 客户查看设备列表 |
| 测试目的 | 验证客户可以查看自己的设备列表 |
| 优先级 | P0 |
| 前置条件 | 客户存在激活的许可证设备 |
| 测试步骤 | 1. 客户登录后进入设备管理页面 |
| 预期结果 | 1. 显示设备列表<br>2. 显示设备名称、状态、最后在线时间等 |
| 用例编号 | TC-DEVICE-002 |
|---------|-------------|
| 用例名称 | 设备在线状态显示 |
| 测试目的 | 验证设备在线状态正确显示 |
| 优先级 | P0 |
| 前置条件 | 存在在线和离线的设备 |
| 测试步骤 | 1. 查看设备列表 |
| 预期结果 | 1. 在线设备显示在线状态<br>2. 离线设备显示离线状态 |
### 9.2 设备解绑
| 用例编号 | TC-DEVICE-003 |
|---------|-------------|
| 用例名称 | 客户解绑设备 |
| 测试目的 | 验证客户可以解绑自己的设备 |
| 优先级 | P1 |
| 前置条件 | 客户存在激活的设备 |
| 测试步骤 | 1. 找到目标设备<br>2. 点击解绑按钮<br>3. 确认解绑 |
| 预期结果 | 1. 解绑成功<br>2. 设备从列表移除<br>3. 许可证释放 |
---
## 10. 线索管理模块
### 10.1 提交线索
| 用例编号 | TC-LEAD-001 |
|---------|-------------|
| 用例名称 | 访客提交线索-完整信息 |
| 测试目的 | 验证访客可以成功提交线索 |
| 优先级 | P0 |
| 前置条件 | 无 |
| 测试步骤 | 1. 打开线索提交页面<br>2. 填写公司名称:测试公司<br>3. 填写联系人:张三<br>4. 填写联系电话:13800138000<br>5. 填写需求描述<br>6. 点击提交 |
| 预期结果 | 1. 提交成功<br>2. 返回线索编号<br>3. 显示提交成功提示 |
| 用例编号 | TC-LEAD-002 |
|---------|-------------|
| 用例名称 | 访客提交线索-缺少必填字段 |
| 测试目的 | 验证缺少必填字段时提交失败 |
| 优先级 | P0 |
| 前置条件 | 无 |
| 测试步骤 | 1. 部分填写线索信息<br>2. 缺少公司名称<br>3. 点击提交 |
| 预期结果 | 1. 提交失败<br>2. 提示必填字段不能为空 |
| 用例编号 | TC-LEAD-003 |
|---------|-------------|
| 用例名称 | 访客提交线索-需求为空 |
| 测试目的 | 验证需求描述可为空(修改后) |
| 优先级 | P0 |
| 前置条件 | 无 |
| 测试步骤 | 1. 填写必填字段(公司名、联系人、电话)<br>2. 需求描述留空<br>3. 点击提交 |
| 预期结果 | 1. 提交成功<br>2. 需求描述自动填充为"Empty" |
| 用例编号 | TC-LEAD-004 |
|---------|-------------|
| 用例名称 | 访客提交线索-邮箱格式错误 |
| 测试目的 | 验证邮箱格式错误时有提示 |
| 优先级 | P1 |
| 前置条件 | 无 |
| 测试步骤 | 1. 填写必填字段<br>2. 填写格式错误的邮箱 abc@<br>3. 点击提交 |
| 预期结果 | 1. 提交失败<br>2. 提示邮箱格式不正确 |
### 10.2 管理员处理线索
| 用例编号 | TC-LEAD-005 |
|---------|-------------|
| 用例名称 | 管理员查看线索列表 |
| 测试目的 | 验证管理员可以查看所有线索 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员,系统存在线索 |
| 测试步骤 | 1. 进入线索管理页面 |
| 预期结果 | 1. 显示线索列表<br>2. 显示公司名称、联系人、电话、状态、提交时间等 |
| 用例编号 | TC-LEAD-006 |
|---------|-------------|
| 用例名称 | 管理员更新线索状态 |
| 测试目的 | 验证管理员可以更新线索状态 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员,存在线索 |
| 测试步骤 | 1. 找到目标线索<br>2. 修改状态为已联系<br>3. 填写跟进记录<br>4. 保存 |
| 预期结果 | 1. 更新成功<br>2. 线索状态变更 |
| 用例编号 | TC-LEAD-007 |
|---------|-------------|
| 用例名称 | 管理员删除线索 |
| 测试目的 | 验证管理员可以删除线索 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员,存在线索 |
| 测试步骤 | 1. 找到目标线索<br>2. 点击删除<br>3. 确认删除 |
| 预期结果 | 1. 删除成功<br>2. 线索从列表移除 |
### 10.3 线索统计
| 用例编号 | TC-LEAD-008 |
|---------|-------------|
| 用例名称 | 查看线索汇总统计 |
| 测试目的 | 验证线索统计功能正常 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员 |
| 测试步骤 | 1. 进入线索汇总页面 |
| 预期结果 | 1. 显示线索总数<br>2. 显示各状态数量:待联系、已联系、已成交、已失效 |
---
## 11. 仪表盘/统计模块
### 11.1 授权概览统计
| 用例编号 | TC-STATS-001 |
|---------|-------------|
| 用例名称 | 查看授权概览统计 |
| 测试目的 | 验证概览统计数据正确 |
| 优先级 | P0 |
| 前置条件 | 已登录为管理员 |
| 测试步骤 | 1. 进入仪表盘首页 |
| 预期结果 | 1. 显示总授权码数量<br>2. 显示活跃许可证数量<br>3. 显示即将过期数量<br>4. 显示异常告警数量<br>5. 显示增长率 |
### 11.2 授权趋势图
| 用例编号 | TC-STATS-002 |
|---------|-------------|
| 用例名称 | 查看授权趋势图 |
| 测试目的 | 验证授权趋势图表展示正确 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员 |
| 测试步骤 | 1. 进入授权趋势页面 |
| 预期结果 | 1. 显示折线图/柱状图<br>2. 显示时间范围内授权码创建数量 |
| 用例编号 | TC-STATS-003 |
|---------|-------------|
| 用例名称 | 筛选授权趋势时间范围 |
| 测试目的 | 验证时间范围筛选功能正常 |
| 优先级 | P1 |
| 前置条件 | 已登录为管理员 |
| 测试步骤 | 1. 选择时间范围:最近7天<br>2. 查看趋势图 |
| 预期结果 | 1. 显示最近7天的数据 |
---
## 附录:测试优先级说明
| 优先级 | 说明 |
|--------|------|
| P0 | 核心功能,必须测试,阻塞发布 |
| P1 | 重要功能,建议测试,影响体验 |
| P2 | 辅助功能,可以测试 |
---
## 附录:测试环境说明
| 环境 | 说明 |
|------|------|
| 开发环境 | 本地开发调试使用 |
| 测试环境 | 功能测试、回归测试使用 |
| 预发布环境 | 验收测试使用 |
| 生产环境 | 正式环境 |
---
## 附录:测试数据准备
1. **管理员账号**
- 用户名: admin
- 密码: admin123
2. **客户用户账号**
- 手机号: 17398467065
- 密码: 12345678
3. **授权码**
- 状态: 正常/锁定/过期
- 激活次数: 未使用/已用完
4. **套餐**
- 状态: 启用/禁用
- 价格: 99/299/999
@@ -0,0 +1,163 @@
# 技术契约与架构设计书:Dashboard 统计卡片重设计
**版本**:v1.0
**日期**:2026-03-13
**作者**:system-architect
**状态**:待评审
---
## 一、现状诊断
### 当前 API 响应结构
```json
{
"total_auth_codes": 12,
"active_licenses": 2,
"expiring_soon": 0,
"abnormal_alerts": 2,
"growth_rate": {
"auth_codes": 100.00,
"licenses": 0.00
}
}
```
### 现存问题清单
| # | 问题 | 严重性 |
|---|------|--------|
| 1 | `growth_rate` 语义不清——是"同比"上月还是"环比"?无时间标注 | 高 |
| 2 | 两个增长率字段(auth_codes / licenses)对用户的业务价值极低,不知道要对比什么 | 高 |
| 3 | 前端 `statsData` 中的 `key` 映射与 i18n label 不一致(注释与实际label错位) | 中 |
| 4 | `expiring_soon` 统计的是"30天内到期的授权码",但卡片标签写的是"即将过期",口径模糊 | 中 |
| 5 | 没有"今日新增"这类高频关注的运营指标 | 中 |
| 6 | 增长率算法缺陷:上月总数为0时直接返回0而非100% | 低 |
---
## 二、竞品分析(License Management SaaS 参考)
### Keygen.sh / LicenseSpring / Cryptlex 共性 Dashboard 指标
```
┌─────────────────────────────────────────────────────────┐
│ 高频关注(运营视角) │
│ • 今日新增授权码 / 本月新增授权码(绝对数 + 环比昨日/上月)│
│ • 活跃设备数(在线 License) │
│ • 即将到期(7天 / 30天)→ 需要跟进续费 │
│ • 异常告警(心跳超时 / 被撤销) │
└─────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ 中频关注(管理视角) │
│ • 授权码总量(存量指标) │
│ • 活跃许可证总量(存量指标) │
│ • 月度增长率(MoM Growth Rate,附说明文字"vs 上月") │
└─────────────────────────────────────────────────────────┘
```
### 关键竞品设计原则
1. **增长率必须标注对比周期**:统一用「vs. 上月同期」,数字旁展示箭头 ↑ ↓ 和颜色区分正负
2. **增长率不放卡片主数值**:作为副文本展示(小字 + 颜色),主数值展示绝对数
3. **即将到期细分**:7天 为紧急(红),30天 为预警(橙)
4. **今日新增**:运营最关注的高频指标,竞品均在首位展示
---
## 三、重设计方案
### 3.1 新指标体系(6张卡片)
| 位置 | 指标名 | 计算口径 | 副文本 |
|------|--------|---------|--------|
| 卡片1 | 授权码总量 | `COUNT(authorization_codes)` | ↑ 本月新增 N 个 |
| 卡片2 | 活跃许可证 | `COUNT(licenses WHERE status='active')` | ↑ 较上月 +X% |
| 卡片3 | 今日新增授权 | `COUNT(licenses WHERE DATE(created_at)=TODAY)` | 昨日 N 个 |
| 卡片4 | 7天内到期 | `COUNT(auth_codes WHERE end_date BETWEEN now AND now+7d AND NOT locked)` | 紧急跟进 |
| 卡片5 | 30天内到期 | `COUNT(auth_codes WHERE end_date BETWEEN now AND now+30d AND NOT locked)` | 含7天内 |
| 卡片6 | 异常告警 | `COUNT(licenses WHERE status='active' AND heartbeat_timeout)` | 心跳超时 |
> 删除:`auth_codes 增长率`、`licenses 增长率` 作为独立卡片(改为副文本方式展示)
### 3.2 增长率展示方式(副文本,不占卡片)
```
┌──────────────────────────┐
│ 🔑 授权码总量 │
│ 12 │
│ ↑ 本月新增 2 个 │ ← 副文本:绝对增量
└──────────────────────────┘
┌──────────────────────────┐
│ ✅ 活跃许可证 │
│ 98 │
│ ↑ +12.5% vs. 上月 │ ← 副文本:百分比增长率 + 标注
└──────────────────────────┘
```
### 3.3 新 API Response Schema
```go
// StatsOverviewResponse 重设计
type StatsOverviewResponse struct {
// 存量指标
TotalAuthCodes int64 `json:"total_auth_codes"` // 授权码总量
ActiveLicenses int64 `json:"active_licenses"` // 活跃许可证
// 流量指标
TodayNewLicenses int64 `json:"today_new_licenses"` // 今日新增许可证
YesterdayNewLicenses int64 `json:"yesterday_new_licenses"` // 昨日新增(用于对比)
MonthNewAuthCodes int64 `json:"month_new_auth_codes"` // 本月新增授权码
// 风险指标
ExpiringIn7Days int64 `json:"expiring_in_7days"` // 7天内到期
ExpiringIn30Days int64 `json:"expiring_in_30days"` // 30天内到期
AbnormalAlerts int64 `json:"abnormal_alerts"` // 异常告警
// 增长率(作为副文本,不占独立卡片)
GrowthRate struct {
AuthCodesMoM float64 `json:"auth_codes_mom"` // 授权码月环比
LicensesMoM float64 `json:"licenses_mom"` // 许可证月环比
} `json:"growth_rate"`
}
```
---
## 四、影响面分析(全栈)
### 后端改动文件
| 文件 | 操作 | 说明 |
|------|------|------|
| `backend/internal/models/license.go` | 修改 | 更新 `StatsOverviewResponse` struct |
| `backend/internal/service/license_service.go` | 修改 | 重写 `GetStatsOverview()` 实现,增加今日/昨日新增查询 |
| `backend/internal/api/handlers/license_handler.go` | 无需修改 | Handler 层直接透传 |
### 前端改动文件
| 文件 | 操作 | 说明 |
|------|------|------|
| `frontend/src/views/Dashboard.vue` | 修改 | 重新定义 `statsData` 6张卡片,增加副文本字段渲染 |
| `frontend/src/i18n/locales/zh.json` | 修改 | 更新 `dashboard.stats.*` key |
| `frontend/src/i18n/locales/en.json` | 修改 | 同上 |
| `frontend/src/i18n/locales/ja.json` | 修改 | 同上 |
| `frontend/src/api/dashboard.ts` | 修改 | 更新 `getOverviewStats` 返回类型定义 |
---
## 五、实施优先级建议
**P0(立即)**:
- 重命名"即将过期"为"30天内到期",语义清晰化
- 增长率卡片改为副文本方式(不移除数据,只改展示层)
- 增长率数字旁加"vs. 上月"标注
**P1(本迭代)**:
- 新增"今日新增"卡片(需要后端加一个 COUNT 查询)
- "即将过期"拆分7天/30天两档
**P2(下迭代)**:
- 卡片支持点击跳转到过滤后的列表页
@@ -0,0 +1,133 @@
# 技术契约与架构设计书:前端加载性能优化
**版本**:v1.0
**日期**:2026-03-11
**作者**:system-architect
**状态**:待 task-coder 实施
---
## 一、问题诊断(根因分析)
### 1.1 每次页面跳转都重新加载 Element Plus CSS
**根因**:`vite.config.ts` 中 `ElementPlusResolver({ importStyle: 'css' })` 配置让 `unplugin-vue-components` 在每个使用到 Element Plus 组件的 `.vue` 文件中注入独立的 CSS import(`el-dropdown-menu.css`、`el-button-group.css` 等)。与此同时 `main.ts` 还保留了 `import 'element-plus/dist/index.css'`(全量 CSS),两者并存形成**双重加载**。
**表现**:网络面板中看到 `el-dropdown-menu.css`、`el-button-group.css`、`element_plus_es_components_loading_s...` 等碎片 CSS 在每次路由切换时被重新请求。
**正确做法**:二选一——
- **方案 A(推荐)**:继续使用 `element-plus/dist/index.css` 全量 CSS(已在 main.ts 中),关闭 resolver 的 `importStyle`。全量 CSS 首次加载后长期缓存,零碎的 per-component CSS 完全消失。
- 方案 B:彻底删除 `element-plus/dist/index.css`,完全依赖 resolver 按需注入。需要确保每个组件都被 resolver 覆盖,否则样式丢失。
### 1.2 字体每次导航重复加载(视觉上的误判)
**根因**:浏览器缓存策略问题。`@fontsource/noto-sans-sc` 输出的 woff2 文件名**不含内容 hash**(如 `noto-sans-sc-116-400-normal.woff2`),而 Vite dev 服务器对字体文件**不设置强缓存 header**(`Cache-Control: no-cache`)。结果是浏览器每次导航都发起带 If-None-Match 的条件请求,即使返回 304,devtools 也会显示为"加载"。
**生产环境额外问题**:vite.config.ts 的 `assetFileNames` 对字体匹配正确会加 hash,但 `@fontsource` 的 CSS 中 `src` 路径是相对路径,构建时 Vite 自动处理后会有 hash,缓存正常。
### 1.3 PangMenZhengDao.ttf:1004 KB,仅 LicenseSearch.vue 使用
这是最严重的单点问题。一个装饰性标题字体(仅 `LicenseSearch.vue` 的 `.platform-title` 用到)以 **1 MB 的 TTF 格式**全局加载。
- 全局加载:`main.ts` → `fonts.css` → 预加载所有字体
- 即使用户从未访问授权查询页,这 1 MB 也会被下载
### 1.4 Noto Sans SC:两个字重 = 每字重 ~15 个 woff2 分片
400 + 700 两个字重,每次页面含中文时浏览器匹配 unicode-range 触发下载,共约 20-30 个分片文件(每个 25-33 KB)。这是 CJK 字体的固有代价,无法消除,但可以:
- 替换为系统字体(零网络开销)
- 转为自托管 WOFF2 子集(仅保留常用汉字)
---
## 二、优化方案设计
### 方案一(必做):移除 ElementPlusResolver CSS 双重加载
**影响层**:构建配置层
**改动意图**:将 `importStyle: 'css'` 改为 `importStyle: false`,消除每个组件的碎片 CSS 请求,改由全局 `element-plus/dist/index.css` 统一提供样式。
**数据收益**:消除约 10-20 个 CSS 请求(每次路由跳转)。
---
### 方案二(必做):PangMenZhengDao 懒加载隔离
**影响层**:CSS 层 + 字体加载策略层
**改动意图**:
1. 从 `fonts.css` 中删除 `@font-face` 全局声明
2. 将 `@font-face` 声明移入 `LicenseSearch.vue` 的 `<style scoped>`,利用路由懒加载天然隔离,仅在用户访问该页时触发字体下载
**数据收益**:首屏及非授权查询页节省 1 MB 传输。
---
### 方案三(推荐):Noto Sans SC → 系统字体降级
**影响层**:CSS 变量层(variables.scss)
**改动意图**:将 `$font-family-primary` 中的 `'Noto Sans SC'` 降级为第二选择,保留兜底。同时评估是否保留 `@fontsource` 导入(影响跨平台一致性)。
如果要保留字体一致性,最小化方案是:
- **只导入 400**(删除 700 的导入),weight:700 的文字交给浏览器合成(faux-bold),视觉差异极小
- 用 `font-display: optional` 替代 `swap` 以避免 FOUT
**数据收益**:减少 ~50% 的字体请求数(约 15 个)。
---
### 方案四(加分):开发服务器字体缓存优化
**影响层**:vite.config.ts server 配置
**改动意图**:为 dev server 添加字体文件 `Cache-Control` 响应头,避免浏览器 devtools 中字体反复显示为"加载中"的视觉误导。
---
## 三、数据模型层(Schema)
无数据库变更。
---
## 四、后端服务层(API)
无需改动。
---
## 五、前端交互层(View)— 目标文件清单
| # | 文件路径 | 操作 | 改动说明 |
|---|---------|------|---------|
| 1 | `frontend/vite.config.ts` | **修改** | `ElementPlusResolver` 的 `importStyle: 'css'` → `false`;添加 dev server font cache header |
| 2 | `frontend/src/assets/styles/fonts.css` | **修改** | 删除 `PangMenZhengDao` 的 `@font-face` 块 |
| 3 | `frontend/src/views/Licenses/LicenseSearch.vue` | **修改** | 在 `<style scoped>` 顶部加入 `PangMenZhengDao` 的 `@font-face` 声明 |
| 4 | `frontend/src/main.ts` | **修改** | 删除 `@fontsource/noto-sans-sc/700.css` 导入(可选,评估后决定)|
**共 4 个文件,不超过 task-coder 单批限制(5个)。**
---
## 六、实施优先级
```
P0(必做,无副作用):
- 文件1:ElementPlusResolver importStyle fix
- 文件2 + 文件3:PangMenZhengDao 懒加载隔离
P1(可选,有视觉权衡):
- 文件4:删除 700 字重导入
```
---
## 七、预期收益对比
| 指标 | 优化前 | 优化后(P0) |
|------|-------|------------|
| 路由跳转触发的 CSS 请求 | ~15 个碎片 CSS | 0 |
| 非授权查询页的字体传输 | 1 MB (PangMenZhengDao) | 0 |
| 首次访问总字体请求数 | ~35 个 | ~20 个 |
---
*→ 移交 `task-coder` 按上述文件清单实施。*
+135
View File
@@ -0,0 +1,135 @@
# 技术契约与架构设计书:登录页国际化补全
**版本**:v1.0
**日期**:2026-03-13
**作者**:system-architect
**状态**:待 task-coder 实施
---
## 一、问题诊断
`src/views/Login.vue` 中存在 **8 处**硬编码英文字符串,未通过 `t()` 调用 i18n 系统,导致切换语言后这些文案不随语言切换。
### 硬编码清单(按 DOM 位置)
| 位置 | 当前硬编码值 | 目标 key |
|------|-------------|---------|
| `.hero-title` | `Secure License<br/>Management` | `login.heroTitleLine1` / `login.heroTitleLine2` |
| `.hero-subtitle` | `Streamline your software licensing...` | `login.heroSubtitle` |
| stat-label "Active Licenses" | `Active Licenses` | `login.stats.activeLicenses` |
| stat-label "Uptime" | `Uptime` | `login.stats.uptime` |
| stat-label "Support" | `Support` | `login.stats.support` |
| `.title` (card) | `Welcome back` | `login.welcomeTitle` |
| `.subtitle` (card) | `Sign in to your account to continue` | `login.welcomeSubtitle` |
| `.login-footer p` | `© 2025 Cedar-V. All rights reserved.` | `login.copyright` |
> **保持原样(不需国际化)**:品牌名 `Cedar-V`、数字统计值 `10K+` / `99.9%` / `24/7`、语言选项标签(自语言名称自描述惯例)。
---
## 二、数据模型层(Schema)— i18n 键值设计
### 新增键(追加至三端 `login` 对象末尾,位于 `error` 块之后)
```jsonc
// en.json
"heroTitleLine1": "Secure License",
"heroTitleLine2": "Management",
"heroSubtitle": "Streamline your software licensing workflow with our enterprise-grade platform.",
"stats": {
"activeLicenses": "Active Licenses",
"uptime": "Uptime",
"support": "Support"
},
"welcomeTitle": "Welcome back",
"welcomeSubtitle": "Sign in to your account to continue",
"copyright": "© 2025 Cedar-V. All rights reserved."
// zh.json
"heroTitleLine1": "安全许可证",
"heroTitleLine2": "管理平台",
"heroSubtitle": "使用我们的企业级平台,简化您的软件许可证管理工作流程。",
"stats": {
"activeLicenses": "活跃许可证",
"uptime": "可用率",
"support": "全天支持"
},
"welcomeTitle": "欢迎回来",
"welcomeSubtitle": "登录您的账户以继续",
"copyright": "© 2025 Cedar-V. 保留所有权利。"
// ja.json
"heroTitleLine1": "セキュアな",
"heroTitleLine2": "ライセンス管理",
"heroSubtitle": "エンタープライズグレードのプラットフォームで、ソフトウェアライセンス管理を効率化。",
"stats": {
"activeLicenses": "アクティブライセンス",
"uptime": "稼働率",
"support": "サポート"
},
"welcomeTitle": "おかえりなさい",
"welcomeSubtitle": "アカウントにサインインして続けてください",
"copyright": "© 2025 Cedar-V. All rights reserved."
```
---
## 三、前端交互层(View)— 修改意图
### 目标文件
| 文件 | 操作 |
|------|------|
| `src/i18n/locales/en.json` | 追加 8 个新键至 `login` 对象 |
| `src/i18n/locales/zh.json` | 追加 8 个新键至 `login` 对象 |
| `src/i18n/locales/ja.json` | 追加 8 个新键至 `login` 对象 |
| `src/views/Login.vue` | 模板层 8 处硬编码 → `{{ t('login.*') }}` |
### Login.vue 模板变更对照
```html
<!-- BEFORE -->
<h1 class="hero-title">Secure License<br/>Management</h1>
<!-- AFTER -->
<h1 class="hero-title">{{ t('login.heroTitleLine1') }}<br/>{{ t('login.heroTitleLine2') }}</h1>
```
```html
<!-- BEFORE -->
<p class="hero-subtitle">Streamline your software licensing workflow with our enterprise-grade platform.</p>
<!-- AFTER -->
<p class="hero-subtitle">{{ t('login.heroSubtitle') }}</p>
```
```html
<!-- BEFORE (3 stat labels) -->
<span class="stat-label">Active Licenses</span>
<span class="stat-label">Uptime</span>
<span class="stat-label">Support</span>
<!-- AFTER -->
<span class="stat-label">{{ t('login.stats.activeLicenses') }}</span>
<span class="stat-label">{{ t('login.stats.uptime') }}</span>
<span class="stat-label">{{ t('login.stats.support') }}</span>
```
```html
<!-- BEFORE -->
<h2 class="title">Welcome back</h2>
<p class="subtitle">Sign in to your account to continue</p>
<!-- AFTER -->
<h2 class="title">{{ t('login.welcomeTitle') }}</h2>
<p class="subtitle">{{ t('login.welcomeSubtitle') }}</p>
```
```html
<!-- BEFORE -->
<p>© 2025 Cedar-V. All rights reserved.</p>
<!-- AFTER -->
<p>{{ t('login.copyright') }}</p>
```
+2 -2
View File
@@ -7,9 +7,9 @@
# @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE
###
# 开发环境接口地址
VITE_API_BASE_URL = 'http://localhost:18888'
# VITE_API_BASE_URL = 'http://localhost:18888'
# VITE_API_BASE_URL = 'http://111.113.214.109:18888'
# VITE_API_BASE_URL = 'http://lm.cedar-v.com:18080'
VITE_API_BASE_URL = 'http://43.136.177.43:18888/'
# # 开发环境打包名称
Loaded 100 of 168 files, more files were not shown because too many files have changed in this diff. Show more