207 Commits
Author SHA1 Message Date
wood911010 5f7e8e2ad5 chore: 更新后端和前端服务的镜像版本至 v1.0.3 2025-12-09 17:37:43 +08:00
wood911010 0678fe248c feat: 添加仪表盘背景图片,优化主题切换按钮和样式,调整统计卡片暗模式背景 2025-12-07 10:43:39 +08:00
wood911010 0ca39951a8 docs: 更新 README 文件,增加对多语言和软件产品支持的描述,添加商业咨询与合作信息 2025-11-28 00:54:02 +08:00
wood911010 ce789093bd feat: 更新许可证表单和授权信息展示,增加功能配置和自定义参数支持,优化国际化文本 2025-11-28 00:23:31 +08:00
wood911010 22898510eb feat: 增加授权管理功能与限制设置,支持动态配置和多语言文本,优化许可证表单交互体验 2025-11-27 20:00:29 +08:00
wood911010 78b71ca325 chore: 更新后端和前端服务的镜像版本至 v1.0.2 2025-11-26 13:33:24 +08:00
wood911010 be69e0cbbb feat: 发布 v1.0.2,新增官方文档连接、许可证和授权文件下载功能,并优化仪表盘授权趋势图表 2025-11-26 13:26:57 +08:00
wood911010 994a5de632 feat: 添加下载授权文件功能,支持下载包含授权码和RSA公钥的压缩包,并更新相关国际化文本 2025-11-25 23:10:35 +08:00
wood911010 c9958b12c3 chore: 更新后端和前端服务的镜像版本至 v1.0.1 2025-11-22 13:29:23 +08:00
wood911010 670088660f chore: 移除导航组件中的搜索点击处理函数,简化代码结构 2025-11-22 13:09:57 +08:00
wood911010 8e67176edf docs: 更新 v1.0.0 文档标题为“首个正式版本发布”,新增 v1.0.1 文档,包含新功能和优化说明 2025-11-22 13:00:24 +08:00
wood911010 151d0343aa chore: 更新前端部署流程,确保目录存在并清理临时文件以优化生产环境部署 2025-11-22 12:49:03 +08:00
wood911010 994f7b71dd chore: 更新前端部署流程,添加构建环境变量和验证构建产物中的 base 路径 2025-11-22 02:18:46 +08:00
wood911010 a4313ef770 chore: 更新前端部署流程,设置构建输出目录为 production,并调整文件传输路径以匹配新结构 2025-11-22 02:08:11 +08:00
wood911010 65dc1861da chore: 更新前端部署流程,使用 rsync 进行文件传输并添加进度显示,简化部署步骤 2025-11-22 01:48:07 +08:00
wood911010 4079fd0e69 chore: 修改前端部署流程,使用临时目录进行部署,添加回滚机制以增强稳定性 2025-11-21 23:23:09 +08:00
wood911010 ac0d673d3b feat: 更新导航组件,暂时禁用搜索和通知按钮,添加语言切换功能,优化语言选择界面 2025-11-21 22:59:56 +08:00
wood911010 0478c201db feat: 实现许可证撤销功能,更新相关国际化文本,优化许可证信息展示 2025-11-21 22:36:25 +08:00
wood911010 1f25df980d feat: 添加许可证撤销和下载功能,更新国际化文本,优化许可证信息展示 2025-11-21 22:21:45 +08:00
wood911010 bed116fcb7 feat: 添加数据库初始化重试机制,增强系统稳定性 2025-11-19 22:27:45 +08:00
wood911010 7db2097435 chore: 更新 Docker Compose 文件中的镜像版本至 v1.0.0,并添加 v0.1.0 版本发布文档,概述初始框架功能和未来计划 2025-11-19 21:31:33 +08:00
wood911010 cefb7005db feat: 添加许可证管理页面的相关文档和图片,增强用户对许可证管理功能的理解 2025-11-19 17:39:08 +08:00
wood911010 b7edd8b110 fix: 修复授权信息和基本信息组件中的可选链访问,确保客户名称正确显示 2025-11-19 17:19:23 +08:00
wood911010 58e8ca0273 fix: 消除CI异常问题 2025-11-19 17:16:36 +08:00
wood911010 7e4f49779c fix 2025-11-19 16:45:11 +08:00
wood911010 a2b5ab1b3a feat: 添加 v1.0.0 版本文档,概述核心功能和模块,提供快速体验指南及未来计划 2025-11-19 16:44:55 +08:00
wood911010 496998acac feat: 发布 v1.0.0 版本,包含客户管理、授权管理和许可证管理模块,支持跨平台集成 2025-11-19 16:31:36 +08:00
wood911010 2f683fa22b feat: 更新许可证管理功能,添加手动创建许可证的接口,增强授权信息展示,更新相关国际化文本 2025-11-19 00:53:37 +08:00
13895237362 49e5952e1d Merge branch 'main' of https://github.com/cedar-v/license-manager 2025-11-07 15:33:18 +08:00
13895237362 f6f36c7812 fix:修改仪表盘页面和授权管理列表页面 2025-11-07 15:33:11 +08:00
13895237362 a34b834382 style:修改项目字体文件引入路径,使用贝蒂字体文件 2025-11-03 15:16:16 +08:00
wood911010 a7b28022c8 feat: 添加客户详情接口的授权统计信息,更新相关文档和数据模型以支持新统计字段 2025-11-01 21:46:38 +08:00
wood911010 10000681cf fix: 更新客户授权逻辑,添加客户停用状态检查,更新相关错误提示信息 2025-11-01 17:55:38 +08:00
wood911010 ac8066e0a5 refactor: 移除客户等级和企业规模的枚举限制,更新相关文档和请求结构以简化字段定义 2025-11-01 17:35:26 +08:00
wood911010 1d62dc4e95 refactor: 更新客户等级字段,修改为基本和活跃等级,调整相关文档和请求结构 2025-11-01 17:31:29 +08:00
wood911010 478ca96d90 fix: 更新授权码有效天数限制,支持最大有效天数为365000天,确保更灵活的授权管理 2025-11-01 17:20:21 +08:00
wood911010 92765441e3 feat: 增强授权码生成和验证逻辑,支持新旧格式兼容,提升安全性 2025-11-01 17:12:35 +08:00
wood911010 caa4506897 feat: 添加RSA非对称加密支持以增强许可证管理,更新配置和文档 2025-11-01 16:57:39 +08:00
wood911010 97f8306aa9 Merge branch 'main' of https://github.com/cedar-v/license-manager 2025-11-01 14:11:43 +08:00
wood911010 e54dbcb1ae feat: 添加客户删除前的授权码和许可证检查,更新相关错误提示信息 2025-11-01 14:11:38 +08:00
13895237362 302bca25be Merge branch 'main' of https://github.com/cedar-v/license-manager 2025-10-30 15:44:58 +08:00
13895237362 27018599b0 fix:修复客户管理与授权管理业务逻辑,优化页面样式 2025-10-30 15:44:51 +08:00
wood911010 5a763ac7b6 feat: 修改外键约束为级联删除,以确保授权码删除时相关记录自动清除 2025-10-29 23:38:30 +08:00
wood911010 7f01806bef feat: 优化授权码处理逻辑,直接传递 gin.Context,简化代码 2025-10-29 23:08:53 +08:00
wood911010 ab50f1151c feat: 更新授权码服务以从上下文中获取用户ID,并添加缺少认证信息的错误处理 2025-10-29 23:01:42 +08:00
wood911010 454a474a36 feat: 添加记录授权变更历史的功能,并构建配置快照 2025-10-29 22:38:20 +08:00
13895237362 1756657cc1 fix:修复仪表盘页面表格显示信息错误bug 2025-10-28 17:37:22 +08:00
13895237362 ded7187ac1 fix:修复授权码创建页面客户下拉框搜索和回显逻辑 2025-10-28 17:13:24 +08:00
13895237362 87e9fe703d fix:修复起止时间可以选择今日之前日期bug 2025-10-28 17:02:33 +08:00
13895237362 1f3aa1ffb0 fix:修复搜索功能逻辑,只在点击列表按钮是检验是否有授权数据 2025-10-28 16:56:19 +08:00
13895237362 52f24b629b Merge branch 'main' of https://github.com/cedar-v/license-manager 2025-10-28 16:39:56 +08:00
13895237362 a8c169bc91 fix:修复Sass 模块启用函数警告 2025-10-28 16:39:48 +08:00
13895237362 9e0264035b feat:新增授权管理模块中,许可证信息与授权变更历史接口与字段对接 2025-10-28 16:09:12 +08:00
wood911010 5cea6aa87a feat: 授权详情接口增加客户信息、许可证数量 2025-10-24 21:18:42 +08:00
13895237362 e3068ec1fa fix:修复授权管理、授权列表页面多语言国际化BUG 2025-10-24 15:33:00 +08:00
13895237362 bb39c001d7 fix:修复类型检查错误与部分页面多语言国际化无效BUG 2025-10-24 12:33:46 +08:00
13895237362 44a54a91a5 feat:新增顶部导航栏用户设置功能 2025-10-24 11:15:15 +08:00
13895237362 9cb8f40141 feat:新增授权管理-授权详情页面:4个页面子路由 2025-10-23 16:28:56 +08:00
13895237362 d5b90e1550 Merge branch 'main' of https://github.com/cedar-v/license-manager 2025-10-22 16:08:29 +08:00
13895237362 1f06896f6d feat:新增授权管理/查看详情页面 2025-10-22 16:08:24 +08:00
wood911010 b8430c5b7d feat: 添加授权码状态计算逻辑并优化查询条件 2025-10-21 10:16:11 +08:00
13895237362 961be26353 fix:修复完善创建授权页面逻辑 2025-10-17 11:55:07 +08:00
13895237362 8ab50aa927 fix:修复授权管理首页接口报错 2025-10-17 11:54:18 +08:00
13895237362 89de40828b feat:新增登录保存用户信息到vuex 2025-10-17 11:53:37 +08:00
wood911010 b3ca90017b feat: 优化授权相关结构体注释,增强代码可读性 2025-10-17 11:01:22 +08:00
13895237362 660dad3d01 style:修改授权列表页面与新增页面样式 2025-10-16 17:43:01 +08:00
13895237362 96e89a54bb feat:新增创建授权页面 2025-10-16 17:41:52 +08:00
13895237362 b018abdc7e feat:新增统一时间格式化共有函数 2025-10-16 16:38:51 +08:00
13895237362 1e267ea477 feat:新增授权列表页面接口调试 2025-10-16 16:38:01 +08:00
13895237362 c78701f82a Merge branch 'main' of https://github.com/cedar-v/license-manager 2025-10-16 16:01:28 +08:00
13895237362 8f0e20f46a feat:新增授权管理子页面:列表页面,新增授权页面 2025-10-16 16:01:20 +08:00
13895237362 c9fed52b29 feat:新增授权管理 2025-10-16 16:00:30 +08:00
wood911010 7ad1ae310a feat: 更新文档,添加GitHub镜像部署指南并优化Docker配置说明 2025-10-12 12:33:19 +08:00
wood911010 f0dee4a778 feat: 移除配置文件中的管理员用户名和密码,增强安全性 2025-10-12 12:07:14 +08:00
wood911010 9c1093ec29 feat: 更新前端Dockerfile和docker-compose配置,修正nginx配置文件路径并完善部署指南 2025-10-12 11:14:28 +08:00
wood911010 21d5cb1a96 feat: 更新Dockerfile和docker-compose配置,优化后端服务的配置管理和健康检查 2025-10-12 10:36:19 +08:00
wood911010 30a03b53f3 feat: 更新docker-compose配置,支持多架构构建并添加GitHub镜像部署指南 2025-10-12 09:33:50 +08:00
wood911010 f8f3edb62d feat: 添加MySQL和Redis服务的docker-compose配置,包含健康检查和环境变量设置 2025-10-12 09:01:07 +08:00
wood911010 7bed641508 feat: 优化MySQL发布工作流,移除Docker Buildx和QEMU设置步骤 2025-10-11 00:02:10 +08:00
wood911010 b5d0798ee9 Merge branch 'main' of https://github.com/cedar-v/license-manager 2025-10-10 21:53:21 +08:00
wood911010 d3cd08ebbe feat: push mysql 2025-10-10 21:52:59 +08:00
13895237362 a6b38c0fdc feat:新增授权管理页面 2025-09-29 17:31:02 +08:00
13895237362 6af27cbe6b style:新增暗模式:登录页面,仪表盘页面 2025-09-29 13:19:08 +08:00
13895237362 c1fd962387 Merge branch 'main' of https://github.com/cedar-v/license-manager 2025-09-26 08:58:20 +08:00
13895237362 dc82b9bd9b fix:修复获取最近授权数据bug 2025-09-26 08:58:03 +08:00
wood911010 13d64d3f98 fix: ts类型错误修复 2025-09-26 00:43:51 +08:00
13895237362 1063fe2bd8 feat:新增系统明暗主题变换功能 2025-09-25 16:57:15 +08:00
13895237362 70f5b8496b feat:新增仪表盘页面接口 2025-09-25 10:30:07 +08:00
13895237362 d02f49b0d7 Merge branch 'main' of https://github.com/cedar-v/license-manager 2025-09-24 10:55:53 +08:00
13895237362 02d38b8004 feat:新增仪表盘页面最近授权数据接口 2025-09-24 10:55:37 +08:00
13895237362 ace458b771 fix:修改页面seo标题 2025-09-24 10:54:52 +08:00
13895237362 b47e2aa54a style:修改仪表盘页面顶部卡片样式 2025-09-24 10:53:28 +08:00
wood911010 e9ffbc4827 fix:授权趋势查询接口swag文档缺少请求字段更新 2025-09-23 16:10:50 +08:00
wood911010 795458f6d8 feat: 增加授权趋势、最近授权两个接口 2025-09-23 14:20:38 +08:00
wood911010 2f23e2c07e fix: demo地址修改 2025-09-22 14:57:51 +08:00
wood911010 9352b113dd feat: 授权变更历史、许可证增删改查、详情、下载、授权概览统计、激活许可证、心跳检查 2025-09-22 14:54:37 +08:00
13895237362 3e0dd9a4cf fix:修复仪表盘页面,echarts图标显示不全 2025-09-18 16:41:15 +08:00
13895237362 177a543077 fix:修复打包代码检查bug 2025-09-18 16:32:24 +08:00
13895237362 ec7fe5ea9c Merge branch 'main' of https://github.com/cedar-v/license-manager 2025-09-17 17:26:39 +08:00
13895237362 114c630035 feat:新增客户管理页面多语言国际化 2025-09-17 17:26:31 +08:00
wood911010 d8fd24390d feat: 授权码删除、锁定、更新接口完成 2025-09-17 00:42:45 +08:00
wood911010 188406c517 feat: 授权码列表、创建授权码接口完成 2025-09-17 00:01:51 +08:00
13895237362 5c086a32f5 Merge branch 'main' of https://github.com/cedar-v/license-manager 2025-09-15 17:11:45 +08:00
13895237362 71aad119b4 feat:修复侧边栏和客户管理页面表格显示bug 2025-09-15 17:11:36 +08:00
wood911010 1ac4f25bc6 feat: 授权模块、用户模块表结构创建、数据迁移,更新登录接口 2025-09-11 00:07:06 +08:00
wood911010 6fa919062a fix 2025-09-05 22:36:13 +08:00
wood911010 0728dc7f19 fix 2025-09-05 22:28:37 +08:00
wood911010 ba4b34b290 fix 2025-09-05 22:22:06 +08:00
wood911010 f4cd61ec75 fix 2025-09-05 22:01:37 +08:00
wood911010 823dc8462f fix: 使用当前域名 2025-09-05 21:49:56 +08:00
13895237362 e81d024f91 style:升级 Sass 语法以避免警告,并未将来Sass新语法做准备 2025-09-05 13:17:15 +08:00
13895237362 9c2d90c37c fix:修复语言选择器样式显示bug 2025-09-05 09:33:54 +08:00
13895237362 959f82fac3 style:修改桌面端的响应式样式 2025-09-05 09:33:54 +08:00
13895237362 da00086e5f build: 构建配置和依赖版本更新 2025-09-05 09:33:54 +08:00
13895237362 f26cf73521 docs:文档完善 2025-09-05 09:33:54 +08:00
wood911010 baa220d061 fix 2025-08-27 12:06:23 +08:00
13895237362 4424badc33 feat:增加客户管理查看页面api 2025-08-21 17:24:14 +08:00
13895237362 b8bc313f42 feat:增加客户信息新增、编辑功能 2025-08-21 16:43:50 +08:00
13895237362 9bb0483e16 feat:代码暂存 2025-08-21 15:38:32 +08:00
13895237362 4ebbcb09d2 feat:新增客户管理模块的编辑、查看功能页面 2025-08-21 09:02:07 +08:00
wood911010 f0762c9d78 feat: 新增客户修改、删除、更新状态接口,成功的错误信息返回优化 2025-08-16 17:10:43 +08:00
wood911010 d20f79782f feat: 增加创建客户接口 2025-08-16 16:07:43 +08:00
wood911010 311ed26284 feat: 增加枚举查询接口,多语言枚举显示由后端提供,增加客户详情查询接口 2025-08-16 15:49:02 +08:00
wood911010 8d64b095c8 fix 2025-08-16 01:34:47 +08:00
wood911010 fbed288aef fix 2025-08-16 01:29:09 +08:00
wood911010 05e8da2551 fix 2025-08-16 01:19:14 +08:00
wood911010 59fdeead65 fix 2025-08-16 01:14:15 +08:00
wood911010 cbda2e3531 fix 2025-08-16 01:04:41 +08:00
wood911010 834a44b25f fix 2025-08-16 00:53:30 +08:00
wood911010 2aef1315a4 fix 2025-08-15 23:45:08 +08:00
wood911010 2774f2b2fb fix 2025-08-15 22:51:29 +08:00
13895237362 1f20b615ce 1.feat:新增桌面端1920*1080到2k和4k屏幕的自适应功能,
2.fix:修复页面没有给定高度BUG,不能铺满容器bug,不能一屏展示效果
2025-08-15 18:56:14 +08:00
13895237362 9f8a29edba feat:增加前端字典功能,并实现字典多语言国际化 2025-08-15 11:30:47 +08:00
13895237362 80ec43ff3d fix:修复api接口响应拦截逻辑,优先显示后端提示信息,遇401 转登陆页面 2025-08-15 11:29:56 +08:00
13895237362 f169d474d0 fix:修复移动端菜单项无法点击bug 2025-08-15 10:17:41 +08:00
13895237362 e6551aed00 feat:新增侧边栏,顶部导航栏,仪表盘页面的多语言国际化功能 2025-08-15 09:50:30 +08:00
13895237362 08dce35524 feat:新增客户管理模块API 2025-08-14 17:34:45 +08:00
13895237362 a114a8eb19 feat:新增客户管理页面 2025-08-14 17:34:45 +08:00
13895237362 a5780b5c87 fix:修复仪表盘页面,数据表格状态列的响应式bug 2025-08-14 17:34:45 +08:00
13895237362 a047f84766 feat:新增系统页面路由认证 2025-08-14 17:34:45 +08:00
13895237362 e5b4aa2a36 refactor:将屏幕断点函数移入utils 文件夹 2025-08-14 17:34:45 +08:00
wood911010 34b8aa6059 feat: 客户列表查询接口完成 2025-08-14 00:42:36 +08:00
wood911010 11dfeaed01 docs: update 2025-08-13 18:43:06 +08:00
wood911010 f7d2adb42f docs: update 2025-08-13 18:41:27 +08:00
wood911010 8673b539b7 docs: update 2025-08-13 18:17:02 +08:00
wood911010 2ec7f21cb1 docs: update 2025-08-13 18:12:43 +08:00
wood911010 4802c97012 Merge branch 'main' of github.com:cedar-v/license-manager 2025-08-13 18:02:18 +08:00
wood911010 207345713d docs: update 2025-08-13 18:02:07 +08:00
13895237362 c0c32e7d68 fix:修复时间选择器弹出层问题,修复打包报错问题 2025-08-13 17:33:19 +08:00
13895237362 2e6c0d0b15 fix:修复顶部导航栏没有根据侧边栏收起状态动态调整
bug
2025-08-13 16:55:00 +08:00
13895237362 dd0100e984 fix:修复时间选择器在移动端的自适应 2025-08-13 16:43:45 +08:00
13895237362 4254b6c81d feat:仪表盘页面新增时间i选择器,echarts图表,数据表格 2025-08-13 16:42:52 +08:00
13895237362 d10ae77fd3 fix:修复统计组件icon 移动端和桌面端布局不合理 2025-08-13 11:41:59 +08:00
13895237362 6d363ae64e fix:修复自动化部署命令中依赖冲突问题 2025-08-13 11:13:49 +08:00
13895237362 8d2e17478d fix:页面引用修复 2025-08-13 10:16:18 +08:00
13895237362 838e61ee44 fix:修复多语言配置,拦截函数配置,错误信息已后端为主 2025-08-13 10:16:18 +08:00
13895237362 b02479e3fb fix:修复打包构建警告信息 2025-08-13 10:16:18 +08:00
wood911010 3f6910a9c2 fix 2025-08-12 18:31:37 +08:00
wood911010 7cc8c8d63f Merge branch 'main' of github.com:cedar-v/license-manager 2025-08-12 18:03:30 +08:00
wood911010 55fe12b30c fix: 跨域允许 2025-08-12 18:03:21 +08:00
13895237362 19eade5f32 feat:增加仪表盘页面 2025-08-12 17:27:11 +08:00
13895237362 282a089c5c fix:修复顶部导航栏icon 不显示问题 2025-08-12 15:51:58 +08:00
13895237362 00d959064a feat:增加icon自定义 2025-08-12 10:24:12 +08:00
13895237362 96cf623fbd feat:增加客户管理,授权管理,角色权限,系统用户,四个页面 2025-08-12 10:24:12 +08:00
13895237362 13160d54e1 fix :修复侧边栏目logo错误 2025-08-12 10:24:12 +08:00
wood911010 cccfca6ef1 fix 2025-08-12 10:17:17 +08:00
wood911010 34e18f8a47 fix 2025-08-12 09:47:33 +08:00
wood911010 60b7fb3a27 fix 2025-08-12 09:38:03 +08:00
wood911010 ffed027ba1 fix 2025-08-12 09:11:21 +08:00
wood911010 aa43a51db5 feat: 客户管理模块接口设计 2025-08-11 21:53:12 +08:00
13895237362 0279083c59 build:根据ai开发文档修改项目结构 2025-08-11 11:55:29 +08:00
13895237362 81761798ea feat:添加ai开发指导文档 2025-08-11 11:55:29 +08:00
wood911010 3622adaa33 fix: 问题修复 2025-08-10 00:11:22 +08:00
wood911010 b56c9061d3 fix: 问题修复 2025-08-09 23:51:10 +08:00
wood911010 c88da44db0 fix: CI调整 2025-08-09 23:32:05 +08:00
wood911010 311bd719c9 fix 2025-08-09 23:12:26 +08:00
wood911010 246d49a038 fix: 前端自动化部署优化 2025-08-09 22:40:48 +08:00
wood911010 6bcd8f4d09 feat:自动构建 2025-08-09 01:34:49 +08:00
wood911010 183f5e7896 Merge branch 'main' of github.com:cedar-v/license-manager 2025-08-08 20:34:10 +08:00
wood911010 fa0dc7e7e1 fix: nginx配置问题处理 2025-08-08 20:33:16 +08:00
13895237362 e85a741159 feat:添加仪表盘页面 2025-08-08 17:42:49 +08:00
13895237362 cd6852775a feat:增加i正义页面布局组件 2025-08-08 17:42:18 +08:00
13895237362 b83662b3ad feat:增加顶部导航栏组件 2025-08-08 17:41:39 +08:00
13895237362 41bd418896 feat:增加侧边栏组件 2025-08-08 17:40:56 +08:00
13895237362 afacf22700 build:置空不同环境的接口地址前缀 2025-08-08 10:07:47 +08:00
13895237362 d00f1b0115 style:i修改登陆页面样式 2025-08-07 18:42:35 +08:00
wood911010 df34fc30cc fix: 错误返回码问题处理 2025-08-07 11:36:48 +08:00
wood911010 bb4de28b78 fix 2025-08-07 11:23:29 +08:00
wood911010 3b09705c25 fix 2025-08-07 11:18:11 +08:00
wood911010 014039cbd1 fix 2025-08-07 11:10:22 +08:00
wood911010 e18a2cf9e5 fix 2025-08-07 11:03:57 +08:00
wood911010 22dcff976d Fix 2025-08-07 10:54:01 +08:00
wood911010 98b78e8ed9 fix: 自动化部署调整 2025-08-07 10:42:12 +08:00
wood911010 92c93cb24f Merge branch 'main' of github.com:cedar-v/license-manager 2025-08-07 10:32:31 +08:00
wood911010 cceecc29a8 Fix: 修改配置 2025-08-07 10:32:24 +08:00
13895237362 62dd1051dd refactor:删除登录页测试代码 2025-08-07 09:45:51 +08:00
13895237362 a0b506cc38 style:修复登陆页面不一屏展示和自适应bug,改变element-plus的主题色 2025-08-07 09:45:51 +08:00
13895237362 3f291cc363 fix:改变i8n和element-plus引入方式 2025-08-07 09:45:51 +08:00
13895237362 af382db83f fix: 修复多语言切换,全局不改变 2025-08-07 09:45:51 +08:00
13895237362 1c677f9987 docs:多语言框架设计方案 2025-08-07 09:45:51 +08:00
13895237362 e6bc739db9 feat:多语言切换功能 2025-08-07 09:45:51 +08:00
wood911010 609698be81 docs: 文档修改优化 2025-08-06 23:04:16 +08:00
wood911010 7cd5b4fb66 Feat: 后端增加国际化特性 2025-08-06 22:23:50 +08:00
wood911010 38afe0d00e Feat: 增加数据库连接模块和客户管理模型 2025-08-04 23:04:20 +08:00
wood911010 ef6ee72866 Fix: 完善错误信息模块设计 2025-08-04 21:37:35 +08:00
wood911010 1c6e6f826b feat: 客户结构设计,错误码优化 2025-08-02 00:37:26 +08:00
13895237362 db7ad2827a feat:多语言功能 2025-08-01 09:34:55 +08:00
248 changed files with 46491 additions and 2241 deletions

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
# 生产环境配置示例
# 复制此文件为 .env 并修改相应配置
# MySQL配置
MYSQL_ROOT_PASSWORD=your-strong-root-password
MYSQL_PASSWORD=your-strong-user-password
MYSQL_PORT=3306
# 应用端口配置
BACKEND_PORT=18888
FRONTEND_PORT=80
# Redis配置 (可选)
REDIS_PASSWORD=your-redis-password
REDIS_PORT=6379
# 镜像配置 (用于生产环境)
BACKEND_IMAGE=ghcr.io/your-org/license-manager-backend:latest
FRONTEND_IMAGE=ghcr.io/your-org/license-manager-frontend:latest
+41
View File
@@ -0,0 +1,41 @@
name: Deploy Backend via Git
# 触发条件
on:
# 当推送到主分支且修改后端相关文件时自动触发
push:
branches: [ main ]
paths:
- 'backend/**'
- '.github/workflows/deploy_backend.git.yml'
# 支持手动触发
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
# 通过SSH连接服务器进行部署
- name: Deploy to server
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
port: 22
command_timeout: 10m
script_stop: true
envs: USER
script: |
# 确保使用正确的用户环境
export HOME=/home/pi
export USER=pi
# 加载pi用户的环境配置(包括代理设置)
source /home/pi/.bashrc || true
source /home/pi/.profile || true
cd /home/pi/license-manager
git pull origin main
pm2 restart license_manager
+19 -31
View File
@@ -1,12 +1,12 @@
name: Deploy Backend
on:
push:
branches: [ main ]
paths:
- 'backend/**'
- 'Dockerfile'
- '.github/workflows/deploy_backend.yml'
# push:
# branches: [ main ]
# paths:
# - 'backend/**'
# - 'Dockerfile'
# - '.github/workflows/deploy_backend.yml'
workflow_dispatch:
env:
@@ -45,22 +45,22 @@ jobs:
- name: Copy files to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.HOST_1 }}
username: root
password: ${{ secrets.PASS_1 }}
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
port: 22
source: "backend-image.tar,backend/configs/config.example.yaml,backend/configs/config.yaml"
target: "/opt/license-manager/"
source: "backend-image.tar"
target: "/home/pi/license-manager/"
- name: Deploy to server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST_1 }}
username: root
password: ${{ secrets.PASS_1 }}
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
port: 22
script: |
cd /opt/license-manager
cd /home/pi/license-manager
# Stop and remove existing container
docker stop ${{ env.CONTAINER_NAME }} || true
@@ -73,24 +73,12 @@ jobs:
docker rmi ${{ env.IMAGE_NAME }}:old || true
docker tag ${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:old || true
# Create config file if not exists
if [ ! -f config.yaml ]; then
if [ -f backend/configs/config.yaml ]; then
cp backend/configs/config.yaml config.yaml
echo "Created config.yaml from backend configs."
else
cp backend/configs/config.example.yaml config.yaml
echo "Created config.yaml from example. Please review and update configuration."
fi
fi
# Run new container
# Run new container with host network
docker run -d \
--name ${{ env.CONTAINER_NAME }} \
--restart unless-stopped \
-p 18888:18888 \
-v /opt/license-manager/config.yaml:/app/config.yaml \
-v /opt/license-manager/logs:/app/logs \
--network host \
-v /home/pi/license-manager/logs:/app/logs \
${{ env.IMAGE_NAME }}:latest
# Clean up
@@ -111,7 +99,7 @@ jobs:
- name: Health check
run: |
sleep 10
response=$(curl -s -o /dev/null -w "%{http_code}" http://${{ secrets.HOST_1 }}:18888/health || echo "000")
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
+185
View File
@@ -0,0 +1,185 @@
name: Deploy Frontend via Git
# 触发条件
on:
# 当推送到主分支且修改前端相关文件时自动触发
push:
branches: [ main ]
paths:
- 'frontend/**'
- '.github/workflows/deploy_frontend.git.yml'
# 支持手动触发
workflow_dispatch:
jobs:
build-and-deploy:
runs-on: ubuntu-latest
steps:
# 检出代码
- name: Checkout repository
uses: actions/checkout@v4
# 设置Node.js环境
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
# 缓存node_modules(不缓存构建输出,确保每次都是最新构建)
- name: Cache dependencies
uses: actions/cache@v4
with:
path: |
frontend/node_modules
key: ${{ runner.os }}-frontend-deps-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-frontend-deps-
# 安装依赖
- name: Install dependencies
run: |
cd frontend
npm ci
# 清理旧的构建目录(确保使用最新代码构建)
- name: Clean old build artifacts
run: |
cd frontend
rm -rf production dist
echo "Cleaned old build directories"
# 执行类型检查和构建
- name: Type check and build
run: |
cd frontend
# 设置构建环境变量,确保与本地构建一致
export VITE_OUTDIR=production
export VITE_ADDRESS_BASE_URL=/production/
# 先执行类型检查
npm run type-check
# 然后执行构建
npm run build:prod
# 检查构建结果
echo "Build completed, checking production directory:"
ls -la production/
# 验证构建产物中的 base 路径
echo "Verifying base path in index.html:"
grep -o 'href="/production/' production/index.html | head -1 || echo "Warning: base path not found"
# 显示构建产物的时间戳,确认是新构建的
echo "Build timestamp:"
stat production/index.html 2>/dev/null || ls -la production/index.html
# 准备临时部署目录
- name: Prepare temp directory
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
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
# 安装 rsync(用于带进度显示的文件传输)
- name: Install rsync
run: sudo apt-get update && sudo apt-get install -y rsync sshpass
# 计算要传输的文件总大小
- name: Calculate total size
id: calc_size
run: |
cd frontend/production
TOTAL_SIZE=$(du -sb . | cut -f1)
echo "total_size=$TOTAL_SIZE" >> $GITHUB_OUTPUT
echo "Total size to transfer: $(numfmt --to=iec-i --suffix=B $TOTAL_SIZE)"
# 将构建产物传输到服务器(带进度显示)
- name: Copy build files to server with progress
run: |
set -e
echo "Starting file transfer with rsync..."
# 启动后台传输(使用 --info=progress2 只显示总体进度,减少输出)
sshpass -p "${{ secrets.PASS_PI }}" 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 &
RSYNC_PID=$!
# 每10秒显示一次进度
TOTAL_SIZE=${{ steps.calc_size.outputs.total_size }}
echo "📦 Total size: $(numfmt --to=iec-i --suffix=B $TOTAL_SIZE 2>/dev/null || echo "${TOTAL_SIZE}B")"
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 }} \
"du -sb /home/pi/lm-www/frontend_tmp/production 2>/dev/null | cut -f1" || echo "0")
if [ "$TRANSFERRED" != "0" ] && [ "$TOTAL_SIZE" != "0" ]; then
PERCENT=$((TRANSFERRED * 100 / TOTAL_SIZE))
TRANSFERRED_HR=$(numfmt --to=iec-i --suffix=B $TRANSFERRED 2>/dev/null || echo "${TRANSFERRED}B")
TOTAL_HR=$(numfmt --to=iec-i --suffix=B $TOTAL_SIZE 2>/dev/null || echo "${TOTAL_SIZE}B")
echo "📦 Transfer progress: $PERCENT% ($TRANSFERRED_HR / $TOTAL_HR)"
else
echo "📦 Transfer in progress..."
fi
done
# 等待传输完成并检查退出码
wait $RSYNC_PID
EXIT_CODE=$?
# 显示 rsync 的最后几行输出
echo "--- rsync output (last 20 lines) ---"
tail -20 /tmp/rsync.log || true
if [ $EXIT_CODE -eq 0 ]; then
echo "✅ File transfer completed!"
else
echo "❌ File transfer failed with exit code $EXIT_CODE"
exit $EXIT_CODE
fi
env:
DEBIAN_FRONTEND: noninteractive
# 完成部署:直接替换并重启 nginx
- name: Deploy to production
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
port: 22
script: |
set -e
echo "Deploying to production..."
# 确保 frontend 目录存在
sudo mkdir -p /home/pi/lm-www/frontend
# 删除原有的 production 目录
sudo rm -rf /home/pi/lm-www/frontend/production
# 将新的 production 目录移过去
sudo mv /home/pi/lm-www/frontend_tmp/production /home/pi/lm-www/frontend/production
# 清理临时目录
sudo rm -rf /home/pi/lm-www/frontend_tmp
echo "Setting permissions..."
sudo chown -R www-data:www-data /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 {} \;
echo "Verifying deployment structure..."
ls -la /home/pi/lm-www/frontend/
ls -la /home/pi/lm-www/frontend/production/ || echo "Warning: production directory not found"
echo "Reloading nginx..."
sudo systemctl reload nginx
echo "✅ Deployment completed!"
+17 -17
View File
@@ -1,13 +1,13 @@
name: Deploy Frontend
on:
push:
branches: [ main ]
paths:
- 'frontend/**'
- 'Dockerfile.frontend'
- 'nginx.conf'
- '.github/workflows/deploy_frontend.yml'
# push:
# branches: [ main ]
# paths:
# - 'frontend/**'
# - 'Dockerfile.frontend'
# - 'nginx.conf'
# - '.github/workflows/deploy_frontend.yml'
workflow_dispatch:
env:
@@ -27,28 +27,28 @@ jobs:
- name: Build Docker image
run: |
docker build -f Dockerfile.frontend -t ${{ env.IMAGE_NAME }}:latest .
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_1 }}
username: root
password: ${{ secrets.PASS_1 }}
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
port: 22
source: "frontend-image.tar"
target: "/opt/license-manager/"
target: "/home/pi/license-manager/"
- name: Deploy to server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST_1 }}
username: root
password: ${{ secrets.PASS_1 }}
host: ${{ secrets.HOST_PI }}
username: pi
password: ${{ secrets.PASS_PI }}
port: 22
script: |
cd /opt/license-manager
cd /home/pi/license-manager
# Stop and remove existing container
docker stop ${{ env.CONTAINER_NAME }} || true
@@ -87,7 +87,7 @@ jobs:
- name: Health check
run: |
sleep 10
response=$(curl -s -o /dev/null -w "%{http_code}" http://${{ secrets.HOST_1 }}:18080/ || echo "000")
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
+31
View File
@@ -0,0 +1,31 @@
name: Publish MySQL to Tencent CCR
on:
workflow_dispatch:
jobs:
publish:
runs-on: ubuntu-latest
steps:
- name: Login to CCR
run: |
echo "${{ secrets.TENCENT_CCR_PASSWORD }}" | docker login ccr.ccs.tencentyun.com --username="${{ secrets.TENCENT_CCR_USERNAME }}" --password-stdin
- name: Pull and push AMD64
run: |
docker pull mysql:8.0 --platform linux/amd64
docker tag mysql:8.0 ccr.ccs.tencentyun.com/cedar-v/mysql:8.0-amd64
docker push ccr.ccs.tencentyun.com/cedar-v/mysql:8.0-amd64
- name: Pull and push ARM64
run: |
docker pull mysql:8.0 --platform linux/arm64
docker tag mysql:8.0 ccr.ccs.tencentyun.com/cedar-v/mysql:8.0-arm64
docker push ccr.ccs.tencentyun.com/cedar-v/mysql:8.0-arm64
- name: Create and push manifest
run: |
docker manifest create ccr.ccs.tencentyun.com/cedar-v/mysql:8.0 \
ccr.ccs.tencentyun.com/cedar-v/mysql:8.0-amd64 \
ccr.ccs.tencentyun.com/cedar-v/mysql:8.0-arm64
docker manifest push ccr.ccs.tencentyun.com/cedar-v/mysql:8.0
+92
View File
@@ -0,0 +1,92 @@
name: Release Backend Image
on:
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g., v1.0.0)'
required: true
type: string
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}-backend
jobs:
test-and-build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23'
- name: Cache Go modules
uses: actions/cache@v3
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('backend/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Run tests
run: |
cd backend
go mod tidy
go mod verify
go test -v ./...
go build -ldflags="-s -w" -o license-manager cmd/main.go
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=raw,value=${{ github.event.inputs.version }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.backend
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate summary
run: |
echo "## 🚀 Backend Release Successful!" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "**Image:** ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
echo "**Tags:**" >> $GITHUB_STEP_SUMMARY
echo "- ${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "- latest" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Usage:" >> $GITHUB_STEP_SUMMARY
echo '```bash' >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
+71
View File
@@ -0,0 +1,71 @@
name: Release Frontend Image
on:
workflow_dispatch:
inputs:
version:
description: 'Release version (e.g., v1.0.0)'
required: true
type: string
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}-frontend
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest
type=raw,value=${{ github.event.inputs.version }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile.frontend
build-args: |
NGINX_CONF=nginx.conf
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Generate summary
run: |
echo "## 🎉 Frontend Release Successful!" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "**Image:** ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}" >> $GITHUB_STEP_SUMMARY
echo "**Tags:**" >> $GITHUB_STEP_SUMMARY
echo "- ${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "- latest" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Usage:" >> $GITHUB_STEP_SUMMARY
echo '```bash' >> $GITHUB_STEP_SUMMARY
echo "docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.event.inputs.version }}" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
-23
View File
@@ -1,23 +0,0 @@
# Multi-stage build
FROM node:18-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
FROM golang:1.23-alpine AS backend-builder
WORKDIR /app/backend
COPY backend/go.mod backend/go.sum ./
RUN go mod download
COPY backend/ ./
RUN go build -o license-manager cmd/main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /app
COPY --from=backend-builder /app/backend/license-manager .
COPY --from=frontend-builder /app/frontend/dist ./static
COPY backend/configs/config.example.yaml ./config.yaml
EXPOSE 8080
CMD ["./license-manager"]
+6 -4
View File
@@ -7,9 +7,11 @@ COPY backend/ ./
RUN go build -ldflags="-s -w" -o license-manager cmd/main.go
FROM alpine:latest
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/backend/license-manager .
COPY backend/configs/config.example.yaml ./config.example.yaml
RUN apk --no-cache add ca-certificates tzdata curl
WORKDIR /app/backend/cmd
COPY --from=builder /app/backend/license-manager ./license-manager
COPY backend/configs/ ../configs/
# Use production config by default
RUN ln -sf ../configs/config.prod.yaml config.yaml
EXPOSE 18888
CMD ["./license-manager"]
+6 -4
View File
@@ -4,7 +4,7 @@ FROM node:20-alpine AS builder
WORKDIR /app
COPY frontend/package.json ./
# Install dependencies without package-lock.json constraints
# Install dependencies (ignore package-lock.json since it's outdated)
RUN npm install
COPY frontend/ ./
@@ -14,7 +14,7 @@ ENV VITE_OUTDIR=dist
ENV VITE_ADDRESS_BASE_URL=/
# Build the project and verify output
RUN npm run prod && ls -la && ls -la dist/
RUN npm run build:prod && ls -la && ls -la dist/
# Production stage with Nginx
FROM nginx:alpine
@@ -22,8 +22,10 @@ FROM nginx:alpine
# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Selectable nginx configuration (default: nginx.conf). Use build-arg to switch.
ARG NGINX_CONF=nginx.conf
ENV NGINX_CONF=${NGINX_CONF}
COPY ${NGINX_CONF} /etc/nginx/conf.d/default.conf
# Create log directory
RUN mkdir -p /var/log/nginx
+120
View File
@@ -0,0 +1,120 @@
# Docker 部署指南
本项目提供两种部署:开发环境与生产环境。以下说明已与当前 `docker-compose` 配置、镜像与反向代理实现对齐。
## 开发环境
使用 `docker compose`(Docker Desktop 或 Docker CE 20.10+):
```bash
# 启动(首次建议带 --build)
docker compose up -d --build
# 查看状态/日志
docker compose ps
docker compose logs -f backend
# 停止
docker compose down
```
### 访问地址(开发)
- 前端: http://localhost:18080
- 后端 API: http://localhost:18888
- MySQL: localhost:13306(数据库、用户、密码见 compose)
## 生产环境
使用 `docker-compose.prod.yml`:
```bash
# 启动生产环境
docker compose -f docker-compose.prod.yml up -d
# 查看服务状态
docker compose -f docker-compose.prod.yml ps
```
### 生产配置说明
- 后端配置文件已通过卷挂载至容器内:
- 开发:`backend/configs/config.dev.yaml` → 容器内 `/app/backend/cmd/config.yaml`
- 生产:`backend/configs/config.prod.yaml` → 容器内 `/app/backend/cmd/config.yaml`
- 前端 Nginx 反向代理已指向容器网络内的 `backend:18888`,无需依赖 `host.docker.internal`。
- 健康检查统一使用 `curl`,后端镜像已内置 `curl`。
- 如需隐藏后端对外端口,可在生产 compose 中移除后端端口映射,仅通过前端或网关暴露。
## 服务说明
### MySQL 8.0
- 端口:3306(生产)/ 13306(开发宿主机映射)
- 库:`license_manager`,用户:`license_user`
- 字符集/排序:`utf8mb4`/`utf8mb4_unicode_ci`
- 开启慢查询日志(生产)
### 后端服务
- 端口:18888
- 健康检查:`/health`
- 配置:通过卷挂载 `config.dev.yaml` / `config.prod.yaml` 为容器内 `config.yaml`
- 日志:标准输出 + `./logs` 挂载目录
### 前端服务
- 端口:80(生产)/ 18080(开发映射)
- 反向代理:`/api/` → `backend:18888`
- 静态资源:启用 Gzip 与长缓存
### Redis(可选,仅生产)
- 端口:6379,AOF 持久化
## 常用命令
```bash
# 重新构建镜像
docker compose build
# 启动指定服务
docker compose up -d mysql backend
# 进入容器
docker compose exec backend sh
docker compose exec mysql mysql -uroot -p
# 备份/恢复数据库
docker compose exec mysql mysqldump -uroot -p license_manager > backup.sql
docker compose exec -T mysql mysql -uroot -p license_manager < backup.sql
# 清理未使用资源
docker system prune -f
```
## 故障排查
1) MySQL 启动失败
```bash
docker compose logs mysql
docker compose down -v && docker compose up -d
```
2) 后端连接数据库失败
```bash
# 确认 MySQL 健康
docker compose ps
# 检查容器内配置(已挂载到 /app/backend/cmd/config.yaml)
docker compose exec backend cat /app/backend/cmd/config.yaml
```
3) 前端无法访问后端 API
```bash
# 容器内连通性
docker compose exec frontend sh -c "curl -sS http://backend:18888/health"
# 检查 nginx 配置
docker compose exec frontend cat /etc/nginx/conf.d/default.conf
```
## 注意事项
- 生产环境请修改默认账号、数据库口令与 JWT 秘钥(见 `backend/configs/config.prod.yaml`)。
- 如使用较新 Docker,YAML 顶部 `version` 字段会被忽略,可按需移除提示。
- `deploy.resources` 仅在 Swarm/K8s 生效,单机 Compose 可忽略。
- 如置于公网,建议仅暴露前端端口,通过前端或网关转发后端 API。
+156 -30
View File
@@ -1,12 +1,83 @@
# 软件授权管理平台
<p align="center">
<a href="https://github.com/cedar-v/license-manager/releases">
<img src="https://img.shields.io/github/v/release/cedar-v/license-manager?logo=git&label=Latest%20Release" alt="Latest Release">
</a>
<a href="https://github.com/cedar-v/license-manager/releases">
<img src="https://img.shields.io/github/downloads/cedar-v/license-manager/total?label=Downloads&logo=github" alt="Downloads">
</a>
<a href="LICENSE">
<img src="https://img.shields.io/github/license/cedar-v/license-manager?label=License&color=blue" alt="License: GPL-3.0">
</a>
<a href="https://github.com/cedar-v/license-manager/issues">
<img src="https://img.shields.io/github/issues/cedar-v/license-manager?label=Issues&logo=github" alt="Open Issues">
</a>
<a href="https://github.com/cedar-v/license-manager/pulls">
<img src="https://img.shields.io/github/issues-pr/cedar-v/license-manager?label=PRs&logo=github" alt="Open PRs">
</a>
</p>
[中文](README.md) | [English](README_EN.md)
---
## 项目概述
软件授权管理平台是一个独立的软件授权管理系统,为IoT平台等软件系统提供授权码生成、分发、验证和管理服务。支持在线和离线两种授权模式,基于硬件绑定的授权机制确保安全性。
软件授权管理平台是一个独立的软件授权管理系统,为软件系统提供授权码生成、分发、验证和管理服务。支持在线和离线两种授权模式,基于硬件绑定的授权机制确保安全性。
✨同时支持**任何开发语言**、**任何类型的软件产品**通过标准化 **HTTP API** 与**跨平台客户端 SDK** **快速集成授权能力**。
## Demo环境
<a href="https://lm.cedar-v.com:18080">
<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)
**登录凭证:**
- 用户名:`admin`
- 密码:`admin@123`
> 💡 这是一个当前功能的演示环境,您可以体验已开发的核心特性。
## 官方文档
官方文档请访问:[https://docs.cedar-v.com](https://docs.lm.cedar-v.com)
## Star收藏与关注
<a href="https://github.com/cedar-v/license-manager" target="_blank">
<img src="https://img.shields.io/github/stars/cedar-v/license-manager?style=social" alt="GitHub Stars">
</a>
<a href="https://github.com/cedar-v/license-manager" target="_blank">
<img src="https://img.shields.io/github/watchers/cedar-v/license-manager?style=social" alt="GitHub Watchers">
</a>
如果这个项目对你有帮助,请给个star收藏,你的支持是我们最大的动力!
## 客户端SDK
客户端SDK请访问:[https://github.com/cedar-v/license-manager-sdk](https://github.com/cedar-v/license-manage-sdk-go)
## 商业咨询与合作
商业咨询与合作请联系QQ群管理员。
## 系统界面
### 登录界面
![登录页面](docs/images/login.png)
### 管理仪表盘
![系统主页](docs/images/home.png)
### 客户管理
![客户管理页面](docs/images/customer.png)
### 许可证管理
![许可证管理页面](docs/images/license.png)
客户管理模块提供完整的客户信息管理功能,包括客户信息的增删改查、状态管理、授权关联等核心功能。
## 核心功能
@@ -21,59 +92,97 @@
## 技术栈
- **前端**:Vue.js 3+ 配合现代化UI组件
- **后端**:Go 1.23+ 使用Gorilla Mux路由和Logrus日志
- **数据库**:PostgreSQL 12+ / MySQL 12+
- **后端**:Go 1.23+ 使用Gin框架、GORM、Viper配置和Logrus日志
- **数据库**:MySQL 8+
- **配置**:YAML格式配置文件
- **部署**:Docker、单机部署或系统服务
## API接口
```
POST /api/validate - 授权验证
POST /api/activate - 授权激活
POST /api/heartbeat - 心跳上报
GET /api/license/{code} - 授权信息查询
GET /api/customers - 客户列表API
GET /tools/{tool} - 工具下载
```
## 安全与性能
- **安全特性**:HMAC-SHA256签名,硬件指纹绑定,HTTPS传输加密
- **性能表现**:支持100+并发用户,API响应时间<2秒
- **可靠性**:完善的错误处理和日志记录
- **安全特性**:
- JWT身份认证和授权机制
- HMAC-SHA256签名验证
- 硬件指纹绑定防盗版
- HTTPS传输加密
- 多语言错误信息支持(中/英/日)
- **性能表现**:
- **高并发**:Go原生协程支持,理论支持10,000+并发连接
- **低延迟**:API平均响应时间 < 50ms
- **内存优化**:Go GC优化,内存占用 < 100MB
- **数据库连接池**:支持连接复用,最大化数据库性能
- **可靠性**:
- 完善的错误处理和多语言错误信息
- 结构化日志记录和监控
- 自动数据库迁移
- 优雅关闭和资源清理
## 安装部署
```bash
# 克隆项目
git clone <repository-url>
git clone https://github.com/cedar-v/license-manager.git
cd license-manager
# 构建应用
# 本地后端构建与运行(可选)
cd backend/cmd
go build -o license-manager
# 配置应用
cp config.example.yaml config.yaml
# 编辑 config.yaml 配置文件
# 配置(二选一)
# 1) 在当前目录创建配置文件(程序优先读取工作目录下的 config.yaml)
# cp ../configs/config.example.yaml ./config.yaml && 编辑
# 2) 直接编辑 ../configs/config.yaml(程序会自动回退读取该文件)
# 运行应用
./license-manager
```
##
## Docker 部署
## Docker部署
### 使用预构建镜像(推荐)
如需快速部署且无需修改源码,可直接使用预构建的 GitHub Container Registry 镜像:
详细部署步骤请参考:[GitHub 镜像部署指南](docs/github-images-deploy.md)
### 从源码构建
推荐使用 Docker Compose,已提供开发与生产编排文件:
```bash
# 构建Docker镜像
docker build -t license-manager .
# 开发环境(首次建议带 --build)
docker compose up -d --build
# 使用Docker运行
docker run -p 8080:8080 -v $(pwd)/config.yaml:/app/config.yaml license-manager
# 生产环境
docker compose -f docker-compose.prod.yml up -d
# 验证后端健康
curl http://localhost:18888/health
```
完整部署说明(反向代理、健康检查、配置挂载等)请参见 `README-Docker.md`。
## 开源许可证
本项目采用GNU通用公共许可证v3.0 - 详见[LICENSE](LICENSE)文件。
本项目采用 **GNU General Public License v3.0 (GPL-3.0)** 开源许可证。
### 许可证说明
- **自由使用**:您可以自由地使用、学习、修改和分发本软件
- **开源要求**:如果您分发修改版本,必须同样开源并采用GPL-3.0许可证
- **版权保护**:使用本软件的衍生作品必须保留原始版权声明
- **无担保**:软件按"现状"提供,不提供任何明示或暗示的担保
### 商业使用
- 允许商业使用,但衍生作品必须同样开源
- 如需专有许可证或商业支持,请联系项目维护者
### 许可证全文
详细许可证条款请查看项目根目录的 [LICENSE](LICENSE) 文件,或访问:
https://www.gnu.org/licenses/gpl-3.0.html
---
@@ -83,4 +192,21 @@ docker run -p 8080:8080 -v $(pwd)/config.yaml:/app/config.yaml license-manager
## 支持
如有任何问题或需要支持,请提交issue。
### 用户反馈与交流
我们非常重视用户的反馈和建议!欢迎加入我们的QQ群进行讨论和交流:
<img src="docs/images/qrcode_1755081220153.jpg" alt="QQ群二维码" width="200" />
扫描二维码或者搜索QQ群号加入,与其他用户和开发者一起:
- 分享使用经验和最佳实践
- 提出问题和获得帮助
- 参与新功能讨论
- 反馈问题和改进建议
### 问题报告
如有技术问题或Bug报告,请通过以下方式联系我们:
- 提交GitHub Issue(推荐)
- 在QQ群中反馈
- 发送邮件给项目维护者
+153 -19
View File
@@ -1,5 +1,23 @@
# License Manager
<p align="center">
<a href="https://github.com/cedar-v/license-manager/releases">
<img src="https://img.shields.io/github/v/release/cedar-v/license-manager?logo=git&label=Latest%20Release" alt="Latest Release">
</a>
<a href="https://github.com/cedar-v/license-manager/releases">
<img src="https://img.shields.io/github/downloads/cedar-v/license-manager/total?label=Downloads&logo=github" alt="Downloads">
</a>
<a href="LICENSE">
<img src="https://img.shields.io/github/license/cedar-v/license-manager?label=License&color=blue" alt="License: GPL-3.0">
</a>
<a href="https://github.com/cedar-v/license-manager/issues">
<img src="https://img.shields.io/github/issues/cedar-v/license-manager?label=Issues&logo=github" alt="Open Issues">
</a>
<a href="https://github.com/cedar-v/license-manager/pulls">
<img src="https://img.shields.io/github/issues-pr/cedar-v/license-manager?label=PRs&logo=github" alt="Open PRs">
</a>
</p>
[中文](README.md) | [English](README_EN.md)
---
@@ -8,6 +26,51 @@
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.
✨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**.
## Demo Environment
<a href="https://lm.cedar-v.com:18080">
<img src="https://img.shields.io/badge/Demo-Online-success?logo=googlechrome" alt="Live Demo">
</a>
Access URL: [lm.cedar-v.com:18080](http://lm.cedar-v.com:18080)
**Login Credentials:**
- Username: `admin`
- Password: `admin@123`
> 💡 This is a demo environment showcasing our current features. You can experience the implemented core functionalities while we continue active development with more features coming soon.
## Stars & Followers
<a href="https://github.com/cedar-v/license-manager" target="_blank">
<img src="https://img.shields.io/github/stars/cedar-v/license-manager?style=social" alt="GitHub Stars">
</a>
<a href="https://github.com/cedar-v/license-manager" target="_blank">
<img src="https://img.shields.io/github/watchers/cedar-v/license-manager?style=social" alt="GitHub Watchers">
</a>
If this project helps you, please give it a star—your support keeps us moving!
## Client SDK
Client SDK repository: [https://github.com/cedar-v/license-manager-sdk](https://github.com/cedar-v/license-manage-sdk-go)
## System Interface
### Login Page
![Login Page](docs/images/login.png)
### Management Dashboard
![Dashboard](docs/images/home.png)
### Customer Management
![Customer Management Page](docs/images/customer.png)
### License Management
![License Management Page](docs/images/license.png)
The customer management module provides comprehensive customer information management features, including CRUD operations for customer data, status management, and authorization associations.
## Key Features
- 🔧 **Customer Management**: Complete customer information management with status control
@@ -21,8 +84,8 @@ License Manager is an independent software licensing platform that provides lice
## Technical Stack
- **Frontend**: Vue.js 3+ with modern UI components
- **Backend**: Go 1.23+ with Gorilla Mux router and Logrus logging
- **Database**: PostgreSQL 12+ / MySQL 12+
- **Backend**: Go 1.23+ with Gin framework, GORM, Viper configuration and Logrus logging
- **Database**: MySQL 8+
- **Configuration**: YAML format configuration files
- **Deployment**: Docker, single machine, or system service
@@ -39,41 +102,95 @@ GET /tools/{tool} - Tool download
## Security & Performance
- **Security**: HMAC-SHA256 signing, hardware fingerprint binding, HTTPS encryption
- **Performance**: Supports 100+ concurrent users with <2s API response time
- **Reliability**: Comprehensive error handling and logging
- **Security Features**:
- JWT authentication and authorization
- HMAC-SHA256 signature verification
- Hardware fingerprint binding for anti-piracy
- HTTPS transport encryption
- Multi-language error message support (Chinese/English/Japanese)
- **Performance**:
- **High Concurrency**: Go native goroutines support, theoretically supports 10,000+ concurrent connections
- **Low Latency**: Average API response time < 50ms
- **Memory Optimized**: Go GC optimization, memory usage < 100MB
- **Database Connection Pool**: Connection reuse for maximum database performance
- **Reliability**:
- Comprehensive error handling with multi-language error messages
- Structured logging and monitoring
- Automatic database migrations
- Graceful shutdown and resource cleanup
## Installation
```bash
# Clone the repository
git clone <repository-url>
git clone https://github.com/cedar-v/license-manager.git
cd license-manager
# Build the application
# Local backend build & run (optional)
cd backend/cmd
go build -o license-manager
# Configure the application
cp config.example.yaml config.yaml
# Edit config.yaml with your settings
# Configuration (choose one)
# 1) Create config.yaml in the working directory (the app looks here first)
# cp ../configs/config.example.yaml ./config.yaml && edit it
# 2) Edit ../configs/config.yaml (the app will fall back to this path)
# Run the application
./license-manager
```
## Docker Deployment
```bash
# Build Docker image
docker build -t license-manager .
### Using Pre-built Images (Recommended)
# Run with Docker
docker run -p 8080:8080 -v $(pwd)/config.yaml:/app/config.yaml license-manager
For quick deployment without downloading source code, you can use pre-built GitHub Container Registry images:
For detailed deployment steps, please refer to: [GitHub Images Deployment Guide](docs/github-images-deploy.md)
### Build from Source
We provide ready-to-use Docker Compose files for development and production:
```bash
# Development (first run with --build is recommended)
docker compose up -d --build
# Production
docker compose -f docker-compose.prod.yml up -d
# Verify backend health
curl http://localhost:18888/health
```
## License
Notes
- Backend config is mounted by Compose:
- Dev: `backend/configs/config.dev.yaml` → `/app/backend/cmd/config.yaml`
- Prod: `backend/configs/config.prod.yaml` → `/app/backend/cmd/config.yaml`
- Frontend Nginx proxies `/api/` to `backend:18888` inside the Docker network.
- Health checks use `curl`; the backend image includes `curl`.
- For full details (reverse proxy, health checks, mounts, troubleshooting), see `README-Docker.md`.
This project is licensed under the GNU General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
## Open Source License
This project is licensed under the **GNU General Public License v3.0 (GPL-3.0)**.
### License Description
- **Free Use**: You are free to use, study, modify, and distribute this software
- **Open Source Requirement**: If you distribute modified versions, they must also be open source under GPL-3.0 license
- **Copyright Protection**: Derivative works using this software must retain the original copyright notice
- **No Warranty**: The software is provided "as is" without any express or implied warranty
### Commercial Use
- Commercial use is permitted, but derivative works must also be open source
- For proprietary licensing or commercial support, please contact the project maintainers
### Full License Text
For detailed license terms, please see the [LICENSE](LICENSE) file in the project root directory, or visit:
https://www.gnu.org/licenses/gpl-3.0.html
---
@@ -83,4 +200,21 @@ We welcome contributions! Please feel free to submit a Pull Request.
## Support
If you have any questions or need support, please open an issue.
### User Feedback & Community
We highly value user feedback and suggestions! Join our QQ group for discussions and communication:
<img src="docs/images/qrcode_1755081220153.jpg" alt="QQ Group QR Code" width="200" />
Scan the QR code or search for the QQ group number to join and connect with other users and developers to:
- Share usage experiences and best practices
- Ask questions and get help
- Participate in new feature discussions
- Provide feedback and improvement suggestions
### Issue Reporting
For technical issues or bug reports, please contact us through:
- Submit GitHub Issues (recommended)
- Provide feedback in the QQ group
- Send email to project maintainers
+121
View File
@@ -0,0 +1,121 @@
# 软件授权客户端示例程序
演示如何与许可证管理系统集成,包括硬件指纹采集、授权激活和心跳检测功能。
## 快速开始
### 编译
```bash
cd backend/cmd/client-demo
go build -o client-demo main.go
```
### 运行
```bash
# 使用命令行参数
./client-demo -server http://localhost:18888
# 或使用配置文件 client_config.json
./client-demo
```
## 授权码配置
将授权码保存到 `license_code/AUTH_CODE` 文件中,程序会自动读取。如果文件不存在或授权码为空,程序在需要激活时会退出并提示错误。
## 命令行参数
| 参数 | 说明 | 默认值 |
|------|------|--------|
| `-server` | 服务器地址 | http://localhost:18888 |
| `-version` | 软件版本 | 1.0.0 |
| `-interval` | 心跳间隔(秒) | 300 |
| `-activate-only` | 仅激活,不启动心跳 | false |
## 环境变量
| 变量名 | 说明 |
|--------|------|
| `LICENSE_ENCRYPTION_KEY` | 许可证文件加密密钥(默认使用服务器默认密钥,长度需 16/24/32 字节) |
## 工作流程
1. **启动时**
- 采集硬件指纹(MAC 地址、CPU、主机ID)
- 检查 `license_code/LICENSE` 文件是否存在
- 如果文件存在,解析并验证有效期(start_date 和 end_date)
2. **需要激活时**(文件不存在或过期)
- 从 `license_code/AUTH_CODE` 文件读取授权码
- 调用激活接口获取许可证
- 保存许可证到 `license_code/LICENSE`
- 再次验证许可证
3. **验证通过后**
- 启动心跳服务(每 300 秒或按服务器指定间隔)
- 自动接收并更新许可证文件
## 硬件指纹
程序自动采集以下信息生成硬件指纹:
- MAC 地址(第一个非虚拟网络接口)
- CPU 型号名称
- 主机 ID(系统唯一标识符)
格式(随意,只要能唯一标识设备即可,哈希值也行):`MAC:xx:xx:xx:xx:xx:xx,CPU:xxx,HOSTID:xxx`
## 许可证文件
许可证文件保存在 `license_code/LICENSE`,使用 AES-GCM 加密,包含:
- 许可证密钥和硬件指纹
- 有效期(开始/结束日期)
- 功能配置和使用限制
**重要**:许可证绑定硬件指纹,更换硬件需重新激活。
## 示例输出
**首次运行**(自动激活)
```
=== 软件授权客户端示例程序 ===
硬件指纹: MAC:00:11:22:33:44:55,CPU:Intel(R) Core(TM) i7-8700,HOSTID:abc123
许可证文件检查失败: 许可证文件不存在
尝试重新激活...
✓ 激活成功!许可证文件已保存到 license_code/LICENSE
✓ 激活后验证通过
[心跳] 启动心跳服务...
✓ 心跳成功 [14:30:00] - 状态: active
```
**后续运行**(使用本地许可证)
```
=== 软件授权客户端示例程序 ===
硬件指纹: MAC:00:11:22:33:44:55,CPU:Intel(R) Core(TM) i7-8700,HOSTID:abc123
✓ 许可证验证通过
[心跳] 启动心跳服务...
✓ 心跳成功 [14:30:00] - 状态: active
```
## 注意事项
- **验证优先级**:优先检查本地许可证文件,文件不存在或过期时才重新激活
- **错误处理**:许可证验证失败会退出程序;心跳失败仅记录日志
- **授权码**:激活时需要授权码,请将授权码保存到 `license_code/AUTH_CODE` 文件
## 故障排查
**激活失败**
- 检查服务器地址和授权码是否正确
- 确认 `license_code/AUTH_CODE` 文件存在且包含有效的授权码
- 查看服务器日志了解详细错误
**心跳失败**
- 检查网络连接
- 确认许可证密钥和硬件指纹是否正确
**硬件指纹变更**
- 更换硬件需重新激活
- 联系管理员释放旧的激活记录
@@ -0,0 +1,9 @@
{
"server_url": "http://localhost:18888",
"authorization_code": "LIC-B597-EFr0tbfAooQF-uUAA",
"software_version": "1.0.0",
"heartbeat_interval": 300,
"license_key": "LIC-DEVICE-68DF8AFEF41CA26F248D7D55",
"config_updated_at": "2025-11-01T17:54:30+08:00",
"license_file": "eyJhbGdvcml0aG0iOiJSU0EtUFNTLVNIQTI1NiIsImRhdGEiOiJ7XCJhY3RpdmF0ZWRfYXRcIjpcIjIwMjUtMTEtMDFUMTc6NTQ6MjguMTI4MTc5NCswODowMFwiLFwiYXV0aG9yaXphdGlvbl9jb2RlXCI6XCJMSUMtQjU5Ny1FRnIwdGJmQW9vUUYtdVVBQVwiLFwiYXV0aG9yaXphdGlvbl9jb2RlX2lkXCI6XCI4ZjZlNzViMS0xMTViLTRhNWUtYjk1ZC0yNjczOTcwZDNmYmFcIixcImNvbmZpZ191cGRhdGVkX2F0XCI6bnVsbCxcImN1c3RvbV9wYXJhbWV0ZXJzXCI6e30sXCJkZXBsb3ltZW50X3R5cGVcIjpcInN0YW5kYWxvbmVcIixcImVuZF9kYXRlXCI6XCIzMDI1LTAzLTAzVDIzOjU5OjU5KzA4OjAwXCIsXCJmZWF0dXJlX2NvbmZpZ1wiOnt9LFwiZ2VuZXJhdGVkX2F0XCI6XCIyMDI1LTExLTAxVDE3OjU0OjMwKzA4OjAwXCIsXCJoYXJkd2FyZV9maW5nZXJwcmludFwiOlwiTUFDOjVlOmEzOjIyOjIyOmVmOmRiLENQVTpBTUQgUnl6ZW4gNSA5NjAwWCA2LUNvcmUgUHJvY2Vzc29yLEhPU1RJRDphMzJlOGViNC00ODg1LTRkMmItYTA3My0yYmM2NDVkZWU0ZTRcIixcImxpY2Vuc2Vfa2V5XCI6XCJMSUMtREVWSUNFLTY4REY4QUZFRjQxQ0EyNkYyNDhEN0Q1NVwiLFwibWF4X2FjdGl2YXRpb25zXCI6NSxcInN0YXJ0X2RhdGVcIjpcIjIwMjUtMTEtMDFUMDA6MDA6MDArMDg6MDBcIixcInN0YXR1c1wiOlwiYWN0aXZlXCIsXCJ1c2FnZV9saW1pdHNcIjp7fX0iLCJzaWduYXR1cmUiOiJoVEc2dUZ3UWtmRnNEam9sSVlFK3NOZ3JVM3hncDJPeHJOd3dSa2FLSmpGSjlLbXR2a1J1N2ZZWUVDUFozc0RLT094RlFYdmM4MktWd0dBcTREZ3A5TXBmajVLVlAwTnhjL0RBOCtKVm9oN1BXTERGZFhmMDd2bUdHZFF4YzBSRy91d2hWN3MyOUwzWW9WdlZGdnJxbUhuNGdBQ3B3eUVnL2Y3SC9HT0FCTTNGdWErekRkelR6QUNUbkdEdG1GbFAwUFZ1SURCRVlGRTRvZmRZZDBObDZMY1E5Z0wrb3pORHh0ZVNpdzNuZE0xelJiS3d4SUxOYVFKWnRaellVT1djem05RHg2bUNYMGlFeEtHTlA0NG9Pem5xNjRvclU5M2tGYzRLOW4wK2svL1JpblZMSWE1VE0vVUN2SmZPNzRkUmxGL2Nta3RRUFp6Tk5jbUJ4bWZoRXc9PSJ9"
}
@@ -0,0 +1 @@
LIC-B597-EFr0tbfAooQF-uUAA
@@ -0,0 +1 @@
eyJhbGdvcml0aG0iOiJSU0EtUFNTLVNIQTI1NiIsImRhdGEiOiJ7XCJhY3RpdmF0ZWRfYXRcIjpcIjIwMjUtMTEtMDFUMTc6NTQ6MjguMTI4MTc5NCswODowMFwiLFwiYXV0aG9yaXphdGlvbl9jb2RlXCI6XCJMSUMtQjU5Ny1FRnIwdGJmQW9vUUYtdVVBQVwiLFwiYXV0aG9yaXphdGlvbl9jb2RlX2lkXCI6XCI4ZjZlNzViMS0xMTViLTRhNWUtYjk1ZC0yNjczOTcwZDNmYmFcIixcImNvbmZpZ191cGRhdGVkX2F0XCI6bnVsbCxcImN1c3RvbV9wYXJhbWV0ZXJzXCI6e30sXCJkZXBsb3ltZW50X3R5cGVcIjpcInN0YW5kYWxvbmVcIixcImVuZF9kYXRlXCI6XCIzMDI1LTAzLTAzVDIzOjU5OjU5KzA4OjAwXCIsXCJmZWF0dXJlX2NvbmZpZ1wiOnt9LFwiZ2VuZXJhdGVkX2F0XCI6XCIyMDI1LTExLTAxVDE3OjU0OjMwKzA4OjAwXCIsXCJoYXJkd2FyZV9maW5nZXJwcmludFwiOlwiTUFDOjVlOmEzOjIyOjIyOmVmOmRiLENQVTpBTUQgUnl6ZW4gNSA5NjAwWCA2LUNvcmUgUHJvY2Vzc29yLEhPU1RJRDphMzJlOGViNC00ODg1LTRkMmItYTA3My0yYmM2NDVkZWU0ZTRcIixcImxpY2Vuc2Vfa2V5XCI6XCJMSUMtREVWSUNFLTY4REY4QUZFRjQxQ0EyNkYyNDhEN0Q1NVwiLFwibWF4X2FjdGl2YXRpb25zXCI6NSxcInN0YXJ0X2RhdGVcIjpcIjIwMjUtMTEtMDFUMDA6MDA6MDArMDg6MDBcIixcInN0YXR1c1wiOlwiYWN0aXZlXCIsXCJ1c2FnZV9saW1pdHNcIjp7fX0iLCJzaWduYXR1cmUiOiJoVEc2dUZ3UWtmRnNEam9sSVlFK3NOZ3JVM3hncDJPeHJOd3dSa2FLSmpGSjlLbXR2a1J1N2ZZWUVDUFozc0RLT094RlFYdmM4MktWd0dBcTREZ3A5TXBmajVLVlAwTnhjL0RBOCtKVm9oN1BXTERGZFhmMDd2bUdHZFF4YzBSRy91d2hWN3MyOUwzWW9WdlZGdnJxbUhuNGdBQ3B3eUVnL2Y3SC9HT0FCTTNGdWErekRkelR6QUNUbkdEdG1GbFAwUFZ1SURCRVlGRTRvZmRZZDBObDZMY1E5Z0wrb3pORHh0ZVNpdzNuZE0xelJiS3d4SUxOYVFKWnRaellVT1djem05RHg2bUNYMGlFeEtHTlA0NG9Pem5xNjRvclU5M2tGYzRLOW4wK2svL1JpblZMSWE1VE0vVUN2SmZPNzRkUmxGL2Nta3RRUFp6Tk5jbUJ4bWZoRXc9PSJ9
+682
View File
@@ -0,0 +1,682 @@
package main
import (
"bytes"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/shirou/gopsutil/v3/cpu"
"github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/mem"
)
// ClientConfig 客户端配置
type ClientConfig struct {
ServerURL string `json:"server_url"` // 服务器地址
AuthorizationCode string `json:"authorization_code"` // 授权码
SoftwareVersion string `json:"software_version"` // 软件版本
HeartbeatInterval int `json:"heartbeat_interval"` // 心跳间隔(秒),默认300
LicenseKey string `json:"license_key"` // 许可证密钥(激活后保存)
ConfigUpdatedAt string `json:"config_updated_at"` // 配置更新时间
LicenseFile string `json:"license_file"` // 许可证文件内容
}
// ActivateRequest 激活请求
type ActivateRequest struct {
AuthorizationCode string `json:"authorization_code"`
HardwareFingerprint string `json:"hardware_fingerprint"`
DeviceInfo map[string]interface{} `json:"device_info,omitempty"`
SoftwareVersion *string `json:"software_version,omitempty"`
}
// ActivateResponse 激活响应
type ActivateResponse struct {
Code string `json:"code"`
Message string `json:"message"`
Data struct {
LicenseKey string `json:"license_key"`
LicenseFile string `json:"license_file"`
HeartbeatInterval int `json:"heartbeat_interval"`
} `json:"data"`
}
// HeartbeatRequest 心跳请求
type HeartbeatRequest struct {
LicenseKey string `json:"license_key"`
HardwareFingerprint string `json:"hardware_fingerprint"`
ConfigUpdatedAt *string `json:"config_updated_at,omitempty"`
UsageData map[string]interface{} `json:"usage_data,omitempty"`
SoftwareVersion *string `json:"software_version,omitempty"`
}
// HeartbeatResponse 心跳响应
type HeartbeatResponse struct {
Code string `json:"code"`
Message string `json:"message"`
Data struct {
Status string `json:"status"`
ConfigUpdated bool `json:"config_updated"`
LicenseFile *string `json:"license_file,omitempty"`
HeartbeatInterval int `json:"heartbeat_interval"`
} `json:"data"`
}
// ErrorResponse 错误响应
type ErrorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
Timestamp string `json:"timestamp"`
}
// LicenseFileData 许可证文件数据结构
type LicenseFileData struct {
LicenseKey string `json:"license_key"`
AuthorizationCodeID string `json:"authorization_code_id"`
HardwareFingerprint string `json:"hardware_fingerprint"`
Status string `json:"status"`
ActivatedAt *string `json:"activated_at"`
ConfigUpdatedAt *string `json:"config_updated_at"`
GeneratedAt string `json:"generated_at"`
AuthorizationCode *string `json:"authorization_code"`
StartDate *string `json:"start_date"`
EndDate *string `json:"end_date"`
DeploymentType *string `json:"deployment_type"`
MaxActivations *int `json:"max_activations"`
FeatureConfig map[string]interface{} `json:"feature_config,omitempty"`
UsageLimits map[string]interface{} `json:"usage_limits,omitempty"`
CustomParameters map[string]interface{} `json:"custom_parameters,omitempty"`
}
const (
configFile = "client_config.json"
licenseDir = "license_code"
licenseFileName = "LICENSE"
)
var (
config ClientConfig
)
func main() {
var (
serverURL = flag.String("server", "http://localhost:18888", "服务器地址 (例如: http://localhost:18888)")
softwareVersion = flag.String("version", "1.0.0", "软件版本")
interval = flag.Int("interval", 300, "心跳间隔(秒)")
activateOnly = flag.Bool("activate-only", false, "仅执行激活,不启动心跳")
)
flag.Parse()
// 加载配置
if err := loadConfig(); err != nil {
log.Printf("加载配置失败,使用命令行参数: %v", err)
}
// 命令行参数优先
if *serverURL != "" {
config.ServerURL = *serverURL
}
// 从 license_code/AUTH_CODE 文件读取授权码
authCodeFromFile, err := readAuthCodeFromFile()
if err == nil && authCodeFromFile != "" {
config.AuthorizationCode = strings.TrimSpace(authCodeFromFile)
}
if *softwareVersion != "" {
config.SoftwareVersion = *softwareVersion
}
if *interval > 0 {
config.HeartbeatInterval = *interval
}
if config.HeartbeatInterval <= 0 {
config.HeartbeatInterval = 300 // 默认5分钟
}
// 验证必要参数
if config.ServerURL == "" {
log.Fatal("请提供服务器地址 (-server 或配置文件)")
}
log.Println("=== 软件授权客户端示例程序 ===")
log.Printf("服务器地址: %s", config.ServerURL)
log.Printf("软件版本: %s", config.SoftwareVersion)
// 初始化RSA公钥(从当前目录的 rsa_public_key.pem 加载)
if err := initRSAPublicKey(); err != nil {
log.Fatalf("初始化RSA公钥失败: %v", err)
}
log.Println("✓ RSA公钥加载成功")
// 采集硬件指纹
hardwareFingerprint, deviceInfo := collectHardwareInfo()
log.Printf("硬件指纹: %s", hardwareFingerprint)
log.Printf("设备信息: CPU=%s, Memory=%s, OS=%s",
deviceInfo["cpu"], deviceInfo["memory"], deviceInfo["os"])
// ===== 新的验证流程 =====
// 1. 检查 license_code/LICENSE 文件是否存在
licenseFilePath := filepath.Join(licenseDir, licenseFileName)
licenseData, err := checkAndParseLicenseFile(licenseFilePath)
if err != nil {
log.Printf("许可证文件检查失败: %v", err)
log.Println("尝试重新激活...")
licenseData = nil // 标记需要重新激活
}
// 2. 如果许可证文件存在,验证时间
needActivation := false
if licenseData != nil {
isValid, err := validateLicenseTime(licenseData)
if err != nil {
log.Printf("许可证时间验证失败: %v", err)
needActivation = true
} else if !isValid {
log.Println("许可证已过期或未生效,需要重新激活")
needActivation = true
} else {
log.Println("✓ 许可证验证通过")
// 从许可证文件加载许可证密钥
config.LicenseKey = licenseData.LicenseKey
config.LicenseFile = "" // 可以从文件读取
}
} else {
needActivation = true
}
// 3. 如果需要激活,使用AUTH_CODE重新获取许可证
if needActivation {
if config.AuthorizationCode == "" {
log.Fatal("需要授权码进行激活,请将授权码保存到 license_code/AUTH_CODE 文件")
}
log.Println("\n[激活] 开始激活授权...")
if err := activateLicense(hardwareFingerprint, deviceInfo); err != nil {
log.Fatalf("激活失败: %v", err)
}
log.Println("✓ 激活成功!")
log.Printf("许可证密钥: %s", config.LicenseKey)
log.Printf("心跳间隔: %d 秒", config.HeartbeatInterval)
// 保存许可证文件到 license_code/LICENSE
if err := saveLicenseFile(licenseFilePath); err != nil {
log.Printf("保存许可证文件失败: %v", err)
} else {
log.Printf("许可证文件已保存到 %s", licenseFilePath)
}
// 保存配置
if err := saveConfig(); err != nil {
log.Printf("保存配置失败: %v", err)
} else {
log.Println("配置已保存到 " + configFile)
}
// 再次验证许可证文件
licenseData, err = checkAndParseLicenseFile(licenseFilePath)
if err != nil {
log.Fatalf("激活后验证许可证文件失败: %v", err)
}
isValid, err := validateLicenseTime(licenseData)
if err != nil || !isValid {
log.Fatalf("激活后验证许可证时间失败: %v", err)
}
log.Println("✓ 激活后验证通过")
}
if *activateOnly {
log.Println("激活完成,退出程序")
return
}
// 4. 验证通过,启动心跳服务
log.Println("\n[心跳] 启动心跳服务...")
startHeartbeat(hardwareFingerprint, deviceInfo)
}
// collectHardwareInfo 采集硬件信息
func collectHardwareInfo() (string, map[string]interface{}) {
var parts []string
// 1. 获取 MAC 地址
if macs, err := getMACAddresses(); err == nil && len(macs) > 0 {
parts = append(parts, fmt.Sprintf("MAC:%s", macs[0]))
}
// 2. 获取 CPU 信息
if cpuInfo, err := cpu.Info(); err == nil && len(cpuInfo) > 0 {
cpuModel := strings.TrimSpace(cpuInfo[0].ModelName)
if cpuModel != "" {
parts = append(parts, fmt.Sprintf("CPU:%s", cpuModel))
}
}
// 3. 获取主板序列号(Windows)或机器ID(Linux)
if hostInfo, err := host.Info(); err == nil {
if hostInfo.HostID != "" {
parts = append(parts, fmt.Sprintf("HOSTID:%s", hostInfo.HostID))
}
}
// 4. 如果没有足够信息,使用操作系统信息
if len(parts) == 0 {
parts = append(parts, fmt.Sprintf("OS:%s-%s", runtime.GOOS, runtime.GOARCH))
}
hardwareFingerprint := strings.Join(parts, ",")
// 收集详细的设备信息
deviceInfo := make(map[string]interface{})
// CPU 信息
if cpuInfo, err := cpu.Info(); err == nil && len(cpuInfo) > 0 {
deviceInfo["cpu"] = cpuInfo[0].ModelName
deviceInfo["cpu_cores"] = runtime.NumCPU()
}
// 内存信息
if memInfo, err := mem.VirtualMemory(); err == nil {
deviceInfo["memory"] = fmt.Sprintf("%d GB", memInfo.Total/(1024*1024*1024))
}
// 操作系统信息
if hostInfo, err := host.Info(); err == nil {
deviceInfo["os"] = fmt.Sprintf("%s %s", hostInfo.Platform, hostInfo.PlatformVersion)
}
deviceInfo["arch"] = runtime.GOARCH
return hardwareFingerprint, deviceInfo
}
// getMACAddresses 获取 MAC 地址
func getMACAddresses() ([]string, error) {
var macs []string
interfaces, err := net.Interfaces()
if err != nil {
return nil, err
}
for _, iface := range interfaces {
// 跳过虚拟接口和回环接口
if iface.Flags&net.FlagLoopback != 0 ||
iface.Flags&net.FlagUp == 0 ||
strings.HasPrefix(iface.Name, "veth") ||
strings.HasPrefix(iface.Name, "docker") {
continue
}
mac := iface.HardwareAddr.String()
if mac != "" {
macs = append(macs, mac)
}
}
return macs, nil
}
// activateLicense 激活授权
func activateLicense(hardwareFingerprint string, deviceInfo map[string]interface{}) error {
req := ActivateRequest{
AuthorizationCode: config.AuthorizationCode,
HardwareFingerprint: hardwareFingerprint,
DeviceInfo: deviceInfo,
}
if config.SoftwareVersion != "" {
req.SoftwareVersion = &config.SoftwareVersion
}
jsonData, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("序列化请求失败: %w", err)
}
url := fmt.Sprintf("%s/api/v1/activate", config.ServerURL)
log.Printf("POST %s", url)
log.Printf("请求体: %s", string(jsonData))
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("读取响应失败: %w", err)
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil {
return fmt.Errorf("激活失败 [%s]: %s", errResp.Code, errResp.Message)
}
return fmt.Errorf("激活失败 [HTTP %d]: %s", resp.StatusCode, string(body))
}
var activateResp ActivateResponse
if err := json.Unmarshal(body, &activateResp); err != nil {
return fmt.Errorf("解析响应失败: %w", err)
}
if activateResp.Code != "000000" {
return fmt.Errorf("激活失败 [%s]: %s", activateResp.Code, activateResp.Message)
}
// 更新配置
config.LicenseKey = activateResp.Data.LicenseKey
config.LicenseFile = activateResp.Data.LicenseFile
if activateResp.Data.HeartbeatInterval > 0 {
config.HeartbeatInterval = activateResp.Data.HeartbeatInterval
}
config.ConfigUpdatedAt = time.Now().Format(time.RFC3339)
return nil
}
// startHeartbeat 启动心跳服务
func startHeartbeat(hardwareFingerprint string, deviceInfo map[string]interface{}) {
ticker := time.NewTicker(time.Duration(config.HeartbeatInterval) * time.Second)
defer ticker.Stop()
// 立即发送一次心跳
log.Println("发送初始心跳...")
sendHeartbeat(hardwareFingerprint, deviceInfo)
// 定期发送心跳
for range ticker.C {
sendHeartbeat(hardwareFingerprint, deviceInfo)
}
}
// sendHeartbeat 发送心跳
func sendHeartbeat(hardwareFingerprint string, deviceInfo map[string]interface{}) {
req := HeartbeatRequest{
LicenseKey: config.LicenseKey,
HardwareFingerprint: hardwareFingerprint,
}
if config.ConfigUpdatedAt != "" {
req.ConfigUpdatedAt = &config.ConfigUpdatedAt
}
// 模拟使用数据
req.UsageData = map[string]interface{}{
"active_users": 50,
"api_calls_today": 5000,
"cpu_usage": 25.5,
"memory_usage": 45.2,
}
if config.SoftwareVersion != "" {
req.SoftwareVersion = &config.SoftwareVersion
}
jsonData, err := json.Marshal(req)
if err != nil {
log.Printf("✗ 心跳失败: 序列化请求失败: %v", err)
return
}
// 打印完整的请求信息
reqJSON, _ := json.MarshalIndent(req, "", " ")
log.Printf("心跳请求信息:\n%s", string(reqJSON))
url := fmt.Sprintf("%s/api/v1/heartbeat", config.ServerURL)
resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
log.Printf("✗ 心跳失败: 请求失败: %v", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("✗ 心跳失败: 读取响应失败: %v", err)
return
}
if resp.StatusCode != http.StatusOK {
var errResp ErrorResponse
if err := json.Unmarshal(body, &errResp); err == nil {
log.Printf("✗ 心跳失败 [%s]: %s", errResp.Code, errResp.Message)
} else {
log.Printf("✗ 心跳失败 [HTTP %d]: %s", resp.StatusCode, string(body))
}
return
}
var heartbeatResp HeartbeatResponse
if err := json.Unmarshal(body, &heartbeatResp); err != nil {
log.Printf("✗ 心跳失败: 解析响应失败: %v", err)
return
}
if heartbeatResp.Code != "000000" {
log.Printf("✗ 心跳失败 [%s]: %s", heartbeatResp.Code, heartbeatResp.Message)
return
}
// 打印完整的响应信息
respJSON, _ := json.MarshalIndent(heartbeatResp, "", " ")
log.Printf("心跳返回信息:\n%s", string(respJSON))
// 更新配置更新时间
if heartbeatResp.Data.ConfigUpdated && heartbeatResp.Data.LicenseFile != nil {
log.Println("✓ 收到配置更新,正在更新许可证文件...")
config.LicenseFile = *heartbeatResp.Data.LicenseFile
config.ConfigUpdatedAt = time.Now().Format(time.RFC3339)
// 保存许可证文件到 license_code/LICENSE
licenseFilePath := filepath.Join(licenseDir, licenseFileName)
if err := saveLicenseFile(licenseFilePath); err != nil {
log.Printf("保存许可证文件失败: %v", err)
} else {
log.Printf("许可证文件已更新到 %s", licenseFilePath)
}
// 保存配置
if err := saveConfig(); err != nil {
log.Printf("保存配置失败: %v", err)
}
}
log.Printf("✓ 心跳成功 [%s] - 状态: %s, 配置更新: %v, 间隔: %d秒",
time.Now().Format("15:04:05"),
heartbeatResp.Data.Status,
heartbeatResp.Data.ConfigUpdated,
heartbeatResp.Data.HeartbeatInterval)
// 如果服务器返回了不同的心跳间隔,更新配置
if heartbeatResp.Data.HeartbeatInterval > 0 &&
heartbeatResp.Data.HeartbeatInterval != config.HeartbeatInterval {
log.Printf("更新心跳间隔: %d 秒 -> %d 秒",
config.HeartbeatInterval, heartbeatResp.Data.HeartbeatInterval)
config.HeartbeatInterval = heartbeatResp.Data.HeartbeatInterval
}
}
// loadConfig 加载配置
func loadConfig() error {
data, err := os.ReadFile(configFile)
if err != nil {
return err
}
return json.Unmarshal(data, &config)
}
// saveConfig 保存配置
func saveConfig() error {
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
return os.WriteFile(configFile, data, 0644)
}
// checkAndParseLicenseFile 检查并解析许可证文件
func checkAndParseLicenseFile(filePath string) (*LicenseFileData, error) {
// 检查文件是否存在
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return nil, fmt.Errorf("许可证文件不存在: %s", filePath)
}
// 读取文件内容
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("读取许可证文件失败: %w", err)
}
// 尝试解密许可证文件
decryptedData, err := decryptLicenseFile(data)
if err != nil {
return nil, fmt.Errorf("解密许可证文件失败: %w", err)
}
// 解析JSON
var licenseData LicenseFileData
if err := json.Unmarshal(decryptedData, &licenseData); err != nil {
return nil, fmt.Errorf("解析许可证文件JSON失败: %w", err)
}
// 打印解析后的许可证信息
log.Println("========== 解析后的许可证信息 ==========")
licenseJSON, _ := json.MarshalIndent(licenseData, "", " ")
log.Printf("%s\n", string(licenseJSON))
log.Println("========================================")
return &licenseData, nil
}
// validateLicenseTime 验证许可证时间
func validateLicenseTime(licenseData *LicenseFileData) (bool, error) {
now := time.Now()
// 检查开始日期
if licenseData.StartDate != nil && *licenseData.StartDate != "" {
startDate, err := time.Parse(time.RFC3339, *licenseData.StartDate)
if err != nil {
// 尝试其他时间格式
startDate, err = time.Parse("2006-01-02T15:04:05Z07:00", *licenseData.StartDate)
if err != nil {
startDate, err = time.Parse("2006-01-02 15:04:05", *licenseData.StartDate)
if err != nil {
return false, fmt.Errorf("解析开始日期失败: %w", err)
}
}
}
if now.Before(startDate) {
return false, fmt.Errorf("许可证尚未生效,生效日期: %s", startDate.Format("2006-01-02 15:04:05"))
}
}
// 检查结束日期
if licenseData.EndDate != nil && *licenseData.EndDate != "" {
endDate, err := time.Parse(time.RFC3339, *licenseData.EndDate)
if err != nil {
// 尝试其他时间格式
endDate, err = time.Parse("2006-01-02T15:04:05Z07:00", *licenseData.EndDate)
if err != nil {
endDate, err = time.Parse("2006-01-02 15:04:05", *licenseData.EndDate)
if err != nil {
return false, fmt.Errorf("解析结束日期失败: %w", err)
}
}
}
if now.After(endDate) {
return false, fmt.Errorf("许可证已过期,过期日期: %s", endDate.Format("2006-01-02 15:04:05"))
}
startDateStr := ""
if licenseData.StartDate != nil {
startDateStr = *licenseData.StartDate
}
endDateStr := ""
if licenseData.EndDate != nil {
endDateStr = *licenseData.EndDate
}
log.Printf("许可证有效期: %s - %s", startDateStr, endDateStr)
} else {
return false, fmt.Errorf("许可证文件缺少结束日期")
}
// 检查状态
if licenseData.Status == "revoked" {
return false, fmt.Errorf("许可证已被撤销")
}
return true, nil
}
// LicenseFileWithSignature RSA签名后的许可证文件结构
type LicenseFileWithSignature struct {
Data string `json:"data"` // 原始数据(JSON字符串)
Signature string `json:"signature"` // 数字签名
Algorithm string `json:"algorithm"` // 签名算法
}
// decryptLicenseFile 验证RSA签名的许可证文件
func decryptLicenseFile(encryptedData []byte) ([]byte, error) {
// Base64解码
decoded, err := base64.StdEncoding.DecodeString(string(encryptedData))
if err != nil {
return nil, fmt.Errorf("base64解码失败: %w", err)
}
// 解析签名后的许可证文件
var signedLicense LicenseFileWithSignature
if err := json.Unmarshal(decoded, &signedLicense); err != nil {
return nil, fmt.Errorf("解析许可证文件失败: %w", err)
}
// 检查签名算法
if signedLicense.Algorithm != "RSA-PSS-SHA256" {
return nil, fmt.Errorf("不支持的签名算法: %s", signedLicense.Algorithm)
}
// 验证签名
dataBytes := []byte(signedLicense.Data)
if err := verifyRSASignature(dataBytes, signedLicense.Signature); err != nil {
return nil, fmt.Errorf("签名验证失败: %w", err)
}
// 返回原始数据
return dataBytes, nil
}
// saveLicenseFile 保存许可证文件到 license_code/LICENSE
func saveLicenseFile(filePath string) error {
// 确保目录存在
dir := filepath.Dir(filePath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("创建目录失败: %w", err)
}
// 如果config.LicenseFile不为空,直接保存
if config.LicenseFile != "" {
// 写入文件(已经是base64编码的加密数据)
return os.WriteFile(filePath, []byte(config.LicenseFile), 0644)
}
// 如果LicenseFile为空,但LicenseKey存在,可能需要重新生成
// 这里我们假设LicenseFile已经在激活时设置
return fmt.Errorf("许可证文件内容为空")
}
// readAuthCodeFromFile 从 license_code/AUTH_CODE 文件读取授权码
func readAuthCodeFromFile() (string, error) {
authCodePath := filepath.Join(licenseDir, "AUTH_CODE")
data, err := os.ReadFile(authCodePath)
if err != nil {
return "", fmt.Errorf("读取授权码文件失败: %w", err)
}
return strings.TrimSpace(string(data)), nil
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"crypto"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"log"
"os"
"path/filepath"
)
// 默认RSA公钥(应该从配置文件或环境变量加载)
// 在生产环境中,这个公钥应该编译到程序中,或者从配置文件读取
var defaultRSAPublicKeyPEM = `
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyour-public-key-here
-----END PUBLIC KEY-----
`
var rsaPublicKey *rsa.PublicKey
// initRSAPublicKey 初始化RSA公钥(从文件或环境变量加载)
func initRSAPublicKey() error {
if rsaPublicKey != nil {
return nil
}
var pubKeyPEM string
var err error
// 1. 首先尝试从当前目录下的 rsa_public_key.pem 文件加载
defaultPubKeyPath := "rsa_public_key.pem"
if _, err := os.Stat(defaultPubKeyPath); err == nil {
data, err := os.ReadFile(defaultPubKeyPath)
if err == nil {
pubKeyPEM = string(data)
// 获取绝对路径用于日志
if absPath, err := filepath.Abs(defaultPubKeyPath); err == nil {
log.Printf("从当前目录加载RSA公钥: %s", absPath)
} else {
log.Printf("从当前目录加载RSA公钥: %s", defaultPubKeyPath)
}
}
}
// 2. 如果当前目录没有,尝试从环境变量获取公钥内容
if pubKeyPEM == "" {
pubKeyPEM = os.Getenv("LICENSE_RSA_PUBLIC_KEY")
if pubKeyPEM != "" {
log.Println("从环境变量 LICENSE_RSA_PUBLIC_KEY 加载RSA公钥")
}
}
// 3. 如果还是没有,尝试从环境变量指定的文件路径加载
if pubKeyPEM == "" {
pubKeyPath := os.Getenv("LICENSE_RSA_PUBLIC_KEY_PATH")
if pubKeyPath != "" {
data, err := os.ReadFile(pubKeyPath)
if err == nil {
pubKeyPEM = string(data)
log.Printf("从环境变量指定的路径加载RSA公钥: %s", pubKeyPath)
}
}
}
// 如果还是没有找到公钥,返回错误
if pubKeyPEM == "" {
// 获取当前工作目录用于错误提示
cwd, _ := os.Getwd()
return fmt.Errorf("RSA公钥未配置,请将公钥文件 rsa_public_key.pem 放在当前目录 (%s) 下,或设置 LICENSE_RSA_PUBLIC_KEY 环境变量或 LICENSE_RSA_PUBLIC_KEY_PATH 环境变量", cwd)
}
key, err := loadRSAPublicKeyFromString(pubKeyPEM)
if err != nil {
return fmt.Errorf("加载RSA公钥失败: %w", err)
}
rsaPublicKey = key
return nil
}
// loadRSAPublicKeyFromString 从PEM格式字符串加载RSA公钥
func loadRSAPublicKeyFromString(pubKeyStr string) (*rsa.PublicKey, error) {
block, _ := pem.Decode([]byte(pubKeyStr))
if block == nil {
return nil, errors.New("无法解析PEM格式的公钥")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("解析公钥失败: %w", err)
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, errors.New("不是有效的RSA公钥")
}
return rsaPub, nil
}
// verifyRSASignature 使用RSA公钥验证数字签名
func verifyRSASignature(data []byte, signatureBase64 string) error {
if rsaPublicKey == nil {
if err := initRSAPublicKey(); err != nil {
return fmt.Errorf("RSA公钥未初始化: %w", err)
}
}
// 解码base64签名
signature, err := base64.StdEncoding.DecodeString(signatureBase64)
if err != nil {
return fmt.Errorf("解码签名失败: %w", err)
}
// 计算数据的SHA256哈希
hashed := sha256.Sum256(data)
// 验证签名
err = rsa.VerifyPSS(rsaPublicKey, crypto.SHA256, hashed[:], signature, &rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthEqualsHash,
Hash: crypto.SHA256,
})
if err != nil {
return fmt.Errorf("签名验证失败: %w", err)
}
return nil
}
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5hGz3hws+L2MUW7CaRQI
t7Vxk0KAdPBUuNdkJcs2kpgkDmiT/pHf+9WXfRVdKD/Eu/t7wckQfzziddXTbeCx
gN/5bKSeLTPXT0VvvSkfB5FUUQABHFWO4fiBlfP/NdFy++qKgCUF1wGg+jSsrhAw
6oMDI7MHVRfRZTe3rTM3T0qi3Ed6qaoIM9INQ87FcqPVMyQ/KXbe4Dz5yVkLY5iY
j8ZJP7iU2vKYvWIG9TNv0FK/dXQk7syMGBSKmIncCe+UaflAajbDGXq52gIeTyhO
BumOLXXBdlwgCVtejBTqDmlQpiJYADKiZLQfx0z1hYbbOPBPB3djXCJJmQk69sso
ZQIDAQAB
-----END PUBLIC KEY-----
+30
View File
@@ -0,0 +1,30 @@
package main
import (
"flag"
"fmt"
"license-manager/pkg/utils"
)
func main() {
var (
privateKeyPath = flag.String("private", "../../configs/rsa_private_key.pem", "私钥文件路径")
publicKeyPath = flag.String("public", "../../configs/rsa_public_key.pem", "公钥文件路径")
keySize = flag.Int("size", 2048, "密钥大小(2048或4096)")
)
flag.Parse()
fmt.Printf("正在生成 RSA 密钥对...\n")
fmt.Printf("密钥大小: %d 位\n", *keySize)
fmt.Printf("私钥路径: %s\n", *privateKeyPath)
fmt.Printf("公钥路径: %s\n", *publicKeyPath)
if err := utils.GenerateRSAKeyPair(*privateKeyPath, *publicKeyPath, *keySize); err != nil {
fmt.Printf("生成失败: %v\n", err)
return
}
fmt.Printf("✓ 密钥对生成成功!\n")
fmt.Printf("\n请将私钥文件保存在安全的位置(服务器端)\n")
fmt.Printf("请将公钥提供给客户端开发人员(可以编译到客户端程序中)\n")
}
+1
View File
@@ -0,0 +1 @@
go run .
+63 -6
View File
@@ -3,11 +3,16 @@ package main
import (
"fmt"
"log"
"os"
"time"
"github.com/gin-gonic/gin"
"license-manager/internal/api/routes"
"license-manager/internal/config"
"license-manager/internal/database"
"license-manager/pkg/i18n"
"license-manager/pkg/logger"
"github.com/gin-gonic/gin"
)
// @title License Manager API
@@ -22,8 +27,8 @@ import (
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
// @host localhost:18888
// @BasePath /
// @schemes http https
// @securityDefinitions.apikey BearerAuth
// @in header
@@ -32,7 +37,12 @@ import (
func main() {
// 加载配置
if err := config.Load(""); err != nil {
configPath := "config.yaml" // Docker 环境默认路径
if _, err := os.Stat(configPath); os.IsNotExist(err) {
configPath = "../configs/config.yaml" // 本地开发环境
}
if err := config.Load(configPath); err != nil {
log.Fatalf("Failed to load config: %v", err)
}
@@ -43,7 +53,54 @@ func main() {
log.Info("启动 License Manager 服务器...")
cfg := config.GetConfig()
// 初始化数据库(失败时重试)
const (
maxDBRetries = 200
retryInterval = 3 * time.Second
)
var dbErr error
for attempt := 1; attempt <= maxDBRetries; attempt++ {
dbErr = database.InitDatabase(&cfg.Database)
if dbErr == nil {
break
}
if attempt == maxDBRetries {
log.Fatalf("数据库初始化失败: %v", dbErr)
}
log.Warnf("数据库初始化失败(第%d次): %v,将在 %v 后重试...", attempt, dbErr, retryInterval)
time.Sleep(retryInterval)
}
defer database.Close()
// 运行数据库迁移
if err := database.RunMigrationIfEnabled(); err != nil {
log.Fatalf("数据库迁移失败: %v", err)
}
// 初始化多语言支持
if cfg.I18n.Enable {
if err := i18n.InitGlobalManager(cfg.I18n.ConfigPath, cfg.I18n.DefaultLang); err != nil {
log.Fatalf("多语言初始化失败: %v", err)
}
// 预加载支持的语言
for _, lang := range cfg.I18n.SupportLangs {
if err := i18n.LoadLanguage(lang); err != nil {
log.Warnf("加载语言包 %s 失败: %v", lang, err)
} else {
log.Infof("成功加载语言包: %s", lang)
}
}
log.Info("多语言支持已启用")
} else {
log.Info("多语言支持已禁用")
}
// 设置Gin模式
gin.SetMode(cfg.Server.Mode)
@@ -53,8 +110,8 @@ func main() {
// 启动服务器
addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port)
log.Infof("服务器启动在 %s", addr)
if err := router.Run(addr); err != nil {
log.Fatalf("服务器启动失败: %v", err)
}
}
}
+51
View File
@@ -0,0 +1,51 @@
server:
host: "0.0.0.0"
port: 18888
mode: "debug"
database:
driver: "mysql"
host: "mysql"
port: 3306
username: "license_user"
password: "license@123"
database: "license_manager"
charset: "utf8mb4"
parse_time: true
loc: "Local"
max_idle_conns: 10
max_open_conns: 100
conn_max_lifetime: 3600
auto_migrate: true
auth:
jwt:
secret: "license-manager-dev-secret-key-32-bytes"
expire_hours: 1
refresh_threshold_minutes: 30
security:
max_login_attempts: 5
lockout_duration_minutes: 15
log:
level: "info"
format: "text"
i18n:
enable: true
default_lang: "zh-CN"
config_path: "../configs/i18n/errors"
support_langs: ["zh-CN", "en-US", "ja-JP"]
license:
# RSA非对称加密配置
rsa:
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
key_size: 2048 # 密钥大小:2048或4096,默认2048
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
+41 -21
View File
@@ -1,30 +1,50 @@
# 自动部署使用
server:
host: "0.0.0.0"
port: 8080
mode: "release" # debug, release, test
port: 18888
mode: "debug" # debug, release, test 开发环境使用debug模式
database:
driver: "mysql"
host: "127.0.0.1"
port: 13306
username: "root"
password: "root@123"
database: "license_manager"
charset: "utf8mb4"
parse_time: true
loc: "Local"
max_idle_conns: 10
max_open_conns: 100
conn_max_lifetime: 3600 # 连接最大生存时间(秒)
auto_migrate: true # 是否自动迁移数据库
auth:
jwt:
secret: "your-256-bit-secret-key-change-this-in-production"
expire_hours: 1
refresh_threshold_minutes: 30
admin:
username: "admin"
password: "admin@123" # 明文密码,部署人员可修改
secret: "license-manager-dev-secret-key-32-bytes" # 32字节的随机字符串
expire_hours: 1 # 1小时后过期
refresh_threshold_minutes: 30 # 30分钟后可刷新token
security:
max_login_attempts: 5
lockout_duration_minutes: 15
max_login_attempts: 5 # 5次失败后锁定15分钟
lockout_duration_minutes: 15 # 15分钟后解锁
log:
level: "info" # debug, info, warn, error
format: "json" # json, text
level: "info" # 日志级别
format: "text" # 日志格式
# 数据库配置(暂未使用)
database:
type: "postgres" # postgres or mysql
host: "localhost"
port: 5432
name: "license_manager"
user: "postgres"
password: "password"
ssl_mode: "disable"
i18n:
enable: true # 是否启用多语言支持
default_lang: "zh-CN" # 默认语言
config_path: "../configs/i18n/errors" # 语言包文件路径(相对于backend目录)
support_langs: ["zh-CN", "en-US", "ja-JP"] # 支持的语言列表
license:
# RSA非对称加密配置
rsa:
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
key_size: 2048 # 密钥大小:2048或4096,默认2048
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
+51
View File
@@ -0,0 +1,51 @@
server:
host: "0.0.0.0"
port: 18888
mode: "release"
database:
driver: "mysql"
host: "mysql"
port: 3306
username: "license_user"
password: "license@123"
database: "license_manager"
charset: "utf8mb4"
parse_time: true
loc: "Local"
max_idle_conns: 50
max_open_conns: 300
conn_max_lifetime: 3600
auto_migrate: true
auth:
jwt:
secret: "please-change-in-prod"
expire_hours: 1
refresh_threshold_minutes: 30
security:
max_login_attempts: 5
lockout_duration_minutes: 15
log:
level: "info"
format: "json"
i18n:
enable: true
default_lang: "zh-CN"
config_path: "../configs/i18n/errors"
support_langs: ["zh-CN", "en-US", "ja-JP"]
license:
# RSA非对称加密配置
rsa:
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
key_size: 2048 # 密钥大小:2048或4096,默认2048
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
+33 -4
View File
@@ -3,18 +3,47 @@ server:
port: 18888
mode: "debug" # debug, release, test 开发环境使用debug模式
database:
driver: "mysql"
host: "raspberrypi.local"
port: 13306
username: "root"
password: "root@123"
database: "license_manager"
charset: "utf8mb4"
parse_time: true
loc: "Local"
max_idle_conns: 10
max_open_conns: 100
conn_max_lifetime: 3600 # 连接最大生存时间(秒)
auto_migrate: true # 是否自动迁移数据库
auth:
jwt:
secret: "license-manager-dev-secret-key-32-bytes" # 32字节的随机字符串
expire_hours: 1 # 1小时后过期
refresh_threshold_minutes: 30 # 30分钟后可刷新token
admin:
username: "admin"
password: "admin@123"
security:
max_login_attempts: 5 # 5次失败后锁定15分钟
lockout_duration_minutes: 15 # 15分钟后解锁
log:
level: "info" # 日志级别
format: "text" # 日志格式
format: "text" # 日志格式
i18n:
enable: true # 是否启用多语言支持
default_lang: "zh-CN" # 默认语言
config_path: "../configs/i18n/errors" # 语言包文件路径(相对于backend目录)
support_langs: ["zh-CN", "en-US", "ja-JP"] # 支持的语言列表
license:
# RSA非对称加密配置
rsa:
private_key_path: "../configs/rsa_private_key.pem" # RSA私钥文件路径(服务器端使用,必须保密)
public_key_path: "../configs/rsa_public_key.pem" # RSA公钥文件路径(客户端使用,可以公开)
key_size: 2048 # 密钥大小:2048或4096,默认2048
heartbeat_timeout: 300 # 心跳超时时间(秒) - 5分钟内为在线
offline_timeout: 1440 # 离线超时时间(分钟) - 24小时后为异常
expiring_days: 30 # 即将过期天数 - 30天内到期算即将过期
+124
View File
@@ -0,0 +1,124 @@
system:
name: "English"
locale: "en-US"
errors:
# Success status
"000000": "Success"
# Authentication module (10xxxx)
auth:
"100001": "Authentication expired"
"100002": "Invalid authentication"
"100003": "Invalid username or password"
"100004": "Authentication required"
"100005": "Insufficient permissions"
# Customer module (20xxxx)
customer:
"200001": "Customer not found"
"200002": "Customer already exists"
"200003": "Customer name is required"
"200004": "Invalid customer information format"
"200005": "Failed to query customer list"
"200006": "Customer has authorization codes, cannot delete. Please delete authorizations first"
"200007": "Customer is disabled, cannot create authorization"
# Authorization module (30xxxx)
authorization:
"300001": "Authorization code not found"
"300002": "Authorization code already exists"
"300003": "Authorization code is locked"
"300004": "Maximum activation limit reached"
"300005": "Invalid hardware fingerprint format"
"300006": "License not found"
"300007": "License has been revoked"
"300008": "Hardware fingerprint mismatch"
"300009": "Failed to generate license file"
"300010": "Invalid configuration parameters"
# Dashboard module (40xxxx)
dashboard:
"400001": "Invalid time type parameter"
"400002": "Invalid start date format"
"400003": "Invalid end date format"
"400004": "Time range exceeds limit (over one year)"
"400005": "Start date cannot be later than end date"
"400006": "Invalid limit parameter"
# System general (90xxxx)
system:
"900001": "Invalid request parameters"
"900002": "Resource not found"
"900003": "Resource conflict"
"900004": "Internal server error"
# Common text
common:
success: "Success"
# Enum translations
enums:
dashboard_period:
week: "This Week"
month: "This Month"
customer_type:
"individual": "Individual"
"enterprise": "Enterprise"
"government": "Government"
"education": "Education"
authorization_code_status:
normal: "Normal"
locked: "Locked"
expired: "Expired"
customer_level:
"basic": "Basic Customer"
"active": "Active Customer"
"vip": "VIP Customer"
"strategic": "Strategic Partner"
customer_status:
"active": "Active"
"disabled": "Disabled"
company_size:
"small": "Small"
"medium": "Medium"
"large": "Large"
"enterprise": "Enterprise"
deployment_type:
"standalone": "Standalone"
"cloud": "Cloud"
"hybrid": "Hybrid"
encryption_type:
"standard": "Standard"
authorization_status:
"normal": "Normal"
"locked": "Locked"
"expired": "Expired"
license_status:
"active": "Active"
"inactive": "Inactive"
"revoked": "Revoked"
license_online_status:
"online": "Online"
"offline": "Offline"
authorization_change_type:
"renewal": "Renewal"
"upgrade": "Upgrade"
"limit_change": "Limit Change"
"feature_toggle": "Feature Toggle"
"lock": "Lock"
"unlock": "Unlock"
"other": "Other"
# Default error message
default_error: "Unknown error"
+124
View File
@@ -0,0 +1,124 @@
system:
name: "日本語"
locale: "ja-JP"
errors:
# 成功状態
"000000": "成功"
# 認証モジュール (10xxxx)
auth:
"100001": "認証が期限切れです"
"100002": "無効な認証"
"100003": "ユーザー名またはパスワードが無効です"
"100004": "認証が必要です"
"100005": "権限が不足しています"
# 顧客モジュール (20xxxx)
customer:
"200001": "顧客が見つかりません"
"200002": "顧客が既に存在します"
"200003": "顧客名は必須です"
"200004": "無効な顧客情報形式"
"200005": "顧客リストの照会に失敗しました"
"200006": "顧客にまだ認可コードが存在します。削除する前に認可を削除してください"
"200007": "顧客が無効になっています。認可を作成できません"
# 認可モジュール (30xxxx)
authorization:
"300001": "認可コードが見つかりません"
"300002": "認可コードが既に存在します"
"300003": "認可コードがロックされています"
"300004": "最大アクティベーション数に達しました"
"300005": "無効なハードウェア指紋形式"
"300006": "ライセンスが見つかりません"
"300007": "ライセンスが取り消されました"
"300008": "ハードウェア指紋が一致しません"
"300009": "ライセンスファイルの生成に失敗しました"
"300010": "無効な設定パラメータ"
# ダッシュボードモジュール (40xxxx)
dashboard:
"400001": "無効な時間タイプパラメータ"
"400002": "無効な開始日付形式"
"400003": "無効な終了日付形式"
"400004": "時間範囲が制限を超えています(1年以上)"
"400005": "開始日は終了日より後にできません"
"400006": "無効な制限パラメータ"
# システム共通 (90xxxx)
system:
"900001": "無効なリクエストパラメータ"
"900002": "リソースが見つかりません"
"900003": "リソースの競合"
"900004": "内部サーバーエラー"
# 共通テキスト
common:
success: "成功"
# 列挙値多言語
enums:
dashboard_period:
week: "今週"
month: "今月"
customer_type:
"individual": "個人顧客"
"enterprise": "企業顧客"
"government": "政府顧客"
"education": "教育顧客"
authorization_code_status:
normal: "正常"
locked: "ロック済み"
expired: "期限切れ"
customer_level:
"basic": "基本顧客"
"active": "アクティブ顧客"
"vip": "VIP顧客"
"strategic": "戦略的パートナー"
customer_status:
"active": "アクティブ"
"disabled": "無効"
company_size:
"small": "小企業"
"medium": "中企業"
"large": "大企業"
"enterprise": "超大企業"
deployment_type:
"standalone": "スタンドアロン"
"cloud": "クラウド"
"hybrid": "ハイブリッド"
encryption_type:
"standard": "標準暗号化"
authorization_status:
"normal": "正常"
"locked": "ロック済み"
"expired": "期限切れ"
license_status:
"active": "アクティブ"
"inactive": "非アクティブ"
"revoked": "取り消し済み"
license_online_status:
"online": "オンライン"
"offline": "オフライン"
authorization_change_type:
"renewal": "更新"
"upgrade": "アップグレード"
"limit_change": "制限変更"
"feature_toggle": "機能切り替え"
"lock": "ロック"
"unlock": "アンロック"
"other": "その他"
# デフォルトエラーメッセージ
default_error: "不明なエラー"
+124
View File
@@ -0,0 +1,124 @@
system:
name: "中文"
locale: "zh-CN"
errors:
# 成功状态
"000000": "成功"
# 认证模块 (10xxxx)
auth:
"100001": "认证已过期"
"100002": "认证无效"
"100003": "用户名或密码错误"
"100004": "缺少认证信息"
"100005": "权限不足"
# 客户模块 (20xxxx)
customer:
"200001": "客户不存在"
"200002": "客户已存在"
"200003": "客户名称不能为空"
"200004": "客户信息格式无效"
"200005": "客户列表查询失败"
"200006": "客户仍有授权,无法删除,请先删除授权"
"200007": "客户已停用,无法创建授权"
# 授权模块 (30xxxx)
authorization:
"300001": "授权码不存在"
"300002": "授权码已存在"
"300003": "授权码已被锁定"
"300004": "激活数量已达上限"
"300005": "硬件指纹格式错误"
"300006": "许可证不存在"
"300007": "许可证已被撤销"
"300008": "硬件指纹不匹配"
"300009": "许可证文件生成失败"
"300010": "配置参数错误"
# 仪表盘模块 (40xxxx)
dashboard:
"400001": "时间类型参数错误"
"400002": "开始日期格式错误"
"400003": "结束日期格式错误"
"400004": "时间范围超过限制(超过一年)"
"400005": "开始日期不能晚于结束日期"
"400006": "数量限制参数错误"
# 系统通用 (90xxxx)
system:
"900001": "请求参数无效"
"900002": "资源不存在"
"900003": "资源冲突"
"900004": "服务器内部错误"
# 通用文本
common:
success: "成功"
# 枚举值多语言
enums:
dashboard_period:
week: "本周"
month: "本月"
customer_type:
"individual": "个人客户"
"enterprise": "企业客户"
"government": "政府客户"
"education": "教育客户"
authorization_code_status:
normal: "正常"
locked: "已锁定"
expired: "已过期"
customer_level:
"basic": "基础客户"
"active": "活跃客户"
"vip": "VIP客户"
"strategic": "战略合作客户"
customer_status:
"active": "激活"
"disabled": "停用"
company_size:
"small": "小型企业"
"medium": "中型企业"
"large": "大型企业"
"enterprise": "超大型企业"
deployment_type:
"standalone": "单机版"
"cloud": "云端版"
"hybrid": "混合版"
encryption_type:
"standard": "标准加密"
authorization_status:
"normal": "正常"
"locked": "已锁定"
"expired": "已过期"
license_status:
"active": "激活"
"inactive": "未激活"
"revoked": "已撤销"
license_online_status:
"online": "在线"
"offline": "离线"
authorization_change_type:
"renewal": "续期"
"upgrade": "升级"
"limit_change": "限制变更"
"feature_toggle": "功能切换"
"lock": "锁定"
"unlock": "解锁"
"other": "其他"
# 默认错误信息
default_error: "未知错误"
+27
View File
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEpQIBAAKCAQEA5hGz3hws+L2MUW7CaRQIt7Vxk0KAdPBUuNdkJcs2kpgkDmiT
/pHf+9WXfRVdKD/Eu/t7wckQfzziddXTbeCxgN/5bKSeLTPXT0VvvSkfB5FUUQAB
HFWO4fiBlfP/NdFy++qKgCUF1wGg+jSsrhAw6oMDI7MHVRfRZTe3rTM3T0qi3Ed6
qaoIM9INQ87FcqPVMyQ/KXbe4Dz5yVkLY5iYj8ZJP7iU2vKYvWIG9TNv0FK/dXQk
7syMGBSKmIncCe+UaflAajbDGXq52gIeTyhOBumOLXXBdlwgCVtejBTqDmlQpiJY
ADKiZLQfx0z1hYbbOPBPB3djXCJJmQk69ssoZQIDAQABAoIBAAyqfNSFiJRJGyiA
iINwbupuOsp4psnJUWC7uu+5uE9pwNJqP3uMXGBo6eYg/vfwSsxuk+mfQCFBINaL
5w/thtgpfHnWtj6W1AuxLu2jDepEvfeXoHgG3c7i939h53L3DPWBfA6B+0hS9qHP
tJIAojDG9a1JdeXhYc3Iu3v1jd33izEPeX/O+I35dusJxag2+0Ii3DIl13/JeSLR
m1i79lpsx6QobVKNt3bz27dxRjDZIXnsalpd5ik/QK8q1iRfGfLHfDwGn2J+X1LT
btbW/fipsBc56AJMOGZ/OXpvO2LncZBzW/66Oa+9fvBi4kH9J3+fGpR9X28qMxuo
OE86niECgYEA/QFHViamV0SzjCb6g9xIYD9hGMToRiq6O9UqPd7512yfxRF9y0I4
HbrJG0k4bASeDTd8e1FvJplwPQn4tkBM02sPlfSFcjOYsDDfHmGaOh79GW/oMdst
BfmaXSCKzYc/vR+cePDRqY+2Y8gAGUfKyte2BAOKOahLblPlp54Qn4MCgYEA6Mrq
9XMWZagFv8y+oxQKm3i4e7/M6y7p/BadDX5s3jNjrGpkr9eE+vZw/033xuiz9rVR
nKVLMeazferXKB1FyD3RU+5lAw3GqaWJDfUWiNyYZMgyOintEMllDTj4YsMhT/w9
d5qS5NNYpgKToEtmPydtynn/DG55CWZkkkAc6/cCgYEA847xj5vxcf1vIzK644mC
GzWoUWHtgO7NDaBt49XtilbFj78A7FU5FFWmGCBqsoHXbT3HFigU6oG3rOsWhMxt
qPFdtawJcyD8klDZ2bN39w+x83TpgGVQeHoLAi+7r5PN5lCyHzMlgygYGwKEJdIZ
BSCX1lFaFZtP0kvtv2jI8+UCgYEA1HBxcOLA+q4V6Q2Qn0Pn1i+Zs5y6BZm/gASm
+Cx6b2BYQnypO+si+N8zCVl+l2++45JeUuRpNbXB8VQBWyMzjmQThlOUrO/CbwiY
hTs3gFhHXhlv9LJUHDCd3i4yRfaqjNK0yVQvX1EbogvR6sNEILf7sn4YaaDbJd/i
QbDuc8sCgYEA5I9wdyGUgnqunw/5r4D+uKNRCtms9hTTMql1me8ZOGcF3+KjjSL/
zjt+BToLhrjjCHJYByS2P5O1vtXeeyiWslexWWTc1ujOjJlrxLgBCAMW+qNz5fLc
CeXO8bo/mT7KS7ABXh7tCNhFsSjNNCUcHyzzKp9S1gzRC6k4AGADx8s=
-----END RSA PRIVATE KEY-----
+9
View File
@@ -0,0 +1,9 @@
-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5hGz3hws+L2MUW7CaRQI
t7Vxk0KAdPBUuNdkJcs2kpgkDmiT/pHf+9WXfRVdKD/Eu/t7wckQfzziddXTbeCx
gN/5bKSeLTPXT0VvvSkfB5FUUQABHFWO4fiBlfP/NdFy++qKgCUF1wGg+jSsrhAw
6oMDI7MHVRfRZTe3rTM3T0qi3Ed6qaoIM9INQ87FcqPVMyQ/KXbe4Dz5yVkLY5iY
j8ZJP7iU2vKYvWIG9TNv0FK/dXQk7syMGBSKmIncCe+UaflAajbDGXq52gIeTyhO
BumOLXXBdlwgCVtejBTqDmlQpiJYADKiZLQfx0z1hYbbOPBPB3djXCJJmQk69sso
ZQIDAQAB
-----END PUBLIC KEY-----
+63 -245
View File
@@ -1,265 +1,83 @@
# API开发规范
# API 开发规范
## 目录结构规范
## 核心要点
### 分层架构
一定要阅读customer模块的实现,看看其他模块的实,明白编码细节,现然后再进行开发。
### 1. 分层架构
```
internal/
├── models/ # 数据模型层
├── service/ # 业务逻辑层
├── repository/ # 数据访问层
└── api/handlers/ # HTTP处理层
├── models/ # 数据模型:结构体定义、验证规则
├── service/ # 业务逻辑:核心业务规则实现
├── repository/ # 数据访问:数据库、缓存操作
└── api/handlers/ # HTTP处理:请求处理、响应格式化
```
### 职责划分
- **Models**: 定义请求/响应结构体,数据验证规则
- **Service**: 实现业务逻辑,处理业务规则
- **Repository**: 数据库操作,缓存操作
- **Handler**: HTTP请求处理,参数绑定,响应格式化
**职责边界**:
- Models:数据结构、输入验证、响应格式
- Service:业务规则、数据处理、跨模块协调
- Repository:数据持久化、查询优化、事务管理
- Handler:HTTP协议、参数绑定、错误响应
## 文件命名规范
### 2. 多语言支持
### 模块分组
按业务模块组织文件:
```
models/
├── auth.go # 认证相关模型
├── customer.go # 客户相关模型
├── license.go # 授权相关模型
└── common.go # 通用模型
service/
├── interfaces.go # 服务接口定义
├── auth_service.go
├── customer_service.go
└── license_service.go
api/handlers/
├── auth_handler.go
├── customer_handler.go
└── license_handler.go
```
## 代码结构规范
### 1. 模型定义 (models/)
```go
// 请求模型
type CreateCustomerRequest struct {
Name string `json:"name" binding:"required"`
Email string `json:"email" binding:"required,email"`
Company string `json:"company"`
}
// 响应模型
type CustomerResponse struct {
ID int `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
Company string `json:"company"`
**枚举字段扩展**:为所有枚举类型添加`*_display`字段
```json
{
"status": "active",
"status_display": "激活",
"customer_type": "enterprise",
"customer_type_display": "企业客户"
}
```
### 2. 服务接口 (service/interfaces.go)
```go
type CustomerService interface {
CreateCustomer(req *models.CreateCustomerRequest) (*models.CustomerResponse, error)
GetCustomer(id int) (*models.CustomerResponse, error)
UpdateCustomer(id int, req *models.UpdateCustomerRequest) error
DeleteCustomer(id int) error
**语言检测优先级**:
1. URL参数:`?lang=en-US`
2. HTTP Header:`Accept-Language`
3. 系统默认:配置文件指定
### 3. 错误处理
**错误码规范**:六位数字
- 前2位:模块代码(10=认证,20=客户,30=授权,90=系统)
- 后4位:连续错误号
**统一响应格式**:
```json
{
"code": 0,
"message": "success",
"data": {...}
}
```
### 3. 服务实现 (service/)
```go
type customerService struct {
repo repository.CustomerRepository
}
### 4. RESTful设计
func NewCustomerService(repo repository.CustomerRepository) CustomerService {
return &customerService{repo: repo}
}
**路由规范**:
- 使用名词表示资源
- HTTP方法体现操作
- 版本控制:`/api/v1/`
func (s *customerService) CreateCustomer(req *models.CreateCustomerRequest) (*models.CustomerResponse, error) {
// 业务逻辑处理
// 数据验证
// 调用repository
}
```
**HTTP状态码**:
- 200:成功(包括业务逻辑失败)
- 400:请求格式错误
- 401:身份认证失败
- 403:权限不足
- 500:服务器内部错误
### 4. 处理器实现 (api/handlers/)
```go
type CustomerHandler struct {
service service.CustomerService
}
### 5. 开发流程
func NewCustomerHandler(service service.CustomerService) *CustomerHandler {
return &CustomerHandler{service: service}
}
1. 定义模型结构(含多语言字段)
2. 声明服务接口
3. 实现业务逻辑(添加i18n处理)
4. 编写HTTP处理器
5. 注册路由
6. 完善Swagger文档
// @Summary 创建客户
// @Tags 客户管理
// @Accept json
// @Produce json
// @Param request body models.CreateCustomerRequest true "客户信息"
// @Success 200 {object} models.APIResponse
// @Router /api/v1/customers [post]
func (h *CustomerHandler) CreateCustomer(c *gin.Context) {
var req models.CreateCustomerRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Error: "INVALID_REQUEST",
Message: "请求参数无效",
})
return
}
### 6. 质量检查
result, err := h.service.CreateCustomer(&req)
if err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: http.StatusInternalServerError,
Error: "CREATE_FAILED",
Message: err.Error(),
})
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: http.StatusOK,
Message: "创建成功",
Data: result,
})
}
```
## API设计规范
### 1. 路由设计
```
GET /api/v1/customers # 获取客户列表
POST /api/v1/customers # 创建客户
GET /api/v1/customers/:id # 获取客户详情
PUT /api/v1/customers/:id # 更新客户
DELETE /api/v1/customers/:id # 删除客户
```
### 2. 响应格式
```go
// 成功响应
type APIResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// 错误响应
type ErrorResponse struct {
Code int `json:"code"`
Error string `json:"error"`
Message string `json:"message"`
Timestamp string `json:"timestamp"`
}
```
### 3. HTTP状态码
- `200` - 请求成功
- `201` - 创建成功
- `400` - 请求参数错误
- `401` - 未认证
- `403` - 权限不足
- `404` - 资源不存在
- `500` - 服务器内部错误
## Swagger文档规范
### 必需注解
```go
// @Summary 接口简要描述
// @Description 接口详细描述
// @Tags 标签分组
// @Accept json
// @Produce json
// @Param 参数名 参数位置 参数类型 是否必需 "参数描述"
// @Success 状态码 {object} 响应类型 "成功描述"
// @Failure 状态码 {object} 错误类型 "失败描述"
// @Security BearerAuth (需要认证的接口)
// @Router 路由路径 [HTTP方法]
```
### 标签分组
- `认证` - 登录、登出、Token相关
- `客户管理` - 客户CRUD操作
- `授权管理` - 授权码、许可证相关
- `系统` - 系统信息、健康检测
## 错误处理规范
### 错误码定义
```go
const (
// 通用错误
ErrInvalidRequest = "INVALID_REQUEST"
ErrUnauthorized = "UNAUTHORIZED"
ErrForbidden = "FORBIDDEN"
ErrNotFound = "NOT_FOUND"
ErrInternalError = "INTERNAL_ERROR"
// 业务错误
ErrCustomerExists = "CUSTOMER_EXISTS"
ErrLicenseExpired = "LICENSE_EXPIRED"
ErrInvalidLicense = "INVALID_LICENSE"
)
```
### 统一错误处理
```go
func handleError(c *gin.Context, err error) {
var statusCode int
var errorCode string
switch err {
case ErrCustomerExists:
statusCode = http.StatusConflict
errorCode = "CUSTOMER_EXISTS"
default:
statusCode = http.StatusInternalServerError
errorCode = "INTERNAL_ERROR"
}
c.JSON(statusCode, models.ErrorResponse{
Code: statusCode,
Error: errorCode,
Message: err.Error(),
Timestamp: time.Now().Format(time.RFC3339),
})
}
```
## 开发流程
### 1. 新增API步骤
1. 在 `models/` 定义请求/响应结构体
2. 在 `service/interfaces.go` 添加服务接口
3. 在 `service/` 实现业务逻辑
4. 在 `handlers/` 实现HTTP处理器
5. 在 `routes/router.go` 注册路由
6. 添加Swagger注解
7. 生成文档:`swag init -g cmd/main.go -o ./docs/swagger`
### 2. 代码检查清单
- [ ] 模型定义是否合理
- [ ] 业务逻辑是否在service层
- [ ] 错误处理是否完整
- [ ] Swagger注解是否完整
- [ ] 路由是否正确注册
- [ ] HTTP状态码是否合适
## 最佳实践
1. **职责单一**:每个函数只做一件事
2. **接口优先**:先定义接口,再实现
3. **错误优雅**:统一错误处理和响应格式
4. **文档完整**:所有API都要有Swagger文档
5. **命名规范**:使用有意义的命名
6. **代码复用**:提取公共逻辑到utils包
- 分层职责是否清晰
- 多语言字段是否完整
- 错误处理是否统一
- Swagger注解是否完整
- 避免不必要的冗余,能写到工具函数的不要写在模块内
+71 -19
View File
@@ -3,12 +3,13 @@
## 技术栈选择
Web框架: Gin
ORM: GORM
ORM: GORM,开发环境使用Auto Migration
配置: Viper
日志: logrus
认证: JWT
缓存: Redis (可选)
数据库: PostgreSQL/MySQL
## 目录结构
@@ -18,52 +19,103 @@
```
backend/
├── cmd/
│ └── server/
│ └── main.go # 程序入口
│ ├── main.go # 程序入口
│ └── license_manager.sh # 启动脚本
├── internal/
│ ├── api/
│ │ ├── handlers/ # HTTP处理器
│ │ │ ├── auth.go
│ │ │ ├── customer.go
│ │ │ ├── license.go
│ │ │ └── system.go
│ │ │ ├── auth_handler.go
│ │ │ ├── authorization_code_handler.go
│ │ │ ├── customer_handler.go
│ │ │ ├── enum_handler.go
│ │ │ ├── license_handler.go
│ │ │ └── system_handler.go
│ │ ├── middleware/ # 中间件
│ │ │ ├── auth.go
│ │ │ ├── cors.go
│ │ │ ├── i18n.go
│ │ │ └── logging.go
│ │ └── routes/ # 路由定义
│ │ └── router.go
│ ├── service/ # 业务逻辑层
│ │ ├── auth_service.go
│ │ ├── authorization_code_service.go
│ │ ├── customer_service.go
│ │ ├── enum_service.go
│ │ ├── license_service.go
│ │ ├── system_service.go
│ │ └── interfaces.go # 接口定义
│ ├── repository/ # 数据访问层
│ │ ├── customer_repo.go
│ │ ├── license_repo.go
│ │ ├── authorization_code_repository.go
│ │ ├── customer_repository.go
│ │ ├── license_repository.go
│ │ ├── user_repository.go
│ │ ├── errors.go
│ │ └── interfaces.go
│ ├── models/ # 数据模型
│ │ ├── auth.go
│ │ ├── common.go
│ │ ├── customer.go
│ │ ├── license.go
│ │ └── common.go
│ │ ├── system.go
│ │ └── user.go
│ ├── config/ # 配置管理
│ │ └── config.go
│ └── cache/ # 缓存层
│ ├── memory.go
│ └── redis.go
│ └── database/ # 数据库连接和迁移
│ ├── connection.go
│ └── migration.go
├── pkg/
│ ├── context/ # 上下文封装
│ │ └── context.go
│ ├── i18n/ # 国际化
│ │ ├── errors.go
│ │ └── manager.go
│ ├── utils/ # 工具函数
│ │ ├── crypto.go
│ │ ├── hardware.go
│ │ └── validator.go
│ │ └── jwt.go
│ └── logger/ # 日志封装
│ └── logger.go
├── configs/
│ ├── config.yaml
│ └── config.example.yaml
│ ├── config.dev.yaml # 开发环境配置
│ ├── config.prod.yaml # 生产环境配置
│ ├── config.yaml # 默认配置
│ ├── config.example.yaml # 配置示例
│ └── i18n/ # 国际化配置
│ └── errors/
│ ├── en-US.yaml
│ ├── ja-JP.yaml
│ └── zh-CN.yaml
├── docs/ # 文档目录
│ ├── api_development_guide.md
│ ├── backend_design.md
│ ├── design_philosophy.md
│ ├── error_i18n_design.md
│ ├── install/
│ │ ├── db_install.md
│ │ └── swagger_install.md
│ ├── modules_design/
│ │ ├── auth_api.md
│ │ ├── customer-api.md
│ │ ├── customer.md
│ │ ├── login&auth.md
│ │ ├── user.md
│ │ ├── user_story.md
│ │ ├── 产品原型设计方案.md
│ │ └── 授权模块属性设计.md
│ └── swagger/ # Swagger文档
│ ├── docs.go
│ ├── swagger.json
│ └── swagger.yaml
├── migrations/ # 数据库迁移
├── tools/ # 硬件信息工具
└── go.mod
│ ├── 001_create_customers_table.sql
│ ├── 002_insert_sample_data.sql
│ ├── 003_create_authorization_codes_table.sql
│ ├── 004_create_licenses_table.sql
│ ├── 005_create_authorization_changes_table.sql
│ ├── 006_create_users_table.sql
│ └── README.md
├── go.mod
└── go.sum
```
**核心设计原则:**
@@ -0,0 +1,177 @@
# 客户详情接口改造需求
## 一、接口信息
**接口路径**: `GET /api/customers/{id}`
**接口说明**: 获取客户详细信息,在原有客户基本信息基础上,新增客户授权统计信息
---
## 二、需求概述
在客户详情接口的响应数据中,新增一个 `authorization_stats` 对象,包含该客户的授权码和许可证相关统计数据,用于在客户详情页面展示客户的授权使用情况。
---
## 三、数据模型设计
### 3.1 响应结构
```json
{
"code": "000000",
"message": "success",
"data": {
// ... 原有客户基本信息字段 ...
// 新增:授权统计信息
"authorization_stats": {
"total_auth_codes": 15, // 总授权码数量
"expired_auth_codes": 3, // 已过期授权码数量
"expiring_soon_auth_codes": 2, // 30日内即将到期授权码数量
"total_licenses": 42, // 总许可证数量
"active_licenses": 28, // 已激活许可证数量
"inactive_licenses": 14, // 未激活许可证数量
"expired_licenses": 8 // 已过期许可证数量
}
}
}
```
### 3.2 统计字段详细说明
#### 授权码统计(基于 `authorization_codes` 表)
| 字段名 | 类型 | 说明 | 计算规则 |
|--------|------|------|----------|
| `total_auth_codes` | int64 | 总授权码数量 | 统计该客户(`customer_id`)的所有授权码总数 |
| `expired_auth_codes` | int64 | 已过期授权码数量 | `end_date < NOW()` 的授权码数量(不考虑锁定状态) |
| `expiring_soon_auth_codes` | int64 | 30日内即将到期授权码数量 | `end_date >= NOW()` 且 `end_date <= DATE_ADD(NOW(), INTERVAL 30 DAY)` 的授权码数量(不考虑锁定状态) |
#### 许可证统计(基于 `licenses` 表)
| 字段名 | 类型 | 说明 | 计算规则 |
|--------|------|------|----------|
| `total_licenses` | int64 | 总许可证数量 | 统计该客户(`customer_id`)的所有许可证总数 |
| `active_licenses` | int64 | 已激活许可证数量 | `status = 'active'` 的许可证数量 |
| `inactive_licenses` | int64 | 未激活许可证数量 | `total_licenses - active_licenses` |
| `expired_licenses` | int64 | 已过期许可证数量 | 关联的授权码已过期(`authorization_code.end_date < NOW()`)的许可证总数 |
---
## 四、计算逻辑说明
### 4.1 授权码状态判断
- **正常(normal)**: `NOW() >= start_date AND NOW() <= end_date AND is_locked = false`
- **已过期(expired)**: `NOW() > end_date`(无论是否锁定)
- **已锁定(locked)**: `is_locked = true`(锁定状态不影响有效期统计)
**注意**:
- **过期和即将到期统计不考虑锁定状态**:只要授权码的 `end_date` 满足条件,无论是否锁定都应计入统计
- 例如:即使授权码已锁定,只要 `end_date < NOW()`,仍应计入"已过期授权码数量"
### 4.2 许可证过期判断
许可证的过期状态**依赖于关联的授权码**:
- 许可证本身没有 `end_date` 字段
- 如果许可证关联的授权码已过期(`authorization_codes.end_date < NOW()`),则视为该许可证已过期
### 4.3 边界条件
1. **空数据情况**: 如果客户没有任何授权码或许可证,所有统计值应返回 `0`
2. **软删除**: 只统计未删除的记录(`deleted_at IS NULL`)
3. **时间计算**: 使用服务器当前时间(`NOW()`)作为基准进行判断
---
## 五、相关数据库表
### 5.1 涉及表结构
- **`customers`**: 客户表(主表)
- **`authorization_codes`**: 授权码表
- `customer_id`: 关联客户ID
- `start_date`: 授权开始时间
- `end_date`: 授权结束时间
- `is_locked`: 是否锁定
- **`licenses`**: 许可证表
- `customer_id`: 关联客户ID(冗余字段)
- `authorization_code_id`: 关联授权码ID(外键)
- `status`: 许可证状态(`active`/`inactive`/`revoked`)
### 5.2 关联关系
```
customers (1) ──< (N) authorization_codes
│
│ (1)
│
└──< (N) licenses
```
---
## 六、实现建议
### 6.1 查询优化
建议使用单次 SQL 查询或有限次查询完成统计,避免 N+1 查询问题:
1. **授权码统计**: 使用 `COUNT` + `CASE WHEN` 在一条 SQL 中完成
2. **许可证统计**:
- `total_licenses` 和 `active_licenses`: 单次查询统计
- `expired_licenses`: 需要关联 `authorization_codes` 表判断过期状态
### 6.2 性能考虑
- 确保相关字段已建立索引(如 `customer_id`, `end_date`, `status`, `is_locked`)
- 对于高频访问的接口,可考虑缓存统计结果(需注意数据一致性)
---
## 七、示例数据
假设某客户(ID: `customer-001`)有以下数据:
**授权码数据**:
- 授权码A: `end_date = '2025-12-31'`, `is_locked = false` → 正常
- 授权码B: `end_date = '2025-01-25'`, `is_locked = false` → 30日内即将到期
- 授权码C: `end_date = '2024-12-31'`, `is_locked = false` → 已过期
- 授权码D: `end_date = '2025-12-31'`, `is_locked = true` → 已锁定(正常范围内)
- 授权码E: `end_date = '2024-11-30'`, `is_locked = true` → 已锁定且已过期(仍计入过期统计)
- 授权码F: `end_date = '2025-01-20'`, `is_locked = true` → 已锁定且即将到期(仍计入即将到期统计)
**许可证数据**:
- 授权码A关联: 10个许可证(8个active, 2个inactive)
- 授权码B关联: 5个许可证(4个active, 1个inactive)
- 授权码C关联: 3个许可证(已过期授权码下的所有许可证,均为active)
- 授权码D关联: 2个许可证(1个active, 1个inactive)
- 授权码E关联: 2个许可证(1个active, 1个inactive,因授权码已过期,这2个许可证计入过期许可证)
- 授权码F关联: 1个许可证(1个active)
**统计结果**:
```json
{
"authorization_stats": {
"total_auth_codes": 6,
"expired_auth_codes": 2, // 授权码C(已过期)+ 授权码E(已锁定但已过期)
"expiring_soon_auth_codes": 2, // 授权码B(即将到期)+ 授权码F(已锁定但即将到期)
"total_licenses": 23, // 10+5+3+2+2+1 = 23
"active_licenses": 18, // 8+4+3+1+1+1 = 18
"inactive_licenses": 5, // 23-18 = 5
"expired_licenses": 5 // 授权码C的3个 + 授权码E的2个 = 5
}
}
```
---
## 八、验收标准
1. ✅ 接口响应包含 `authorization_stats` 对象
2. ✅ 所有统计字段类型为 `int64`,空数据返回 `0`
3. ✅ 已过期和即将到期授权码统计不考虑锁定状态(仅基于 `end_date` 判断)
4. ✅ 许可证过期判断基于关联授权码的有效期
5. ✅ 计算逻辑准确,边界情况处理正确
6. ✅ 查询性能良好,无明显的性能问题
@@ -0,0 +1,125 @@
# 仪表盘 API 设计
## 1. 授权趋势统计 API
### 1.1 获取授权趋势数据
```http
GET /api/v1/dashboard/authorization-trend
```
**查询参数**
- `type` - 时间类型 (week/month/custom) 必填
- `start_date` - 开始日期 (YYYY-MM-DD格式,当type为custom时必填)
- `end_date` - 结束日期 (YYYY-MM-DD格式,当type为custom时必填)
- `timezone` - 时区 (可选,如:Asia/Shanghai、UTC等,默认使用服务器本地时区)
**参数说明:**
- `week`: 返回本周每日的授权总数(周一到周日)
- `month`: 返回本月每日的授权总数(1号到当月最后一天)
- `custom`: 返回指定时间段内每日授权总数,时间间隔不超过一年
- `timezone`: 指定时区,影响"本周"、"本月"以及自定义日期的计算基准。支持标准时区名称如Asia/Shanghai、America/New_York、UTC等
**响应**
```json
{
"code": "000000",
"message": "成功",
"data": {
"period": {
"type": "week",
"start_date": "2024-01-01",
"end_date": "2024-01-07",
"description_display": "本周"
},
"trend_data": [
{
"date": "2024-01-01",
"total_authorizations": 15,
"new_authorizations": 3,
"expired_authorizations": 1
},
{
"date": "2024-01-02",
"total_authorizations": 17,
"new_authorizations": 2,
"expired_authorizations": 0
}
],
"summary": {
"total_count": 120,
"new_count": 15,
"expired_count": 5,
"growth_rate": 5.2
}
}
}
```
**响应字段说明:**
- `description_display` - 多语言
## 2. 最近授权列表 API
### 2.1 获取最近授权列表
```http
GET /api/v1/dashboard/recent-authorizations
```
**查询参数**
- `limit` - 返回数量限制(默认20,最大100)
- `customer_id` - 客户ID筛选(可选)
- `status` - 状态筛选 (normal/locked/expired) 可选
**响应**
```json
{
"code": "000000",
"message": "成功",
"data": {
"list": [
{
"id": "uuid-string",
"code": "LIC-COMP001-A7B9X2-C8F4",
"customer_id": "customer-uuid",
"customer_name": "张三公司",
"description": "企业版授权",
"status": "normal",
"status_display": "正常",
"start_date": "2024-01-01T00:00:00Z",
"end_date": "2024-12-31T23:59:59Z",
"max_activations": 10,
"current_activations": 7,
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z"
}
],
"total": 20
}
}
```
**响应字段说明:**
- `total` - 符合条件的授权总数量
- `list` - 当前返回的授权列表数据
- `status_display` - 多语言
## 3. 状态说明
### 3.1 授权码状态
- `normal` - 正常:当前时间在有效期内且未被锁定
- `locked` - 已锁定:is_locked=true
- `expired` - 已过期:当前时间超过end_date
### 3.2 时间类型
- `week` - 本周:从周一00:00:00到周日23:59:59 (基于指定时区)
- `month` - 本月:从1号00:00:00到当月最后一天23:59:59 (基于指定时区)
- `custom` - 自定义:指定开始和结束日期,最大跨度365天 (基于指定时区)
## 4. 错误码定义
- `400001` - 时间类型参数错误
- `400002` - 开始日期格式错误
- `400003` - 结束日期格式错误
- `400004` - 时间范围超过限制(超过一年)
- `400005` - 开始日期不能晚于结束日期
- `400006` - 数量限制参数错误
+58
View File
@@ -0,0 +1,58 @@
# 系统设计理念
## 代码即文档(Code as Documentation)
通过代码本身传达设计意图,减少文档维护负担:
- **语义化命名**:函数、变量、常量名称体现其用途和含义
- **结构化组织**:目录结构和文件组织反映业务模型
- **内联说明**:关键逻辑通过简洁注释说明设计思路
- **自解释代码**:优先写出无需注释就能理解的代码
## 分层架构(Layered Architecture)
明确的职责边界,保持系统的可维护性:
- **模型层(Models)**:数据结构定义、验证规则、业务实体
- **服务层(Service)**:核心业务逻辑、跨领域协调、规则实现
- **数据层(Repository)**:数据持久化、查询优化、事务管理
- **接口层(Handlers)**:HTTP协议处理、参数绑定、响应格式化
**设计原则**:上层依赖下层,避免跨层调用,通过接口解耦
## 整洁代码(Clean Code)
追求代码的可读性和表达力:
- **单一职责**:每个函数、类只负责一个明确的职责
- **函数简洁**:函数体保持简短,易于理解和测试
- **避免重复**:提取公共逻辑,提高代码复用性
- **有意义的抽象**:合理使用抽象层次,不过度设计
## 渐进式复杂度(Progressive Complexity)
从简单到复杂的演进路径:
- **开箱即用**:系统默认配置即可运行,降低上手门槛
- **按需扩展**:高级功能通过配置启用,不影响基础使用
- **平缓学习**:功能按复杂度分层,用户可逐步深入
- **向后兼容**:新功能不破坏现有接口和行为
## 失败快速原则(Fail Fast)
及早发现问题,提供明确反馈:
- **启动验证**:系统启动时检查配置完整性和依赖可用性
- **输入校验**:在数据处理早期进行格式和业务规则验证
- **明确错误**:提供具体的错误信息和解决建议
- **优雅降级**:局部故障不影响整体服务可用性
## 可维护性设计(Maintainable Design)
面向长期演进的架构设计:
- **接口优先**:通过接口定义契约,降低模块间耦合
- **依赖注入**:便于测试替换和功能扩展
- **配置外化**:运行时行为通过配置文件控制
- **模块化组织**:按业务领域组织代码,便于团队协作
+68
View File
@@ -0,0 +1,68 @@
# 许可证加密设计文档
## 加密方案
使用 **RSA 数字签名**确保许可证文件的完整性和来源认证。
### 签名算法
- **算法**: RSA-PSS
- **哈希**: SHA-256
- **密钥长度**: 2048 位(推荐)或 4096 位
## 服务器端实现
### 密钥管理
- 私钥存储在配置文件中:`configs/rsa_private_key.pem`
- 配置文件路径:`license.rsa.private_key_path`
### 签名流程
1. 构建许可证数据(JSON)
2. 使用 RSA 私钥对数据进行数字签名
3. 生成许可证文件结构:
```json
{
"data": "原始JSON数据(字符串)",
"signature": "base64编码的签名",
"algorithm": "RSA-PSS-SHA256"
}
```
4. Base64 编码整个结构
### 代码位置
- 签名实现:`backend/internal/service/license_service.go::signLicenseFile()`
- 密钥工具:`backend/pkg/utils/crypto.go`
## 客户端实现
### 公钥加载
支持三种方式(按优先级):
1. 当前目录的 `rsa_public_key.pem` 文件
2. 环境变量 `LICENSE_RSA_PUBLIC_KEY`(PEM 格式字符串)
3. 环境变量 `LICENSE_RSA_PUBLIC_KEY_PATH`(文件路径)
### 验证流程
1. Base64 解码许可证文件
2. 解析 JSON 结构
3. 使用 RSA 公钥验证签名
4. 验证通过后解析许可证数据
### 代码位置
- 验证实现:`backend/cmd/client-demo/rsa.go::verifyRSASignature()`
- 许可证解析:`backend/cmd/client-demo/main.go::decryptLicenseFile()`
## 密钥生成
使用工具生成密钥对:
```bash
go run backend/cmd/gen-rsa-keys/main.go
```
生成文件:
- `rsa_private_key.pem` - 服务器使用(必须保密)
- `rsa_public_key.pem` - 客户端使用(可公开)
## 安全性说明
1. **防伪造**:只有服务器拥有私钥,无法伪造有效签名
2. **防篡改**:任何数据修改都会导致签名验证失败
3. **密钥分离**:公钥可公开,私钥仅服务器持有
+342
View File
@@ -0,0 +1,342 @@
# 错误信息多语言设计方案
## 设计理念
基于系统设计理念,构建简洁、易用、可维护的多语言错误信息响应系统:
- **配置驱动**:语言包通过配置文件管理,支持热更新
- **代码即文档**:通过结构化的语言文件体现错误信息组织
- **渐进式复杂度**:从单语言平滑升级到多语言支持
- **失败快速原则**:语言缺失时优雅降级到默认语言
## 整体架构设计
### 1. 目录结构
```
backend/
├── configs/
│ └── i18n/ # 多语言配置目录
│ ├── errors/ # 错误信息语言包
│ │ ├── zh-CN.yaml # 中文错误信息
│ │ ├── en-US.yaml # 英文错误信息
│ │ └── ja-JP.yaml # 日文错误信息
│ └── messages/ # 其他信息语言包(可选扩展)
├── pkg/
│ └── i18n/ # 国际化工具包
│ ├── manager.go # 多语言管理器
│ └── errors.go # 错误信息国际化封装
```
### 2. 核心设计原则
- **单一职责**:i18n 包专门处理多语言逻辑
- **接口解耦**:通过接口与现有错误系统集成
- **配置外化**:所有语言文件独立管理
- **缓存优化**:语言包加载后内存缓存
## 语言包配置格式
### YAML 配置结构
错误信息按模块组织,保持与现有错误码体系一致:
```yaml
# configs/i18n/errors/zh-CN.yaml
system:
name: "中文"
locale: "zh-CN"
errors:
# 成功状态
"000000": "成功"
# 认证模块 (10xxxx)
auth:
"100001": "认证已过期"
"100002": "认证无效"
"100003": "用户名或密码错误"
"100004": "缺少认证信息"
"100005": "权限不足"
# 客户模块 (20xxxx) - 预留
customer:
"200001": "客户不存在"
"200002": "客户已存在"
# 授权模块 (30xxxx) - 预留
license:
"300001": "授权码无效"
"300002": "授权已过期"
# 系统通用 (90xxxx)
system:
"900001": "请求参数无效"
"900002": "资源不存在"
"900003": "资源冲突"
"900004": "服务器内部错误"
# 默认错误信息
default_error: "未知错误"
```
```yaml
# configs/i18n/errors/en-US.yaml
system:
name: "English"
locale: "en-US"
errors:
"000000": "Success"
auth:
"100001": "Authentication expired"
"100002": "Invalid authentication"
"100003": "Invalid username or password"
"100004": "Authentication required"
"100005": "Insufficient permissions"
customer:
"200001": "Customer not found"
"200002": "Customer already exists"
license:
"300001": "Invalid license key"
"300002": "License expired"
system:
"900001": "Invalid request parameters"
"900002": "Resource not found"
"900003": "Resource conflict"
"900004": "Internal server error"
default_error: "Unknown error"
```
## 核心组件设计
### 1. 多语言管理器
```go
// pkg/i18n/manager.go
type Manager struct {
defaultLang string
loadedLangs map[string]*LanguageData
configPath string
}
type LanguageData struct {
System SystemInfo `yaml:"system"`
Errors map[string]string `yaml:"errors"`
DefaultError string `yaml:"default_error"`
}
type SystemInfo struct {
Name string `yaml:"name"`
Locale string `yaml:"locale"`
}
// 核心接口
type I18nManager interface {
LoadLanguage(lang string) error
GetErrorMessage(code, lang string) string
SupportedLanguages() []string
SetDefaultLanguage(lang string)
}
```
### 2. 错误信息国际化封装
```go
// pkg/i18n/errors.go
type ErrorI18n struct {
manager I18nManager
}
// 扩展现有错误响应函数
func NewI18nErrorResponse(code, lang string, customMessage ...string) (int, string, string) {
// 获取本地化错误信息
message := GetI18nErrorMessage(code, lang)
// 支持自定义消息覆盖
if len(customMessage) > 0 && customMessage[0] != "" {
message = customMessage[0]
}
httpStatus := getHTTPStatusByCode(code)
return httpStatus, code, message
}
```
## 语言检测策略
### 1. 多层级语言检测
按优先级顺序检测客户端语言偏好:
1. **URL 参数**:`?lang=en-US` (最高优先级)
2. **HTTP Header**:`Accept-Language: zh-CN,zh;q=0.9,en;q=0.8`
3. **用户配置**:数据库存储的用户语言偏好
4. **系统默认**:配置文件指定的默认语言
### 2. 语言检测中间件
```go
// internal/api/middleware/i18n.go
func I18nMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
lang := detectLanguage(c)
c.Set("language", lang)
c.Next()
}
}
func detectLanguage(c *gin.Context) string {
// 1. URL 参数检测
if lang := c.Query("lang"); lang != "" {
return lang
}
// 2. Accept-Language 头检测
if lang := parseAcceptLanguage(c.GetHeader("Accept-Language")); lang != "" {
return lang
}
// 3. 返回默认语言
return config.DefaultLanguage
}
```
## 集成实现方案
### 1. 配置系统集成
扩展现有配置结构,添加国际化配置:
```go
// internal/config/config.go
type Config struct {
// ... 现有配置
I18n I18nConfig `yaml:"i18n"`
}
type I18nConfig struct {
Enable bool `yaml:"enable"` // 是否启用多语言
DefaultLang string `yaml:"default_lang"` // 默认语言
ConfigPath string `yaml:"config_path"` // 语言包路径
SupportLangs []string `yaml:"support_langs"` // 支持的语言列表
}
```
### 2. Handler 层集成
修改现有错误处理流程,支持多语言响应:
```go
// 使用示例
func (h *AuthHandler) Login(c *gin.Context) {
// ... 业务逻辑
if err != nil {
// 获取客户端语言
lang := c.GetString("language")
// 返回本地化错误响应
httpStatus, code, message := i18n.NewI18nErrorResponse("100003", lang)
c.JSON(httpStatus, models.ErrorResponse{
Code: httpStatus,
Error: code,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
}
```
## 性能优化策略
### 1. 缓存机制
- **启动加载**:应用启动时预加载常用语言包
- **内存缓存**:语言包加载后常驻内存
- **懒加载**:按需加载非常用语言包
### 2. 降级策略
- **语言缺失降级**:请求语言不存在时降级到默认语言
- **错误码缺失降级**:特定错误码翻译缺失时使用通用错误信息
- **完全降级**:多语言系统异常时回退到原有单语言模式
## 开发和维护流程
### 1. 语言包维护
- **版本控制**:语言文件纳入 Git 管理
- **格式校验**:CI 流程中校验 YAML 格式正确性
- **完整性检查**:确保所有语言包包含相同的错误码
### 2. 新增错误码流程
1. 在 `pkg/errors/codes.go` 中添加新错误码
2. 在所有语言包中添加对应翻译
3. 更新相关文档和测试用例
### 3. 新增语言支持流程
1. 创建新的语言包文件(如 `fr-FR.yaml`)
2. 翻译所有错误信息
3. 在配置中添加新语言到支持列表
4. 更新 API 文档
## 实施建议
### 第一阶段:基础架构
1. 实现多语言管理器核心功能
2. 创建中英文语言包
3. 集成语言检测中间件
4. 改造现有错误处理函数
### 第二阶段:功能完善
1. 添加更多语言支持
2. 实现配置热更新
3. 添加性能监控
4. 完善错误处理和降级机制
### 第三阶段:扩展应用
1. 扩展到其他响应信息的多语言支持
2. 添加用户语言偏好存储
3. 实现动态语言切换
4. 提供语言包管理接口
## 配置示例
### 应用配置
```yaml
# configs/config.yaml
i18n:
enable: true
default_lang: "zh-CN"
config_path: "configs/i18n/errors"
support_langs: ["zh-CN", "en-US", "ja-JP"]
```
### 使用示例
```go
// 初始化
i18nManager := i18n.NewManager("configs/i18n/errors", "zh-CN")
i18nManager.LoadLanguage("zh-CN")
i18nManager.LoadLanguage("en-US")
// 使用
message := i18nManager.GetErrorMessage("100003", "en-US")
// 输出:Invalid username or password
```
这个设计方案遵循系统的设计理念,提供了简洁、可维护的多语言错误信息支持,同时保持了与现有架构的良好集成。
+24
View File
@@ -0,0 +1,24 @@
# MySQL安装
## Docker安装
```bash
docker run --name lm-mysql -d \
--restart always \
-p 13306:3306 \
-e MYSQL_ROOT_PASSWORD=root@123 \
-e MYSQL_DATABASE=license_manager \
-v mysql-data:/var/lib/mysql \
-v mysql-conf:/etc/mysql/conf.d \
-v mysql-logs:/var/log/mysql \
mysql:8.0 \
--default-authentication-plugin=mysql_native_password
```
## 卸载清理
```bash
docker rm -f lm-mysql
docker volume rm mysql-data mysql-conf mysql-logs
```
@@ -38,4 +38,4 @@ swag fmt
# 验证swagger注释
swag init --parseVendor -g cmd/main.go
```
```
-273
View File
@@ -1,273 +0,0 @@
# 登录与认证模块设计
## 概述
本模块负责系统的用户认证、登录状态管理和权限控制,基于JWT(JSON Web Token)实现无状态认证机制。
## 技术选型
- **认证方式**: JWT (JSON Web Token)
- **加密算法**: HS256 (HMAC-SHA256)
- **密码加密**: bcrypt
- **Session存储**: 内存缓存/Redis(可选)
## 社区版登录设计
用户名和密码配置在配置文件中,默认用户名和密码为 `admin` / `admin@123`
## 功能模块
### 1. 用户登录 (Login)
#### 1.1 登录流程
```
1. 用户提交用户名/密码
2. 验证用户凭证
3. 生成JWT Token
4. 返回Token和用户信息
5. 前端存储Token(localStorage/sessionStorage)
```
#### 1.2 API接口设计
```http
POST /api/v1/login
Content-Type: application/json
{
"username": "admin",
"password": "admin@123"
}
```
**响应格式:**
```json
{
"code": 200,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 3600,
"user_info": {
"username": "admin",
"role": "administrator"
}
}
}
```
#### 1.3 实现细节
- 密码使用bcrypt进行哈希验证
- JWT包含用户ID、用户名、角色、过期时间
- Token默认有效期1小时,可配置
- 支持记住我功能(延长Token有效期)
### 2. 用户登出 (Logout)
#### 2.1 登出流程
```
1. 客户端删除本地Token
2. 可选:服务端将Token加入黑名单
3. 返回登出成功响应
```
#### 2.2 API接口设计
```http
POST /api/v1/logout
Authorization: Bearer <token>
```
**响应格式:**
```json
{
"code": 200,
"message": "登出成功"
}
```
### 3. Token刷新
#### 3.1 刷新机制
- 当Token即将过期时(剩余时间<30分钟),自动刷新
- 刷新Token与访问Token分离(可选实现)
#### 3.2 API接口设计
```http
POST /api/v1/auth/refresh
Authorization: Bearer <token>
```
## JWT Token设计
### 1. Token结构
```json
{
"header": {
"alg": "HS256",
"typ": "JWT"
},
"payload": {
"user_id": 1,
"username": "admin",
"role": "administrator",
"iat": 1640995200,
"exp": 1641002400
},
"signature": "signature_string"
}
```
### 2. Claims定义
- `user_id`: 用户ID
- `username`: 用户名
- `role`: 用户角色
- `iat`: 签发时间
- `exp`: 过期时间
## 中间件认证设计
### 1. 认证中间件 (AuthMiddleware)
#### 1.1 功能职责
- 验证HTTP请求头中的JWT Token
- 解析Token获取用户信息
- 处理Token过期和无效情况
- 将用户信息注入到请求上下文
#### 1.2 认证流程
```
1. 检查Authorization头是否存在
2. 提取Bearer Token
3. 验证Token签名和有效性
4. 解析用户信息
5. 将用户信息存储到Context
6. 继续处理请求或返回401错误
```
#### 1.3 实现伪代码
```go
func AuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// 1. 获取Token
token := extractToken(c)
if token == "" {
c.JSON(401, gin.H{"error": "Missing token"})
c.Abort()
return
}
// 2. 验证Token
claims, err := validateToken(token)
if err != nil {
c.JSON(401, gin.H{"error": "Invalid token"})
c.Abort()
return
}
// 3. 设置用户信息到Context
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
c.Next()
}
}
```
### 2. 可选中间件 (OptionalAuth)
对于某些接口可能需要可选认证(如公开API),提供可选认证中间件:
```go
func OptionalAuthMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
token := extractToken(c)
if token != "" {
if claims, err := validateToken(token); err == nil {
c.Set("user_id", claims.UserID)
c.Set("username", claims.Username)
c.Set("role", claims.Role)
}
}
c.Next()
}
}
```
## 路由保护策略
### 1. 路由分组
```go
// 公开路由
public := r.Group("/api/v1")
{
public.POST("/login", handlers.Login)
}
// 需要认证的路由
auth := r.Group("/api/v1")
auth.Use(middleware.AuthMiddleware())
{
auth.POST("/logout", handlers.Logout)
auth.GET("/customers", handlers.GetCustomers)
auth.POST("/licenses", handlers.CreateLicense)
}
// 管理员路由
admin := r.Group("/api/v1/admin")
admin.Use(middleware.AuthMiddleware(), middleware.AdminOnly())
{
admin.GET("/system/info", handlers.GetSystemInfo)
}
```
### 2. 权限级别
- **Public**: 无需认证
- **Authenticated**: 需要有效Token
- **Admin**: 需要管理员权限
## 安全措施
### 1. Token安全
- 使用强密钥签名(至少32字节)
- 设置合理的过期时间
### 2. 密码安全
- bcrypt加密,cost factor >= 10
- 登录失败限制(防暴力破解)
## 配置参数
```yaml
auth:
jwt:
secret: "your-256-bit-secret"
expire_hours: 1
refresh_threshold_minutes: 30
admin:
username: "admin"
password: "admin@123" # 明文密码 部署人员可修改
security:
max_login_attempts: 5
lockout_duration_minutes: 15
```
## 错误处理
### 1. 认证错误码
- `AUTH_001`: Token缺失
- `AUTH_002`: Token无效
- `AUTH_003`: Token过期
- `AUTH_004`: 权限不足
- `AUTH_005`: 用户名或密码错误
- `AUTH_006`: 账户被锁定
### 2. 统一错误响应
```json
{
"code": 401,
"error": "AUTH_002",
"message": "Token无效",
"timestamp": "2024-01-01T12:00:00Z"
}
```
+551
View File
@@ -0,0 +1,551 @@
# 授权模块API设计
## 1. 授权码管理 API
### 1.1 授权码列表
```http
GET /api/v1/authorization-codes
```
**查询参数**
- `customer_id` - 客户ID
- `status` - 状态 (normal/locked/expired)
- `page` - 页码
- `page_size` - 每页数量(默认20,最大100)
- `start_date` - 创建开始时间
- `end_date` - 创建结束时间
- `sort` - 排序字段 (created_at/updated_at/code)
- `order` - 排序方向 (asc/desc)
**实现说明**
由于 `status` 是虚拟字段,需要在 SQL 查询中动态计算并筛选:
```sql
-- 状态计算逻辑
SELECT *,
CASE
WHEN is_locked = true THEN 'locked'
WHEN end_date < NOW() THEN 'expired'
WHEN start_date <= NOW() AND end_date >= NOW() THEN 'normal'
ELSE 'expired'
END AS status
FROM authorization_codes
WHERE 1=1
-- status 参数筛选
AND (
(:status = 'locked' AND is_locked = true) OR
(:status = 'expired' AND is_locked = false AND end_date < NOW()) OR
(:status = 'normal' AND is_locked = false AND start_date <= NOW() AND end_date >= NOW()) OR
:status IS NULL
)
-- 其他筛选条件
AND (:customer_id IS NULL OR customer_id = :customer_id)
AND (:start_date IS NULL OR created_at >= :start_date)
AND (:end_date IS NULL OR created_at <= :end_date)
ORDER BY created_at DESC
LIMIT :page_size OFFSET :offset;
```
**响应**
```json
{
"code": "000000",
"message": "成功",
"data": {
"list": [
{
"id": "uuid-string",
"code": "LIC-COMP001-A7B9X2-C8F4",
"customer_id": "customer-uuid",
"customer_name": "张三公司",
"customer_name_display": "张三公司",
"status": "normal",
"status_display": "正常",
"start_date": "2024-01-01T00:00:00Z",
"end_date": "2024-12-31T23:59:59Z",
"max_activations": 10,
"current_activations": 7,
"deployment_type": "standalone",
"deployment_type_display": "单机版",
"is_locked": false,
"description": "企业版授权",
"created_at": "2024-01-01T10:00:00Z"
}
],
"total": 100,
"page": 1,
"page_size": 20,
"total_pages": 5
}
}
```
### 1.2 创建授权码
```http
POST /api/v1/authorization-codes
```
**说明:** 有效期从当天00:00:00开始,到指定天数后的23:59:59结束。例如:validity_days=1表示今天一整天有效。
**请求体**
```json
{
"customer_id": "customer-uuid-123",
"software_id": "erp-system",
"description": "企业版授权",
"validity_days": 365,
"deployment_type": "standalone",
"encryption_type": "standard",
"software_version": "1.0.0",
"max_activations": 10,
"feature_config": {
"modules": ["user_mgmt", "inventory", "finance"]
},
"usage_limits": {
"max_users": 100,
"api_calls_per_day": 10000
},
"custom_parameters": {}
}
```
**参数说明:**
- `customer_id`: 客户ID(必填)
- `software_id`: 软件产品ID(可选)
- `description`: 授权描述(可选)
- `validity_days`: 有效期天数,范围1-36500天(必填)
- `deployment_type`: 部署类型,枚举值:standalone/cloud/hybrid(必填)
- `encryption_type`: 加密类型,枚举值:standard/advanced(可选,默认standard)
- `software_version`: 软件版本(可选)
- `max_activations`: 最大激活数量(必填,最小值1)
- `feature_config`: 功能配置JSON(可选)
- `usage_limits`: 使用限制JSON(可选)
- `custom_parameters`: 自定义参数JSON(可选)
**响应**
```json
{
"code": "000000",
"message": "成功",
"data": {
"id": "uuid-string",
"code": "LIC-COMP001-A7B9X2-C8F4"
}
}
```
### 1.3 授权码详情
```http
GET /api/v1/authorization-codes/{id}
```
**响应**
```json
{
"code": "000000",
"message": "成功",
"data": {
"id": "uuid-string",
"code": "LIC-COMP001-A7B9X2-C8F4",
"customer_id": "customer-uuid",
"status": "normal",
"status_display": "正常",
"start_date": "2024-01-01T00:00:00Z",
"end_date": "2024-12-31T23:59:59Z",
"max_activations": 10,
"current_activations": 7,
"deployment_type": "standalone",
"deployment_type_display": "单机版",
"encryption_type": "standard",
"encryption_type_display": "标准加密",
"feature_config": {
"modules": ["user_mgmt", "inventory", "finance"]
},
"usage_limits": {
"max_users": 100,
"api_calls_per_day": 10000
},
"custom_parameters": {},
"is_locked": false,
"lock_reason": null,
"description": "企业版授权",
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z",
"customer_info": {
"id": "customer-uuid",
"customer_code": "CUS-2024-0001",
"customer_name": "张三公司",
"customer_type": "enterprise",
"customer_type_display": "企业客户",
"status": "active",
"status_display": "激活",
"created_at": "2023-12-01T10:00:00Z"
},
"activated_licenses_count": 7
}
}
```
### 1.4 更新授权码
```http
PUT /api/v1/authorization-codes/{id}
```
**请求体**
```json
{
"start_date": "2024-01-01",
"end_date": "2025-12-31",
"max_activations": 20,
"feature_config": {
"modules": ["user_mgmt", "inventory", "finance", "crm"]
},
"usage_limits": {
"max_users": 200,
"api_calls_per_day": 20000
},
"change_type": "upgrade",
"reason": "客户升级套餐"
}
```
### 1.5 锁定/解锁授权码
```http
PUT /api/v1/authorization-codes/{id}/lock
```
**请求体**
```json
{
"is_locked": true,
"lock_reason": "违规使用"
}
```
### 1.6 删除授权码
```http
DELETE /api/v1/authorization-codes/{id}
```
## 2. 许可证管理 API
### 2.1 许可证列表
```http
GET /api/v1/licenses
```
**查询参数**
- `authorization_code_id` - 授权码ID
- `customer_id` - 客户ID
- `status` - 状态 (active/inactive/revoked)
- `is_online` - 在线状态
- `page` - 页码(默认1)
- `page_size` - 每页数量(默认20,最大100)
- `sort` - 排序字段 (created_at/updated_at/activated_at/last_heartbeat)
- `order` - 排序方向 (asc/desc)
**响应**
```json
{
"code": "000000",
"message": "成功",
"data": {
"list": [
{
"id": "license-uuid",
"license_key": "LIC-DEVICE-ABC123456789",
"authorization_code_id": "auth-code-uuid",
"authorization_code": "LIC-COMP001-A7B9X2-C8F4",
"customer_name": "张三公司",
"hardware_fingerprint": "CPU:ABC123,MB:DEF456",
"status": "active",
"status_display": "激活",
"is_online": true,
"is_online_display": "在线",
"activation_ip": "192.168.1.100",
"last_online_ip": "192.168.1.100",
"activated_at": "2024-01-01T10:00:00Z",
"last_heartbeat": "2024-01-01T14:30:00Z"
}
],
"total": 50,
"page": 1,
"page_size": 20,
"total_pages": 3
}
}
```
### 2.2 许可证详情
```http
GET /api/v1/licenses/{id}
```
**响应**
```json
{
"code": "000000",
"message": "成功",
"data": {
"id": "license-uuid",
"license_key": "LIC-DEVICE-ABC123456789",
"authorization_code_id": "auth-code-uuid",
"authorization_code": "LIC-COMP001-A7B9X2-C8F4",
"customer_id": "customer-uuid",
"customer_name": "张三公司",
"hardware_fingerprint": "CPU:ABC123,MB:DEF456,MAC:00:11:22:33:44:55",
"device_info": {
"cpu": "Intel i7-8700",
"memory": "16GB",
"disk": "512GB SSD",
"os": "Windows 10 Pro"
},
"activation_ip": "192.168.1.100",
"status": "active",
"status_display": "激活",
"is_online": true,
"is_online_display": "在线",
"activated_at": "2024-01-01T10:00:00Z",
"last_heartbeat": "2024-01-01T14:30:00Z",
"last_online_ip": "192.168.1.100",
"config_updated_at": "2024-01-01T10:00:00Z",
"usage_data": {
"active_users": 50,
"api_calls_today": 5000
},
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T14:30:00Z"
}
}
```
### 2.3 手动添加许可证
```http
POST /api/v1/licenses
```
**请求体**
```json
{
"authorization_code_id": "auth-code-uuid",
"hardware_fingerprint": "CPU:ABC123,MB:DEF456,MAC:00:11:22:33:44:55",
"device_info": {
"cpu": "Intel i7-8700",
"memory": "16GB",
"disk": "512GB SSD",
"os": "Windows 10 Pro"
},
"activation_ip": "192.168.1.100"
}
```
**响应**
```json
{
"code": "000000",
"message": "成功",
"data": {
"id": "license-uuid",
"license_key": "LIC-DEVICE-ABC123456789",
"authorization_code_id": "auth-code-uuid",
"customer_id": "customer-uuid",
"hardware_fingerprint": "CPU:ABC123,MB:DEF456,MAC:00:11:22:33:44:55",
"device_info": {
"cpu": "Intel i7-8700",
"memory": "16GB",
"disk": "512GB SSD",
"os": "Windows 10 Pro"
},
"activation_ip": "192.168.1.100",
"status": "inactive",
"created_at": "2024-01-01T10:00:00Z",
"updated_at": "2024-01-01T10:00:00Z"
}
}
```
### 2.4 撤销许可证
```http
PUT /api/v1/licenses/{id}/revoke
```
**请求体**
```json
{
"reason": "设备更换"
}
```
### 2.5 下载许可证文件
```http
GET /api/v1/licenses/{id}/download
```
**响应**: 返回加密的许可证文件
## 3. 客户端激活 API
### 3.1 软件激活
```http
POST /api/v1/activate
```
**请求体**
```json
{
"authorization_code": "LIC-COMP001-A7B9X2-C8F4",
"hardware_fingerprint": "CPU:ABC123,MB:DEF456,MAC:00:11:22:33:44:55",
"device_info": {
"cpu": "Intel i7-8700",
"memory": "16GB",
"os": "Windows 10 Pro"
},
"software_version": "1.0.0"
}
```
**响应**
```json
{
"code": "000000",
"message": "成功",
"data": {
"license_key": "LIC-DEVICE-ABC123456789",
"license_file": "base64编码的加密许可证文件",
"heartbeat_interval": 300
}
}
```
### 3.2 心跳检测
```http
POST /api/v1/heartbeat
```
**请求体**
```json
{
"license_key": "LIC-DEVICE-ABC123456789",
"hardware_fingerprint": "CPU:ABC123,MB:DEF456,MAC:00:11:22:33:44:55",
"config_updated_at": "2024-01-01T10:00:00Z",
"usage_data": {
"active_users": 50,
"api_calls_today": 5000
},
"software_version": "1.0.0"
}
```
**响应**
```json
{
"code": "000000",
"message": "成功",
"data": {
"status": "active",
"config_updated": true,
"license_file": "base64编码的新许可证文件",
"heartbeat_interval": 300
}
}
```
## 4. 统计报表 API
### 4.1 授权概览统计
```http
GET /api/v1/stats/overview
```
**响应**
```json
{
"code": "000000",
"message": "成功",
"data": {
"total_auth_codes": 1234,
"active_licenses": 856,
"expiring_soon": 23,
"abnormal_alerts": 5,
"growth_rate": {
"auth_codes": 5.2,
"licenses": 3.1
}
}
}
```
### 4.2 授权变更历史
```http
GET /api/v1/authorization-codes/{id}/changes
```
**查询参数**
- `page` - 页码(默认1)
- `page_size` - 每页数量(默认20,最大100)
- `change_type` - 变更类型筛选 (renewal/upgrade/limit_change/feature_toggle/lock/unlock/other)
- `operator_id` - 操作人筛选
- `start_date` - 开始时间 (YYYY-MM-DD格式)
- `end_date` - 结束时间 (YYYY-MM-DD格式)
- `sort` - 排序字段 (created_at/change_type)
- `order` - 排序方向 (asc/desc)
**响应**
```json
{
"code": "000000",
"message": "成功",
"data": {
"list": [
{
"id": "change-uuid",
"change_type": "upgrade",
"change_type_display": "升级",
"operator_id": "user-uuid",
"operator_name": "张三",
"reason": "客户升级套餐",
"created_at": "2024-01-01T10:00:00Z"
}
],
"total": 25,
"page": 1,
"page_size": 20,
"total_pages": 2
}
}
```
## 5. 错误码定义
- `300001` - 授权码不存在
- `300002` - 授权码已存在
- `300003` - 授权码已被锁定
- `300004` - 激活数量已达上限
- `300005` - 硬件指纹格式错误
- `300006` - 许可证不存在
- `300007` - 许可证已被撤销
- `300008` - 硬件指纹不匹配
- `300009` - 许可证文件生成失败
- `300010` - 配置参数错误
## 6. 状态说明
### 6.1 授权码状态
- `normal` - 正常:当前时间在有效期内且未被锁定
- `locked` - 已锁定:is_locked=true
- `expired` - 已过期:当前时间超过end_date
### 6.2 部署类型
- `standalone` - 单机版:独立部署的软件
- `cloud` - 云端版:基于云的SaaS服务
- `hybrid` - 混合版:结合本地和云端的部署
### 6.3 加密类型
- `standard` - 标准加密:基础安全级别
- `advanced` - 高级加密:增强安全级别
### 6.4 许可证状态
- `active` - 激活:正常使用中
- `inactive` - 未激活:已创建但尚未激活
- `revoked` - 已撤销:被管理员撤销或因违规被停用
+218
View File
@@ -0,0 +1,218 @@
# 客户管理API设计
## 1. API概览
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/customers` | 查询客户列表 |
| GET | `/api/customers/{id}` | 获取客户详情 |
| POST | `/api/customers` | 创建客户 |
| PUT | `/api/customers/{id}` | 更新客户信息 |
| DELETE | `/api/customers/{id}` | 删除客户(软删除) |
| PATCH | `/api/customers/{id}/status` | 修改客户状态 |
---
## 2. 数据模型
### 客户信息结构
```json
{
"id": "uuid",
"customer_code": "CUS-2025-0001",
"customer_name": "北京科技有限公司",
"customer_type": "enterprise",
"customer_type_display": "企业客户",
"contact_person": "张三",
"contact_title": "技术总监",
"email": "zhangsan@example.com",
"phone": "13800138000",
"address": "北京市朝阳区科技园区100号",
"company_size": "medium",
"company_size_display": "中型企业",
"customer_level": "vip",
"customer_level_display": "VIP客户",
"status": "active",
"status_display": "激活",
"description": "重要客户,需要优先技术支持",
"created_at": "2025-01-15T10:30:00Z",
"updated_at": "2025-01-20T15:45:00Z",
"created_by": "admin_uuid",
"updated_by": "admin_uuid"
}
```
### 字段说明
- `customer_type`: `individual` | `enterprise` | `government` | `education`
- `customer_type_display`: 客户类型的多语言显示名称
- `company_size`: `small` | `medium` | `large` | `enterprise`
- `company_size_display`: 企业规模的多语言显示名称
- `customer_level`: `normal` | `vip` | `enterprise` | `strategic`
- `customer_level_display`: 客户等级的多语言显示名称
- `status`: `active` | `disabled`
- `status_display`: 状态的多语言显示名称
> **注意**: `*_display` 字段根据请求头 `Accept-Language` 或查询参数 `lang` 返回对应语言的显示文本
---
## 3. API详细设计
### 3.1 查询客户列表
```
GET /api/customers
```
**Query参数**
- `page`: 页码,默认1
- `page_size`: 每页条数,默认20,最大100
- `search`: 搜索关键词(支持客户编码、名称、联系人、邮箱)
- `customer_type`: 客户类型筛选
- `customer_level`: 客户等级筛选
- `status`: 状态筛选
- `sort`: 排序字段,默认`created_at`
- `order`: 排序方向,默认`desc`
**响应示例**
```json
{
"code": 0,
"message": "success",
"data": {
"list": [
{
"id": "uuid",
"customer_code": "CUS-2025-0001",
"customer_name": "北京科技有限公司",
"customer_type": "enterprise",
"customer_type_display": "企业客户",
"contact_person": "张三",
"email": "zhangsan@example.com",
"customer_level": "vip",
"customer_level_display": "VIP客户",
"status": "active",
"status_display": "激活",
"created_at": "2025-01-15T10:30:00Z"
}
],
"total": 100,
"page": 1,
"page_size": 20,
"total_pages": 5
}
}
```
### 3.2 获取客户详情
```
GET /api/customers/{id}
```
**响应示例**
```json
{
"code": 0,
"message": "success",
"data": {
// 完整的客户信息结构
}
}
```
### 3.3 创建客户
```
POST /api/customers
```
**请求体**
```json
{
"customer_name": "北京科技有限公司",
"customer_type": "enterprise",
"contact_person": "张三",
"contact_title": "技术总监",
"email": "zhangsan@example.com",
"phone": "13800138000",
"address": "北京市朝阳区科技园区100号",
"company_size": "medium",
"customer_level": "normal",
"description": "新客户",
"status": "active"
}
```
**必填字段**
- `customer_name`: 客户名称
- `contact_person`: 联系人姓名
- `customer_level`: 客户等级
- `customer_type`: 客户类型
- `company_size`: 企业规模
- `status`: 状态
**响应示例**
```json
{
"code": 0,
"message": "客户创建成功",
"data": {
// 创建后的完整客户信息
}
}
```
### 3.4 更新客户信息
```
PUT /api/customers/{id}
```
**请求体**: 与创建客户相同,但所有字段都是可选的
**响应示例**
```json
{
"code": 0,
"message": "客户信息更新成功",
"data": {
// 更新后的完整客户信息
}
}
```
### 3.5 删除客户
```
DELETE /api/customers/{id}
```
**响应示例**
```json
{
"code": 0,
"message": "客户删除成功",
"data": null
}
```
### 3.6 修改客户状态
```
PATCH /api/customers/{id}/status
```
**请求体**
```json
{
"status": "disabled"
}
```
**响应示例**
```json
{
"code": 0,
"message": "客户状态更新成功",
"data": {
"id": "uuid",
"status": "disabled"
}
}
```
+337
View File
@@ -0,0 +1,337 @@
# 客户属性设计与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(集团)
- **必填**: 否
- **用途**: 制定差异化授权策略
### 客户分级
- `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. 业务价值总结
### 提高工作效率
- **快速识别**: 通过客户编码和等级快速识别重要客户
- **精准搜索**: 多维度搜索快速定位目标客户
- **批量操作**: 减少重复性操作,提高处理效率
### 优化客户服务
- **服务分级**: 根据客户等级提供差异化服务
- **及时响应**: 通过状态管理及时识别问题客户
- **个性化**: 根据客户属性提供合适的解决方案
### 支撑业务决策
- **客户分析**: 通过等级、类型分布了解客户结构
- **趋势监控**: 通过创建时间分析客户增长趋势
- **风险控制**: 通过状态管理防范业务风险
+145
View File
@@ -0,0 +1,145 @@
# 登录与认证模块设计
## 概述
本模块负责系统的用户认证、登录状态管理和权限控制,基于JWT(JSON Web Token)实现无状态认证机制。
## 技术选型
- **认证方式**: JWT (JSON Web Token)
- **加密算法**: HS256 (HMAC-SHA256)
- **密码加密**: bcrypt
- **Session存储**: 内存缓存/Redis(可选)
## 社区版登录设计
用户名和密码硬编码在系统中,默认用户名和密码为 `admin` / `admin@123`
## 功能模块
### 1. 用户登录 (Login)
#### 1.1 登录流程
```
1. 用户提交用户名/密码
2. 验证用户凭证
3. 生成JWT Token
4. 返回Token和用户信息
5. 前端存储Token(localStorage/sessionStorage)
```
#### 1.2 API接口设计
```http
POST /api/v1/login
Content-Type: application/json
{
"username": "admin",
"password": "admin@123"
}
```
**响应格式:**
```json
{
"code": 200,
"message": "登录成功",
"data": {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"expires_in": 3600,
"user_info": {
"username": "admin",
"role": "administrator"
}
}
}
```
#### 1.3 实现细节
- 密码使用bcrypt进行哈希验证
- JWT包含用户ID、用户名、角色、过期时间
- Token默认有效期1小时,可配置
- 支持记住我功能(延长Token有效期)
### 2. 用户登出 (Logout)
#### 2.1 登出流程
```
1. 客户端删除本地Token
2. 可选:服务端将Token加入黑名单
3. 返回登出成功响应
```
#### 2.2 API接口设计
```http
POST /api/v1/logout
Authorization: Bearer <token>
```
**响应格式:**
```json
{
"code": 200,
"message": "登出成功"
}
```
### 3. Token刷新
#### 3.1 刷新机制
- 当Token即将过期时(剩余时间<30分钟),自动刷新
- 刷新Token与访问Token分离(可选实现)
#### 3.2 API接口设计
```http
POST /api/v1/auth/refresh
Authorization: Bearer <token>
```
## JWT Token设计
### 1. Token结构
```json
{
"header": {
"alg": "HS256",
"typ": "JWT"
},
"payload": {
"user_id": 1,
"username": "admin",
"role": "administrator",
"iat": 1640995200,
"exp": 1641002400
},
"signature": "signature_string"
}
```
### 2. Claims定义
- `user_id`: 用户ID
- `username`: 用户名
- `role`: 用户角色
- `iat`: 签发时间
- `exp`: 过期时间
## 中间件认证设计
### 1. 认证中间件 (AuthMiddleware)
#### 1.1 功能职责
- 验证HTTP请求头中的JWT Token
- 解析Token获取用户信息
- 处理Token过期和无效情况
- 将用户信息注入到请求上下文
#### 1.2 认证流程
```
1. 检查Authorization头是否存在
2. 提取Bearer Token
3. 验证Token签名和有效性
4. 解析用户信息
5. 将用户信息存储到Context
6. 继续处理请求或返回401错误
```
+61
View File
@@ -0,0 +1,61 @@
# 用户管理模块设计
## 表结构设计
### 用户表 (users)
**基础信息属性**
- `id` - 主键 (UUID)
- `username` - 用户名,唯一
- `email` - 邮箱地址,唯一
- `password_hash` - 密码哈希值
- `full_name` - 真实姓名
- `phone` - 手机号码
**角色权限属性**
- `role` - 用户角色 (admin管理员/operator操作员/viewer查看者)
- `status` - 账号状态 (active活跃/disabled禁用/locked锁定)
**登录相关属性**
- `last_login_at` - 最后登录时间
- `last_login_ip` - 最后登录IP
- `login_attempts` - 登录失败次数
- `locked_until` - 账号锁定到期时间
**时间字段**
- `created_at` - 创建时间
- `updated_at` - 更新时间
## 角色权限说明
**admin (管理员)**
- 完整的系统管理权限
- 可以管理用户、授权码、许可证
- 可以查看所有报表和日志
**operator (操作员)**
- 可以创建和管理授权码
- 可以管理许可证
- 可以查看相关报表
- 不能管理用户
**viewer (查看者)**
- 只读权限
- 可以查看授权码和许可证信息
- 可以查看报表
- 不能进行任何修改操作
## 安全机制
**密码策略**
- 最小长度8位
- 包含大小写字母、数字、特殊字符
- 定期提醒更换密码
**账号锁定**
- 连续5次登录失败自动锁定30分钟
- 管理员可手动解锁账号
**会话管理**
- JWT Token有效期24小时
- 支持Token刷新机制
+243
View File
@@ -0,0 +1,243 @@
# 授权管理用户故事
## 授权管理 - 用户故事
### 🏢 **授权管理员角色**
**故事1:生成新授权**
```
作为 授权管理员
我想要 为客户生成新的授权码
以便 客户能够部署和使用IoT平台
验收标准:
- 我可以选择已有客户或创建新客户
- 我可以设置授权期限(如1年)
- 我可以设置设备数量限制(如100台)
- 我可以选择启用的功能模块
- 系统自动生成唯一的授权码
- 我可以下载包含授权码的部署包
```
**故事2:监控授权状态**
```
作为 授权管理员
我想要 查看所有授权的使用状态
以便 及时发现问题并提供支持
验收标准:
- 我可以看到每个授权的在线状态
- 我可以看到最后心跳时间
- 我可以看到软件资源使用情况,比如用户数量等
- 过期或异常的授权会高亮显示
- 我可以筛选查看特定状态的授权
```
**故事3:处理授权过期**
```
作为 授权管理员
我想要 在授权即将过期时收到提醒
以便 提前联系客户续费
验收标准:
- 授权过期前30天、7天、1天自动提醒
- 我可以看到即将过期的授权列表
- 我可以为客户延长授权期限并非重新生成一个授权
- 我可以生成续费用的新授权
```
### 👨‍💼 **客户/最终用户角色**
**故事4:部署授权**
```
作为 IoT平台客户
我想要 在我的服务器上部署授权
以便 开始使用IoT平台服务
验收标准:
- 系统启动时自动验证授权有效性
- 授权验证失败时有清晰的错误提示
- 我可以看到授权的基本信息(如过期时间)
```
**故事5:监控自己的授权**
```
作为 IoT平台客户
我想要 了解我的授权使用情况
以便 合理规划设备接入和功能使用
验收标准:
- 我可以看到授权剩余天数
- 我可以看到已使用的设备数量和限制
- 我可以看到可用的功能模块
- 接近限制时收到提醒
- 我知道如何联系续费或扩容
```
### 🔧 **系统运维角色**
**故事6:处理授权异常**
```
作为 系统运维人员
我想要 快速诊断和解决授权相关问题
以便 确保客户服务不中断
验收标准:
- 我可以查看授权验证的详细日志
- 我可以看到硬件指纹匹配情况
- 我可以临时禁用或启用授权
- 我可以重置授权的硬件绑定
- 我有权限进行紧急授权延期
```
### 💼 **销售/客服角色**
**故事7:客户咨询处理**
```
作为 销售/客服人员
我想要 快速查询客户的授权信息
以便 准确回答客户问题并提供服务
验收标准:
- 我可以通过客户名称快速找到授权
- 我可以看到授权的详细信息和状态
- 我可以查看客户的授权历史记录
- 我可以生成授权使用报告
- 我知道如何为客户申请授权变更
```
**故事8:续费商机识别**
```
作为 销售人员
我想要 识别即将到期的授权客户
以便 主动联系促成续费
验收标准:
- 我可以看到30天内过期的授权列表
- 我可以看到客户的授权使用活跃度
- 我可以看到客户的设备使用情况
- 系统提示扩容或升级的商机
- 我可以导出客户联系信息进行跟进
```
### 🚨 **紧急安全场景**
**故事1:发现安全威胁时远程锁定**
```
作为 安全管理员
我想要 在发现客户环境存在安全威胁时远程锁定软件
以便 防止数据泄露或系统被恶意使用
验收标准:
- 我可以在管理后台选择特定授权进行锁定
- 锁定后客户端软件立即停止所有功能
- 客户端显示"系统已被远程锁定,请联系技术支持"
- 我可以设置锁定原因(安全威胁/违规使用/紧急维护)
- 锁定操作会记录日志和通知相关人员
- 我可以随时解除锁定恢复正常服务
```
### 💰 **商业违约场景**
**故事2:客户违约时执行锁定**
```
作为 业务管理员
我想要 在客户违反合同条款时远程锁定软件
以便 保护公司商业利益并促使客户履约
验收标准:
- 我可以设置锁定生效时间(立即/定时)
- 客户端显示违约原因和解决方式
- 我可以设置部分功能锁定(如只锁定高级功能)
- 客户付款后我可以快速解锁
- 整个过程有完整的操作记录
```
### 🔧 **技术维护场景**
**故事3:系统维护时临时锁定**
```
作为 系统运维人员
我想要 在进行重要系统升级时临时锁定客户端
以便 确保升级过程中数据一致性和系统稳定
验收标准:
- 我可以批量锁定所有或特定客户的软件
- 客户端显示"系统维护中,预计X小时后恢复"
- 我可以设置维护窗口和预计恢复时间
- 维护完成后可以批量解锁
- 客户可以看到维护进度和预计完成时间
```
### 🏢 **客户端场景**
**故事4:客户收到锁定通知**
```
作为 IoT平台用户
我想要 在软件被锁定时了解原因和解决方法
以便 快速恢复正常使用
验收标准:
- 软件被锁定时我收到清晰的提示信息
- 我可以看到锁定原因(技术/商务/安全)
- 我可以看到联系方式和解决步骤
- 对于计划性维护,我能看到预计恢复时间
- 我可以通过软件界面直接联系技术支持
- 我可以查看我的历史锁定记录
```
### 🎯 **精细化控制场景**
**故事5:功能级别锁定**
```
作为 产品管理员
我想要 对违规客户进行部分功能锁定
以便 在保持基本服务的同时限制高级功能
验收标准:
- 我可以选择锁定特定功能模块(如数据导出、API调用)
- 我可以设置设备数量限制(如从100台降到50台)
- 客户端清楚显示哪些功能被限制
- 我可以逐步解锁功能作为激励措施
- 客户可以看到如何恢复完整功能的说明
```
### 🔧 **授权管理员角色**
**故事:为不同客户选择合适的加密方式**
```
作为 授权管理员
我想要 在生成授权时选择使用标准加密或高级加密
以便 满足不同客户的安全需求
验收标准:
- 我可以在生成授权页面选择加密类型:
• 标准加密 (HMAC-SHA256) - 适用于一般客户
• 高级加密 (RSA-2048) - 适用于安全要求高的客户
- 系统显示每种加密方式的简单说明
- 生成的授权码会标识使用的加密类型
- 客户端能够自动识别并使用对应的解密方式
- 我可以在授权列表中看到每个授权使用的加密类型
```
----
@@ -0,0 +1,361 @@
# 授权管理系统 - 产品原型设计方案
## 📋 文档概述
**目标用户**: 产品经理、UI/UX设计师
**文档目的**: 为授权管理系统的原型设计和交互设计提供详细指导
**适用范围**: 管理后台、客户端授权验证流程
---
## 🎯 产品定位
### 核心价值
- **统一授权管理** - 为多个软件产品提供统一的授权管理平台
- **灵活配置** - 支持不同软件的个性化授权规则
- **安全可靠** - 基于硬件指纹的防盗版机制
- **离线支持** - 许可证文件支持完全离线运行
### 目标用户群体
1. **系统管理员** - 管理授权码、监控使用情况
2. **销售人员** - 为客户创建和配置授权
3. **客服人员** - 处理客户授权问题和故障排查
4. **最终用户** - 软件激活和使用
---
## 🚀 核心业务流程
### 1. 授权码生命周期
```mermaid
graph TD
A[创建授权码] --> B[配置权限和限制]
B --> C[分发给客户]
C --> D[客户激活软件]
D --> E[生成许可证]
E --> F[软件正常运行]
F --> G{配置需要更新?}
G -->|是| H[推送新配置]
G -->|否| I[定期心跳检查]
H --> F
I --> F
J[授权过期] --> K[软件停止功能]
F --> J
L[违规使用] --> M[锁定授权]
F --> L
```
---
## 📱 页面结构设计
### 导航结构
```
授权管理系统
├── 仪表板 (Dashboard)
│ ├── 概览统计
│ ├── 近期活动
│ └── 告警信息
├── 授权码管理
│ ├── 授权码列表
│ ├── 创建授权码
│ ├── 授权码详情
│ └── 批量操作
├── 许可证管理
│ ├── 许可证列表
│ ├── 许可证详情
│ ├── 手动添加许可证
│ └── 设备管理
├── 客户管理
│ ├── 客户列表
│ ├── 客户详情
│ └── 客户授权概览
├── 报表分析
│ ├── 使用情况统计
│ ├── 激活趋势分析
│ └── 收入分析
```
---
## 🎨 关键页面设计要求
### 1. 仪表板页面
**布局要求:**
- 采用卡片式布局,4列网格排列
- 顶部显示核心指标(总授权数、活跃许可证、过期警告等)
- 中部显示图表(激活趋势、使用情况分布)
- 底部显示近期活动日志
**核心指标卡片:**
```
┌─────────────┬─────────────┬─────────────┬─────────────┐
│ 总授权码数量 │ 活跃许可证 │ 即将过期 │ 异常告警 │
│ 1,234 │ 856 │ 23 │ 5 │
│ ↑ 5.2% │ ↑ 3.1% │ ↓ 12% │ ↑ 2 │
└─────────────┴─────────────┴─────────────┴─────────────┘
```
**交互要求:**
- 指标卡片可点击跳转到详情页面
- 图表支持时间范围筛选(7天/30天/90天)
- 实时数据更新(每5分钟刷新)
### 2. 授权码管理页面
**列表页面字段:**
| 字段 | 显示名称 | 宽度 | 操作 |
|-----|---------|------|------|
| code | 授权码 | 120px | 复制 |
| customer_name | 客户名称 | 150px | 点击查看 |
| status | 状态 | 80px | 状态标签 |
| activated/max | 激活情况 | 100px | 进度条 |
| end_date | 到期时间 | 120px | 倒计时 |
| remark | 备注说明 | 100px | - |
| actions | 操作 | 120px | 按钮组 |
**状态标识设计:**
- 🟢 正常 (normal) - 绿色标签
- 🔒 已锁定 (locked) - 红色标签
- ⏰ 已过期 (expired) - 灰色标签
**筛选器设计:**
```
┌─ 筛选条件 ────────────────────────────────────────┐
│ 客户: [下拉选择] 状态: [多选] │
│ 创建时间: [日期范围] 到期时间: [日期范围] │
│ [重置] [搜索] │
└─────────────────────────────────────────────────┘
```
### 3. 授权码详情页面
**页面布局:**
```
┌─ 基础信息 ─────────────────┬─ 配置信息 ─────────────────┐
│ 授权码: ABC123 │ 开始时间: 2024-01-01 │
│ 客户: 张三公司 │ 结束时间: 2024-12-31 │
│ │ 最大激活数: 10 │
│ 状态: 🟢 正常 │ 当前激活: 7 │
│ 创建时间: 2024-01-01 │ 部署类型: 单机版 │
│ 创建人: 李四 │备注说明: xxxx
└───────────────────────────┴───────────────────────────┘
┌─ 功能配置 ─────────────────────────────────────────────┐
│ { │
│ "modules": ["user_mgmt", "inventory", "finance"], │
│ "max_users": 100, │
│ "api_calls_per_day": 10000 │
│ } │
│ [编辑配置] │
└───────────────────────────────────────────────────────┘
┌─ 许可证列表 ───────────────────────────────────────────┐
│ 设备1 | 192.168.1.100 | 最后心跳: 2分钟前 | 🟢在线 │
│ 设备2 | 192.168.1.101 | 最后心跳: 1小时前 | 🔴离线 │
│ 设备3 | 192.168.1.102 | 最后心跳: 5分钟前 | 🟢在线 │
│ [查看更多] │
└───────────────────────────────────────────────────────┘
```
**操作按钮设计:**
- **主要操作:** [编辑配置] [延期续费] [导出许可证]
- **次要操作:** [锁定/解锁授权] [查看日志] [复制授权码]
- **危险操作:** [删除授权] - 需要二次确认
### 4. 创建授权码页面
**表单步骤设计:**
```
步骤1: 基础信息 → 步骤2: 授权配置 → 步骤3: 功能设置 → 步骤4: 确认创建
🔵 ⚪ ⚪ ⚪
```
**授权码生成规则:**
- 格式: `LIC-{customer_code}-{random}-{checksum}`
- 示例: `LIC-COMP001-A7B9X2-C8F4`
- 由后端自动生成,确保唯一性和安全性
**步骤1 - 基础信息:**
```
┌─ 基础信息 ─────────────────────────────────────────┐
│ * 客户选择: [下拉搜索框] [+ 新建客户] │
│ 授权码: [提交后自动生成] │
│ 备注说明: [多行文本框] │
└───────────────────────────────────────────────────┘
```
**步骤2 - 授权配置:**
```
┌─ 授权配置 ─────────────────────────────────────────┐
│ * 授权期限: │
│ ○ 永久授权 │
│ ● 限期授权 时间范围 │
│ │
│ * 最大激活数: [数字输入框] 台设备 │
│ │
│ * 部署类型: │
│ ● 单机版 ○ 云端版 ○ 混合版 │
│ │
│ 加密类型: ● 标准加密 ○ 高级加密 │
└───────────────────────────────────────────────────┘
```
**步骤3 - 功能设置:**
```
MVP版本使用json编辑器即可
┌─ 功能模块 ─────────────────────────────────────────┐
│ [JSON编辑器或键值对编辑器] │
│ 键: [输入框] 值: [输入框] [+ 添加] [- 删除] │
└───────────────────────────────────────────────────┘
┌─ 使用限制 ─────────────────────────────────────────┐
│ [JSON编辑器或键值对编辑器] │
│ 键: [输入框] 值: [输入框] [+ 添加] [- 删除] │
└───────────────────────────────────────────────────┘
┌─ 自定义参数 ───────────────────────────────────────┐
│ [JSON编辑器或键值对编辑器] │
│ 键: [输入框] 值: [输入框] [+ 添加] [- 删除] │
└───────────────────────────────────────────────────┘
```
### 5. 手动添加许可证页面
**适用场景:**
- 离线部署环境
- 客户提前提供硬件信息
- 批量部署前预生成许可证
**表单设计:**
```
┌─ 许可证信息 ───────────────────────────────────────┐
│ * 授权码: [下拉选择框 - 显示可用授权码] │
│ * 硬件指纹: [文本输入框] │
│ 示例: CPU:ABC123,MB:DEF456,MAC:00:11:22:33:44:55│
│ │
│ 设备信息: [JSON编辑器] (可选) │
│ { │
│ "cpu": "Intel i7-8700", │
│ "memory": "16GB", │
│ "disk": "512GB SSD", │
│ "os": "Windows 10 Pro" │
│ } │
│ │
│ 激活IP: [IP输入框] (可选) │
│ 备注: [多行文本框] │
│ │
│ [取消] [生成许可证文件] │
└───────────────────────────────────────────────────┘
```
**操作流程:**
```
[选择授权码] → [输入硬件信息] → [验证格式] → [生成许可证] → [下载许可证文件]
↓ ↓ ↓ ↓ ↓
检查激活限制 解析硬件指纹 格式校验 更新数据库 提供文件下载
```
---
## 🔄 状态与交互流程
### 1. 授权状态流转
```mermaid
stateDiagram-v2
[*] --> 正常
正常 --> 已锁定: 管理员锁定/违规检测
正常 --> 已过期: 到期时间
已锁定 --> 正常: 管理员解锁
已过期 --> 正常: 续费延期
已过期 --> [*]: 删除授权
已锁定 --> [*]: 删除授权
```
### 2. 许可证状态流转
```mermaid
stateDiagram-v2
[*] --> 未激活: 创建许可证
未激活 --> 激活: 软件首次连接
激活 --> 已撤销: 设备更换/违规/管理员撤销
已撤销 --> [*]: 清理记录
激活 --> 在线: 心跳正常
激活 --> 离线: 心跳超时
在线 --> 离线: 心跳中断
离线 --> 在线: 心跳恢复
```
### 3. 关键操作确认流程
**修改授权配置:**
```
[编辑配置] → [确认弹窗] → [填写变更信息] → [最终确认] → [执行修改]
↓ ↓
确认修改对话框 变更类型选择
变更原因(可选)
```
**弹窗设计:**
```
┌─ 确认修改授权配置 ─────────────────────────────────────┐
│ │
│ 您即将修改授权码 ABC123 的配置,此操作将影响 │
│ 7 个已激活的许可证设备。 │
│ │
│ * 变更类型: │
│ ○ renewal续费 ○ upgrade升级 ○ limit_change限制调整│
│ ○ feature_toggle功能开关 ○ lock锁定 ○ unlock解锁 │
│ ○ other其他 │
│ │
│ 变更原因: [多行文本框 - 非必填] │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 例如:客户要求增加用户数限制... │ │
│ │ 或填写锁定原因等自定义说明 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ [取消] [确认修改] │
└───────────────────────────────────────────────────────┘
```
---
## 📊 数据展示要求
### 1. 列表页面分页
**分页控件要求:**
- 每页显示数量: 10/20/50/100 可选
- 页面跳转: 首页/上页/下页/末页 + 页码输入
- 总数显示: "共 1,234 条记录,第 1/25 页"
### 2. 日期时间显示
**标准格式:**
- 完整日期时间: 2024-01-01 14:30:25
- 相对时间: 2分钟前、1小时前、昨天、3天前
- 倒计时: 还有15天到期、已过期3天
### 3. 状态指示器
**在线状态:**
- 🟢 在线 (最近5分钟有心跳)
- 🟡 离线 (5分钟-24小时无心跳)
- 🔴 异常 (24小时以上无心跳)
**进度指示:**
```
激活情况: 7/10 [████████▒▒] 70%
```
这份文档为产品经理和设计师提供了详细的原型设计指导,涵盖了用户流程、页面布局、交互细节和技术要求。可以基于这份文档进行原型制作和界面设计。
@@ -0,0 +1,175 @@
# 授权管理模块属性设计
## 整体架构说明
授权管理采用两层架构模式:
1. **授权码 (authorization_codes)** - 业务配置的容器,包含功能权限、使用限制等
2. **许可证 (licenses)** - 硬件激活凭证,只表示哪台机器有权使用该授权
### 关系映射
```
授权码 (1) → 许可证 (N)
```
一个授权码定义业务规则,多个许可证表示可以在多台机器上使用
### 数据流向
```
授权码(业务配置模板) → 许可证(硬件绑定) → 软件本地许可证文件(包含完整配置) → 软件运行时使用
```
---
## 1. 授权码表 (authorization_codes)
### 基础信息属性
- `id` - 主键
- `code` - 唯一授权码 (软件身份凭证,可重复使用)
- `customer_id` - 关联客户ID
- `created_by` - 创建人ID
- `created_at/updated_at` - 时间戳
- `software_id` - 目标软件产品ID(企业版)
- `description` - 描述
### 授权核心属性
- `start_date` - 授权开始时间
- `end_date` - 授权结束时间
- `deployment_type` - 部署类型 (standalone单机版/cloud云端版/hybrid混合版)
- `encryption_type` - 加密类型 (standard标准加密/advanced高级加密)(企业版)
- `software_version` - 支持的软件版本范围(企业版)
### 激活控制属性
- `max_activations` - 最大许可证数量
- `current_activations` - 当前已激活数量(可通过关联查询获得)
### 状态管理属性
- (虚字段-不出现在表中)`status` - 授权状态 (normal正常/locked已锁定/expired已过期)
- **SQL 计算逻辑**:
```sql
CASE
WHEN is_locked = true THEN 'locked'
WHEN end_date < NOW() THEN 'expired'
WHEN start_date <= NOW() AND end_date >= NOW() THEN 'normal'
ELSE 'expired'
END AS status
```
- **优先级**: locked > expired > normal
- **筛选实现**: 在 WHERE 子句中使用相同逻辑进行条件判断
- `is_locked` - 是否被锁定 (布尔字段)
- `lock_reason` - 锁定原因 (自定义文本输入)
- `locked_at` - 锁定时间
- `locked_by` - 锁定操作人
### 通用功能控制属性
- `feature_config` - 功能配置 (JSON格式,软件产品基础功能)
- `usage_limits` - 使用量限制 (JSON格式,如用户数、API调用次数等)
- `custom_parameters` - 自定义参数 (JSON格式,软件特定功能或用户定制功能)
---
## 2. 许可证表 (licenses)
### 基础信息属性
- `id` - 主键
- `license_key` - 许可证密钥 (设备特定)
- `authorization_code_id` - 关联的授权码 (外键)
- `customer_id` - 客户ID (冗余字段,便于查询)
- `created_at/updated_at` - 时间戳
### 硬件绑定属性
- `hardware_fingerprint` - 绑定的硬件指纹
- `device_info` - 设备信息 (JSON格式,包含CPU、内存等)
- `activation_ip` - 激活时的IP地址
### 激活状态属性
- `status` - 许可证状态 (active激活/inactive未激活/revoked已撤销)
- 硬件更换 - 用户换了新设备,需要撤销旧设备的许可证
- 设备丢失/损坏 - 设备不再使用,主动释放许可证名额
- 违规使用 - 发现设备被盗用或滥用,管理员撤销
- `activated_at` - 激活时间
- `last_heartbeat` - 最后心跳时间
- (虚字段)`is_online` - 在线状态
- `last_online_ip` - 最后在线IP
### 配置同步属性
- `config_updated_at` - 配置更新时间
### 使用统计属性
- `usage_data` - 使用数据 (JSON格式,软件上报的统计信息)
---
## 3. 授权变更历史表 (authorization_changes)
当修改授权的时候,需要用户确认并选择变更类型和填写变更原因(非必输)
### 基础属性
- `id` - 主键
- `authorization_code_id` - 授权码ID
- `change_type` - 变更类型 (renewal续费/upgrade升级/limit_change限制调整/feature_toggle功能开关/lock锁定/unlock解锁/other其他)
- `old_config` - 变更前配置 (JSON)
- `new_config` - 变更后配置 (JSON)
- `operator_id` - 操作人ID
- `reason` - 变更原因
- `created_at` - 记录创建时间
---
## 软件运行机制
### 启动流程
1. **读取许可证文件**: 加载并解密本地许可证文件,验证数字签名防止篡改
2. **配置检查**:
- 如果联网,比较许可证内的 `config_updated_at` 与服务端 `updated_at`
- 如有差异则下载新的加密许可证文件替换本地文件
3. **配置验证**: 检查许可证内的授权有效期和功能权限
4. **应用配置**: 根据许可证内的配置启用/禁用功能模块
5. **开始运行**: 按许可证配置限制控制资源使用
### 心跳流程
1. **发送心跳**: 携带授权码 + 硬件指纹 + 使用统计 + 许可证内的配置更新时间
2. **状态验证**: 服务端验证授权码和许可证状态
3. **配置比较**: 比较客户端上报的配置更新时间与服务端当前时间
4. **返回响应**:
- 如配置有更新:返回新的加密许可证文件
- 无更新:仅返回状态确认和下次心跳间隔
5. **更新许可证**: 如有配置更新,客户端替换本地许可证文件
### 许可证生成逻辑
1. **请求验证**: 软件发送授权码 + 硬件指纹
2. **授权码检查**: 验证授权码有效性和状态
3. **许可证查找**: 检查该硬件是否已有许可证
4. **数量限制检查**: 如果是新硬件,检查当前激活数 < max_activations
5. **许可证生成**: 生成包含完整配置的加密许可证文件
6. **返回数据**: 返回加密的许可证文件
### 配置同步机制
1. **管理员修改授权码配置**: 更新authorization_codes表,同时更新 `updated_at` 时间戳
2. **客户端检测**: 通过心跳或启动时比较许可证内的配置更新时间
3. **许可证更新**: 发现配置有更新时,客户端下载新的加密许可证文件
4. **文件替换**: 用新的许可证文件替换本地文件
5. **应用新配置**: 软件重新加载许可证文件中的配置
### 并发控制
```sql
-- 原子性操作:检查并生成许可证
BEGIN TRANSACTION;
SELECT COUNT(*) FROM licenses WHERE authorization_code = ? AND status = 'active';
-- 如果未超限,则INSERT新许可证
INSERT INTO licenses (license_key, authorization_code, hardware_fingerprint, config_updated_at, ...) VALUES (...);
COMMIT;
```
### 离线运行支持
1. **自包含许可证**: 许可证文件包含完整的加密配置信息
2. **有效期检查**: 软件自行验证许可证内的授权是否在有效期内
3. **功能控制**: 根据许可证文件中的配置控制功能可用性
4. **网络恢复**: 连网后通过心跳自动检查并更新许可证文件
### 业务场景支持
1. **多机部署** - 同一授权码在不同硬件上自动激活
2. **离线运行** - 软件使用许可证文件完全离线运行
3. **配置更新** - 授权码配置变更,心跳时推送新许可证文件给所有激活设备
4. **数量控制** - 通过max_activations控制并发许可证数量
5. **硬件更换** - 新硬件指纹触发新许可证生成
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
+17 -2
View File
@@ -5,15 +5,20 @@ go 1.23
require (
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.4.0
github.com/sirupsen/logrus v1.9.3
github.com/spf13/viper v1.19.0
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.3
golang.org/x/crypto v0.28.0
gopkg.in/yaml.v2 v2.4.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.30.1
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/PuerkitoBio/purell v1.1.1 // indirect
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect
@@ -24,6 +29,7 @@ require (
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
@@ -31,12 +37,16 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/go-sql-driver/mysql v1.8.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.6 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -44,25 +54,30 @@ 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/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
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.25.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/text v0.19.0 // indirect
golang.org/x/text v0.20.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.34.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+40 -4
View File
@@ -1,3 +1,5 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
@@ -29,6 +31,8 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@@ -47,15 +51,24 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -73,6 +86,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
@@ -95,12 +110,18 @@ github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
@@ -134,11 +155,17 @@ github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+z
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg=
github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
@@ -164,10 +191,12 @@ golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -175,6 +204,8 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -185,14 +216,15 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -208,5 +240,9 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+50 -28
View File
@@ -3,9 +3,12 @@ package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
@@ -34,34 +37,41 @@ func NewAuthHandler(authService service.AuthService) *AuthHandler {
func (h *AuthHandler) Login(c *gin.Context) {
var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: http.StatusBadRequest,
Error: "INVALID_REQUEST",
Message: "请求参数无效",
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
data, err := h.authService.Login(&req)
// 获取客户端IP
clientIP := c.ClientIP()
data, err := h.authService.Login(c.Request.Context(), &req, clientIP)
if err != nil {
statusCode := http.StatusUnauthorized
errorCode := "100003" // 登录失败
if err.Error() == "配置未初始化" {
statusCode = http.StatusInternalServerError
errorCode = "900004" // 服务器内部错误
}
c.JSON(statusCode, models.ErrorResponse{
Code: statusCode,
Error: "LOGIN_FAILED",
Message: err.Error(),
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse(errorCode, 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.LoginResponse{
Code: http.StatusOK,
Message: "登录成功",
Code: "000000",
Message: successMessage,
Data: data,
})
}
@@ -80,9 +90,11 @@ func (h *AuthHandler) Logout(c *gin.Context) {
// 对于JWT无状态认证,客户端删除Token即可
// 这里可以记录登出日志或将Token加入黑名单(可选实现)
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: http.StatusOK,
Message: "登出成功",
Code: "000000",
Message: successMessage,
})
}
@@ -101,10 +113,11 @@ func (h *AuthHandler) RefreshToken(c *gin.Context) {
// 从请求头获取Token
token := extractTokenFromHeader(c)
if token == "" {
c.JSON(http.StatusUnauthorized, models.ErrorResponse{
Code: http.StatusUnauthorized,
Error: "TOKEN_MISSING",
Message: "认证令牌缺失",
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("100004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
@@ -112,18 +125,27 @@ func (h *AuthHandler) RefreshToken(c *gin.Context) {
newToken, err := h.authService.RefreshToken(token)
if err != nil {
c.JSON(http.StatusUnauthorized, models.ErrorResponse{
Code: http.StatusUnauthorized,
Error: "TOKEN_REFRESH_FAILED",
Message: "刷新令牌失败",
// 根据错误类型选择合适的认证错误码
errorCode := "100001" // token过期,可以刷新
if err.Error() == "token无效" {
errorCode = "100002" // token无效,需要重新登录
}
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse(errorCode, 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: http.StatusOK,
Message: "刷新成功",
Code: "000000",
Message: successMessage,
Data: map[string]interface{}{
"token": newToken,
},
@@ -142,4 +164,4 @@ func extractTokenFromHeader(c *gin.Context) string {
return authHeader[7:]
}
return ""
}
}
@@ -0,0 +1,646 @@
package handlers
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"license-manager/internal/api/middleware"
"license-manager/internal/config"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"github.com/gin-gonic/gin"
)
type AuthorizationCodeHandler struct {
authCodeService service.AuthorizationCodeService
}
// NewAuthorizationCodeHandler 创建授权码处理器
func NewAuthorizationCodeHandler(authCodeService service.AuthorizationCodeService) *AuthorizationCodeHandler {
return &AuthorizationCodeHandler{
authCodeService: authCodeService,
}
}
// CreateAuthorizationCode 创建授权码
// @Summary 创建授权码
// @Description 创建新的授权码,自动生成授权码字符串。有效期从当天00:00:00开始,到指定天数后的23:59:59结束
// @Tags 授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param authorization_code body models.AuthorizationCodeCreateRequest true "授权码信息"
// @Success 200 {object} models.APIResponse{data=models.AuthorizationCodeCreateResponse} "创建成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "客户不存在"
// @Failure 409 {object} models.ErrorResponse "授权码已存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/authorization-codes [post]
func (h *AuthorizationCodeHandler) CreateAuthorizationCode(c *gin.Context) {
var req models.AuthorizationCodeCreateRequest
if err := c.ShouldBindJSON(&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: time.Now().Format(time.RFC3339),
})
return
}
// 直接传递 gin.Context
data, err := h.authCodeService.CreateAuthorizationCode(c, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// GetAuthorizationCodeList 查询授权码列表
// @Summary 查询授权码列表
// @Description 分页查询授权码列表,支持筛选和排序
// @Tags 授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @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 status query string false "状态筛选" Enums(normal, locked, expired)
// @Param start_date query string false "创建开始时间"
// @Param end_date query string false "创建结束时间"
// @Param sort query string false "排序字段,默认created_at" Enums(created_at, updated_at, code)
// @Param order query string false "排序方向,默认desc" Enums(asc, desc)
// @Success 200 {object} models.APIResponse{data=models.AuthorizationCodeListResponse} "查询成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/authorization-codes [get]
func (h *AuthorizationCodeHandler) GetAuthorizationCodeList(c *gin.Context) {
var req models.AuthorizationCodeListRequest
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: time.Now().Format(time.RFC3339),
})
return
}
// 直接传递 gin.Context
data, err := h.authCodeService.GetAuthorizationCodeList(c, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// GetAuthorizationCode 获取授权码详情
// @Summary 获取授权码详情
// @Description 根据授权码ID获取授权码详细信息
// @Tags 授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "授权码ID"
// @Success 200 {object} models.APIResponse{data=models.AuthorizationCode} "查询成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/authorization-codes/{id} [get]
func (h *AuthorizationCodeHandler) GetAuthorizationCode(c *gin.Context) {
id := c.Param("id")
if id == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
// 直接传递 gin.Context
data, err := h.authCodeService.GetAuthorizationCode(c, id)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// DownloadAuthorizationFile 下载授权文件
// @Summary 下载授权文件
// @Description 下载授权码文件,包含授权码和RSA公钥,供客户端使用
// @Tags 授权码管理
// @Accept json
// @Produce application/zip
// @Security BearerAuth
// @Param id path string true "授权码ID"
// @Success 200 {file} binary "包含授权码文件和RSA公钥的压缩包"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
// @Failure 409 {object} models.ErrorResponse "授权码不可用"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/authorization-codes/{id}/download [get]
func (h *AuthorizationCodeHandler) DownloadAuthorizationFile(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
if id == "" {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
ctx := middleware.WithLanguage(c.Request.Context(), c)
fileData, fileName, authCode, err := h.authCodeService.GenerateAuthorizationFile(ctx, id)
if err != nil {
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
cfg := config.GetConfig()
if cfg == nil || cfg.License.RSA.PublicKeyPath == "" {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
publicKeyPath := filepath.Clean(cfg.License.RSA.PublicKeyPath)
publicKeyData, err := os.ReadFile(publicKeyPath)
if err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
var buffer bytes.Buffer
zipWriter := zip.NewWriter(&buffer)
const zipAuthFileName = "authorization_code.txt"
authFileWriter, err := zipWriter.Create(zipAuthFileName)
if err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
if _, err := authFileWriter.Write(fileData); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
publicKeyWriter, err := zipWriter.Create("rsa_public_key.pem")
if err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
if _, err := publicKeyWriter.Write(publicKeyData); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
if err := zipWriter.Close(); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
packageFileName := fmt.Sprintf("authorization_package_%s.zip", strings.TrimSpace(authCode))
if strings.TrimSpace(authCode) == "" {
baseName := strings.TrimSuffix(fileName, filepath.Ext(fileName))
packageFileName = fmt.Sprintf("%s_package.zip", baseName)
}
c.Header("Content-Description", "File Transfer")
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", packageFileName))
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Cache-Control", "must-revalidate")
c.Header("Pragma", "public")
c.Data(http.StatusOK, "application/zip", buffer.Bytes())
}
// UpdateAuthorizationCode 更新授权码
// @Summary 更新授权码
// @Description 更新授权码信息,所有字段都是可选的,需要指定变更类型和原因
// @Tags 授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "授权码ID"
// @Param authorization_code body models.AuthorizationCodeUpdateRequest true "授权码信息"
// @Success 200 {object} models.APIResponse{data=models.AuthorizationCode} "更新成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/authorization-codes/{id} [put]
func (h *AuthorizationCodeHandler) UpdateAuthorizationCode(c *gin.Context) {
id := c.Param("id")
if id == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
var req models.AuthorizationCodeUpdateRequest
if err := c.ShouldBindJSON(&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: time.Now().Format(time.RFC3339),
})
return
}
// 直接传递 gin.Context
authCode, err := h.authCodeService.UpdateAuthorizationCode(c, id, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: authCode,
})
}
// LockUnlockAuthorizationCode 锁定/解锁授权码
// @Summary 锁定/解锁授权码
// @Description 锁定或解锁指定的授权码,可以设置锁定原因
// @Tags 授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "授权码ID"
// @Param lock_request body models.AuthorizationCodeLockRequest true "锁定/解锁信息"
// @Success 200 {object} models.APIResponse{data=models.AuthorizationCode} "操作成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/authorization-codes/{id}/lock [put]
func (h *AuthorizationCodeHandler) LockUnlockAuthorizationCode(c *gin.Context) {
id := c.Param("id")
if id == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
var req models.AuthorizationCodeLockRequest
if err := c.ShouldBindJSON(&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: time.Now().Format(time.RFC3339),
})
return
}
// 直接传递 gin.Context
data, err := h.authCodeService.LockUnlockAuthorizationCode(c, id, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// DeleteAuthorizationCode 删除授权码
// @Summary 删除授权码
// @Description 删除指定的授权码(软删除)
// @Tags 授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "授权码ID"
// @Success 200 {object} models.APIResponse "删除成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/authorization-codes/{id} [delete]
func (h *AuthorizationCodeHandler) DeleteAuthorizationCode(c *gin.Context) {
id := c.Param("id")
if id == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
// 直接传递 gin.Context
if err := h.authCodeService.DeleteAuthorizationCode(c, id); err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// GetAuthorizationChangeList 查询授权变更历史列表
// @Summary 查询授权变更历史列表
// @Description 分页查询指定授权码的变更历史,支持筛选
// @Tags 授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "授权码ID"
// @Param page query int false "页码,默认1" minimum(1)
// @Param page_size query int false "每页条数,默认20,最大100" minimum(1) maximum(100)
// @Param change_type query string false "变更类型筛选"
// @Param operator_id query string false "操作人筛选"
// @Param start_date query string false "开始时间 (YYYY-MM-DD格式)"
// @Param end_date query string false "结束时间 (YYYY-MM-DD格式)"
// @Param sort query string false "排序字段,默认created_at" Enums(created_at, change_type)
// @Param order query string false "排序方向,默认desc" Enums(asc, desc)
// @Success 200 {object} models.APIResponse{data=models.AuthorizationChangeListResponse} "查询成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/authorization-codes/{id}/changes [get]
func (h *AuthorizationCodeHandler) GetAuthorizationChangeList(c *gin.Context) {
id := c.Param("id")
if id == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
var req models.AuthorizationChangeListRequest
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: time.Now().Format(time.RFC3339),
})
return
}
// 直接传递 gin.Context
data, err := h.authCodeService.GetAuthorizationChangeList(c, id, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
@@ -0,0 +1,430 @@
package handlers
import (
"errors"
"net/http"
"time"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"github.com/gin-gonic/gin"
)
type CustomerHandler struct {
customerService service.CustomerService
}
// NewCustomerHandler 创建客户处理器
func NewCustomerHandler(customerService service.CustomerService) *CustomerHandler {
return &CustomerHandler{
customerService: customerService,
}
}
// GetCustomerList 查询客户列表
// @Summary 查询客户列表
// @Description 分页查询客户列表,支持搜索和筛选
// @Tags 客户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码,默认1" minimum(1)
// @Param page_size query int false "每页条数,默认20,最大100" minimum(1) maximum(100)
// @Param search query string false "搜索关键词(支持客户编码、名称、联系人、邮箱)"
// @Param customer_type query string false "客户类型筛选" Enums(individual, enterprise, government, education)
// @Param customer_level query string false "客户等级筛选" Enums(normal, vip, enterprise, strategic)
// @Param status query string false "状态筛选" Enums(active, disabled)
// @Param sort query string false "排序字段,默认created_at" Enums(created_at, updated_at, customer_name, customer_code)
// @Param order query string false "排序方向,默认desc" Enums(asc, desc)
// @Success 200 {object} models.APIResponse{data=models.CustomerListResponse} "查询成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/customers [get]
func (h *CustomerHandler) GetCustomerList(c *gin.Context) {
var req models.CustomerListRequest
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: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.customerService.GetCustomerList(ctx, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// GetCustomer 获取客户详情
// @Summary 获取客户详情
// @Description 根据客户ID获取客户详细信息
// @Tags 客户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "客户ID"
// @Success 200 {object} models.APIResponse{data=models.Customer} "查询成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "客户不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/customers/{id} [get]
func (h *CustomerHandler) GetCustomer(c *gin.Context) {
id := c.Param("id")
if id == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.customerService.GetCustomer(ctx, id)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// CreateCustomer 创建客户
// @Summary 创建客户
// @Description 创建新的客户记录,自动生成客户编码
// @Tags 客户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param customer body models.CustomerCreateRequest true "客户信息"
// @Success 200 {object} models.APIResponse{data=models.Customer} "创建成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 409 {object} models.ErrorResponse "客户已存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/customers [post]
func (h *CustomerHandler) CreateCustomer(c *gin.Context) {
var req models.CustomerCreateRequest
if err := c.ShouldBindJSON(&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: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.customerService.CreateCustomer(ctx, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// UpdateCustomer 更新客户信息
// @Summary 更新客户信息
// @Description 更新客户信息,所有字段都是可选的
// @Tags 客户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "客户ID"
// @Param customer body models.CustomerUpdateRequest true "客户信息"
// @Success 200 {object} models.APIResponse{data=models.Customer} "更新成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "客户不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/customers/{id} [put]
func (h *CustomerHandler) UpdateCustomer(c *gin.Context) {
id := c.Param("id")
if id == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
var req models.CustomerUpdateRequest
if err := c.ShouldBindJSON(&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: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.customerService.UpdateCustomer(ctx, id, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// DeleteCustomer 删除客户
// @Summary 删除客户
// @Description 直接删除客户记录,删除前会检查是否有关联的授权码或许可证,如有则提示先删除授权
// @Tags 客户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "客户ID"
// @Success 200 {object} models.APIResponse "删除成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "客户不存在"
// @Failure 409 {object} models.ErrorResponse "客户仍有授权,无法删除"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/customers/{id} [delete]
func (h *CustomerHandler) DeleteCustomer(c *gin.Context) {
id := c.Param("id")
if id == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
err := h.customerService.DeleteCustomer(ctx, id)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// UpdateCustomerStatus 修改客户状态
// @Summary 修改客户状态
// @Description 修改客户状态(激活/停用)
// @Tags 客户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "客户ID"
// @Param status body models.CustomerStatusUpdateRequest true "状态信息"
// @Success 200 {object} models.APIResponse{data=models.Customer} "状态更新成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "客户不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/customers/{id}/status [patch]
func (h *CustomerHandler) UpdateCustomerStatus(c *gin.Context) {
id := c.Param("id")
if id == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
var req models.CustomerStatusUpdateRequest
if err := c.ShouldBindJSON(&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: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.customerService.UpdateCustomerStatus(ctx, id, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
@@ -0,0 +1,159 @@
package handlers
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
)
type DashboardHandler struct {
dashboardService service.DashboardService
}
// NewDashboardHandler 创建仪表盘处理器
func NewDashboardHandler(dashboardService service.DashboardService) *DashboardHandler {
return &DashboardHandler{
dashboardService: dashboardService,
}
}
// GetAuthorizationTrend 获取授权趋势数据
// @Summary 获取授权趋势数据
// @Description 根据时间类型(本周/本月/自定义)获取授权趋势统计数据
// @Tags 仪表盘
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param type query string true "时间类型" Enums(week,month,custom)
// @Param start_date query string false "开始日期(YYYY-MM-DD格式,当type为custom时必填)"
// @Param end_date query string false "结束日期(YYYY-MM-DD格式,当type为custom时必填)"
// @Param timezone query string false "时区(如:Asia/Shanghai,UTC等,默认使用服务器本地时区)"
// @Success 200 {object} models.APIResponse{data=models.DashboardAuthorizationTrendResponse} "授权趋势数据"
// @Failure 400 {object} models.ErrorResponse "请求参数错误"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/dashboard/authorization-trend [get]
func (h *DashboardHandler) GetAuthorizationTrend(c *gin.Context) {
// 绑定查询参数
var req models.DashboardAuthorizationTrendRequest
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: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
// 调用服务层获取数据
response, err := h.dashboardService.GetAuthorizationTrend(ctx, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
// 返回成功响应
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: response,
})
}
// GetRecentAuthorizations 获取最近授权列表
// @Summary 获取最近授权列表
// @Description 获取最近创建的授权码列表,支持按客户ID和状态筛选
// @Tags 仪表盘
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param limit query int false "返回数量限制(默认20,最大100)"
// @Param customer_id query string false "客户ID筛选"
// @Param status query string false "状态筛选" Enums(normal,locked,expired)
// @Success 200 {object} models.APIResponse{data=models.DashboardRecentAuthorizationsResponse} "最近授权列表"
// @Failure 400 {object} models.ErrorResponse "请求参数错误"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/dashboard/recent-authorizations [get]
func (h *DashboardHandler) GetRecentAuthorizations(c *gin.Context) {
// 绑定查询参数
var req models.DashboardRecentAuthorizationsRequest
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: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
// 调用服务层获取数据
response, err := h.dashboardService.GetRecentAuthorizations(ctx, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
// 返回成功响应
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: response,
})
}
@@ -0,0 +1,133 @@
package handlers
import (
"errors"
"net/http"
"time"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"github.com/gin-gonic/gin"
)
type EnumHandler struct {
enumService service.EnumService
}
// NewEnumHandler 创建枚举处理器
func NewEnumHandler(enumService service.EnumService) *EnumHandler {
return &EnumHandler{
enumService: enumService,
}
}
// GetAllEnums 获取所有枚举类型及其值
// @Summary 获取所有枚举值
// @Description 获取系统中所有枚举类型及其对应的多语言显示值
// @Tags 枚举管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.EnumListResponse} "查询成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/enums [get]
func (h *EnumHandler) GetAllEnums(c *gin.Context) {
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.enumService.GetAllEnums(ctx)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// GetEnumsByType 获取指定类型的枚举值
// @Summary 获取指定类型的枚举值
// @Description 根据枚举类型获取对应的多语言显示值
// @Tags 枚举管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param type path string true "枚举类型" Enums(customer_type, customer_level, customer_status, company_size)
// @Success 200 {object} models.APIResponse{data=models.EnumTypeResponse} "查询成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/enums/{type} [get]
func (h *EnumHandler) GetEnumsByType(c *gin.Context) {
enumType := c.Param("type")
if enumType == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": enum type is required",
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.enumService.GetEnumsByType(ctx, enumType)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
@@ -0,0 +1,625 @@
package handlers
import (
"archive/zip"
"bytes"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"license-manager/internal/api/middleware"
"license-manager/internal/config"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"github.com/gin-gonic/gin"
)
type LicenseHandler struct {
licenseService service.LicenseService
}
// NewLicenseHandler 创建许可证处理器
func NewLicenseHandler(licenseService service.LicenseService) *LicenseHandler {
return &LicenseHandler{
licenseService: licenseService,
}
}
// GetLicenseList 查询许可证列表
// @Summary 查询许可证列表
// @Description 分页查询许可证列表,支持筛选
// @Tags 许可证管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码,默认1" minimum(1)
// @Param page_size query int false "每页条数,默认20,最大100" minimum(1) maximum(100)
// @Param authorization_code_id query string false "授权码ID筛选"
// @Param customer_id query string false "客户ID筛选"
// @Param status query string false "状态筛选" Enums(active, inactive, revoked)
// @Param is_online query bool false "在线状态筛选"
// @Param sort query string false "排序字段,默认created_at" Enums(created_at, updated_at, activated_at, last_heartbeat)
// @Param order query string false "排序方向,默认desc" Enums(asc, desc)
// @Success 200 {object} models.APIResponse{data=models.LicenseListResponse} "查询成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/licenses [get]
func (h *LicenseHandler) GetLicenseList(c *gin.Context) {
var req models.LicenseListRequest
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: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.licenseService.GetLicenseList(ctx, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// GetLicense 获取许可证详情
// @Summary 获取许可证详情
// @Description 根据许可证ID获取许可证详细信息
// @Tags 许可证管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "许可证ID"
// @Success 200 {object} models.APIResponse{data=models.LicenseDetailResponse} "查询成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "许可证不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/licenses/{id} [get]
func (h *LicenseHandler) GetLicense(c *gin.Context) {
id := c.Param("id")
if id == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.licenseService.GetLicense(ctx, id)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// CreateLicense 手动添加许可证
// @Summary 手动添加许可证
// @Description 为指定授权码手动创建许可证
// @Tags 许可证管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body models.LicenseCreateRequest true "许可证创建信息"
// @Success 201 {object} models.APIResponse{data=models.License} "创建成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/licenses [post]
func (h *LicenseHandler) CreateLicense(c *gin.Context) {
var req models.LicenseCreateRequest
if err := c.ShouldBindJSON(&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: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.licenseService.CreateLicense(ctx, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusCreated, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// RevokeLicense 撤销许可证
// @Summary 撤销许可证
// @Description 撤销指定的许可证,撤销后无法再使用
// @Tags 许可证管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "许可证ID"
// @Param request body models.LicenseRevokeRequest true "撤销原因"
// @Success 200 {object} models.APIResponse{data=models.License} "撤销成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "许可证不存在"
// @Failure 409 {object} models.ErrorResponse "许可证已被撤销"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/licenses/{id}/revoke [put]
func (h *LicenseHandler) RevokeLicense(c *gin.Context) {
id := c.Param("id")
if id == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
var req models.LicenseRevokeRequest
if err := c.ShouldBindJSON(&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: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.licenseService.RevokeLicense(ctx, id, &req)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// DownloadLicenseFile 下载许可证文件
// @Summary 下载许可证文件
// @Description 下载加密的许可证文件,用于客户端软件激活
// @Tags 许可证管理
// @Accept json
// @Produce application/zip
// @Security BearerAuth
// @Param id path string true "许可证ID"
// @Success 200 {file} binary "包含许可证文件和RSA公钥的压缩包"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "许可证不存在"
// @Failure 409 {object} models.ErrorResponse "许可证已被撤销"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/licenses/{id}/download [get]
func (h *LicenseHandler) DownloadLicenseFile(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
if id == "" {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
fileData, fileName, licenseKey, err := h.licenseService.GenerateLicenseFile(ctx, id)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
cfg := config.GetConfig()
if cfg == nil || cfg.License.RSA.PublicKeyPath == "" {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
publicKeyPath := filepath.Clean(cfg.License.RSA.PublicKeyPath)
publicKeyData, err := os.ReadFile(publicKeyPath)
if err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
var buffer bytes.Buffer
zipWriter := zip.NewWriter(&buffer)
const zipLicenseFileName = "license.lic"
licenseFileWriter, err := zipWriter.Create(zipLicenseFileName)
if err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
if _, err := licenseFileWriter.Write(fileData); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
publicKeyWriter, err := zipWriter.Create("rsa_public_key.pem")
if err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
if _, err := publicKeyWriter.Write(publicKeyData); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
if err := zipWriter.Close(); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
return
}
packageFileName := fmt.Sprintf("license_package_%s.zip", strings.TrimSpace(licenseKey))
if strings.TrimSpace(licenseKey) == "" {
baseName := strings.TrimSuffix(fileName, filepath.Ext(fileName))
packageFileName = fmt.Sprintf("%s_package.zip", baseName)
}
// 设置响应头,指示这是一个文件下载
c.Header("Content-Description", "File Transfer")
c.Header("Content-Type", "application/zip")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", packageFileName))
c.Header("Content-Transfer-Encoding", "binary")
c.Header("Cache-Control", "must-revalidate")
c.Header("Pragma", "public")
// 返回文件内容
c.Data(http.StatusOK, "application/zip", buffer.Bytes())
}
// ActivateLicense 激活许可证
// @Summary 激活许可证
// @Description 客户端使用授权码激活软件,获取许可证文件
// @Tags 许可证激活
// @Accept json
// @Produce json
// @Param request body models.ActivateRequest true "激活请求"
// @Success 200 {object} models.APIResponse{data=models.ActivateResponse} "激活成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 404 {object} models.ErrorResponse "授权码不存在"
// @Failure 409 {object} models.ErrorResponse "授权码已锁定或已过期"
// @Failure 429 {object} models.ErrorResponse "激活数量已达上限"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/activate [post]
func (h *LicenseHandler) ActivateLicense(c *gin.Context) {
var req models.ActivateRequest
if err := c.ShouldBindJSON(&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: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
// 获取客户端IP
clientIP := c.ClientIP()
data, err := h.licenseService.ActivateLicense(ctx, &req, clientIP)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// Heartbeat 心跳检测
// @Summary 心跳检测
// @Description 客户端定期发送心跳,更新在线状态和使用数据
// @Tags 许可证激活
// @Accept json
// @Produce json
// @Param request body models.HeartbeatRequest true "心跳请求"
// @Success 200 {object} models.APIResponse{data=models.HeartbeatResponse} "心跳成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 404 {object} models.ErrorResponse "许可证不存在"
// @Failure 409 {object} models.ErrorResponse "许可证已被撤销"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/heartbeat [post]
func (h *LicenseHandler) Heartbeat(c *gin.Context) {
var req models.HeartbeatRequest
if err := c.ShouldBindJSON(&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: time.Now().Format(time.RFC3339),
})
return
}
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
// 获取客户端IP
clientIP := c.ClientIP()
data, err := h.licenseService.Heartbeat(ctx, &req, clientIP)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// GetStatsOverview 获取授权概览统计
// @Summary 获取授权概览统计
// @Description 获取授权码、许可证的总体统计信息
// @Tags 统计分析
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.StatsOverviewResponse} "查询成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/stats/overview [get]
func (h *LicenseHandler) GetStatsOverview(c *gin.Context) {
// 设置语言到Context中
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.licenseService.GetStatsOverview(ctx)
if err != nil {
// 错误已经在Service层完全包装好了,直接使用
var i18nErr *i18n.I18nError
if errors.As(err, &i18nErr) {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: time.Now().Format(time.RFC3339),
})
} else {
// 兜底:理论上不应该到这里,因为Service层应该返回I18nError
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: time.Now().Format(time.RFC3339),
})
}
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
@@ -47,7 +47,7 @@ func (h *SystemHandler) GetSystemInfo(c *gin.Context) {
systemInfo := h.systemService.GetSystemInfo()
c.JSON(http.StatusOK, models.APIResponse{
Code: http.StatusOK,
Code: "000000",
Message: "获取成功",
Data: systemInfo,
})
+5 -2
View File
@@ -7,10 +7,13 @@ import (
// CORSMiddleware 跨域中间件
func CORSMiddleware() gin.HandlerFunc {
return gin.HandlerFunc(func(c *gin.Context) {
// 允许所有域名访问
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Credentials", "true")
c.Header("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE")
c.Header("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH")
c.Header("Access-Control-Max-Age", "86400") // 24小时
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
@@ -19,4 +22,4 @@ func CORSMiddleware() gin.HandlerFunc {
c.Next()
})
}
}
+222
View File
@@ -0,0 +1,222 @@
package middleware
import (
"context"
"strings"
"license-manager/pkg/i18n"
pkgcontext "license-manager/pkg/context"
"github.com/gin-gonic/gin"
)
const (
// LanguageKey 存储语言信息的上下文键
LanguageKey = "language"
// DefaultLanguage 默认语言
DefaultLanguage = "zh-CN"
)
// I18nConfig 多语言中间件配置
type I18nConfig struct {
Enable bool `yaml:"enable"` // 是否启用多语言
DefaultLang string `yaml:"default_lang"` // 默认语言
SupportLangs []string `yaml:"support_langs"` // 支持的语言列表
}
// I18nMiddleware 多语言检测中间件
func I18nMiddleware(config *I18nConfig) gin.HandlerFunc {
// 如果未启用多语言,使用默认语言
if config == nil || !config.Enable {
return func(c *gin.Context) {
c.Set(LanguageKey, config.DefaultLang)
c.Next()
}
}
return func(c *gin.Context) {
lang := detectLanguage(c, config)
c.Set(LanguageKey, lang)
c.Next()
}
}
// detectLanguage 检测客户端语言偏好
// 按优先级顺序:URL参数 > Accept-Language头 > 用户配置 > 系统默认
func detectLanguage(c *gin.Context, config *I18nConfig) string {
// 1. 检查URL参数 ?lang=en-US (最高优先级)
if lang := c.Query("lang"); lang != "" {
if isLanguageSupported(lang, config.SupportLangs) {
return lang
}
}
// 2. 检查HTTP头 Accept-Language
if acceptLang := c.GetHeader("Accept-Language"); acceptLang != "" {
if lang := parseAcceptLanguageHeader(acceptLang, config.SupportLangs); lang != "" {
return lang
}
}
// 3. 检查用户配置(如果有用户身份验证)
// 这里可以从用户数据库或JWT token中获取用户语言偏好
if userLang := getUserLanguagePreference(c); userLang != "" {
if isLanguageSupported(userLang, config.SupportLangs) {
return userLang
}
}
// 4. 返回系统默认语言
return config.DefaultLang
}
// parseAcceptLanguageHeader 解析Accept-Language头部
// 例:zh-CN,zh;q=0.9,en;q=0.8,ja;q=0.7
func parseAcceptLanguageHeader(acceptLang string, supportedLangs []string) string {
if acceptLang == "" {
return ""
}
// 解析Accept-Language头部
languages := strings.Split(acceptLang, ",")
// 按权重排序的语言偏好
for _, langWithQ := range languages {
// 移除权重信息,例如 "zh-CN;q=0.9" -> "zh-CN"
lang := strings.TrimSpace(langWithQ)
if idx := strings.Index(lang, ";"); idx != -1 {
lang = lang[:idx]
}
lang = strings.TrimSpace(lang)
// 检查是否支持该语言
if isLanguageSupported(lang, supportedLangs) {
return lang
}
// 尝试匹配语言的主要部分,例如 "zh-CN" -> "zh"
if idx := strings.Index(lang, "-"); idx != -1 {
primaryLang := lang[:idx]
// 查找支持的语言中是否有相同主要语言
for _, supported := range supportedLangs {
if strings.HasPrefix(supported, primaryLang+"-") {
return supported
}
}
}
}
return ""
}
// getUserLanguagePreference 获取用户语言偏好(从用户配置或JWT中)
func getUserLanguagePreference(c *gin.Context) string {
// 这里可以实现从JWT token或用户数据库中获取用户语言偏好
// 示例实现:从JWT Claims中获取语言偏好
// 1. 从JWT token中获取(如果有认证中间件)
if userID, exists := c.Get("user_id"); exists && userID != nil {
// 这里可以查询数据库获取用户的语言偏好
// userLang := getUserLanguageFromDB(userID.(string))
// return userLang
_ = userID // 暂时未实现
}
// 2. 从自定义头部获取
if userLang := c.GetHeader("X-User-Language"); userLang != "" {
return userLang
}
return ""
}
// isLanguageSupported 检查语言是否在支持列表中
func isLanguageSupported(lang string, supportedLangs []string) bool {
if len(supportedLangs) == 0 {
// 如果没有配置支持语言列表,检查i18n管理器
return i18n.IsLanguageSupported(lang)
}
for _, supported := range supportedLangs {
if supported == lang {
return true
}
}
return false
}
// GetLanguage 从gin.Context中获取语言设置
func GetLanguage(c *gin.Context) string {
if lang, exists := c.Get(LanguageKey); exists {
if langStr, ok := lang.(string); ok {
return langStr
}
}
return DefaultLanguage
}
// SetLanguage 设置当前请求的语言
func SetLanguage(c *gin.Context, lang string) {
c.Set(LanguageKey, lang)
}
// 便利函数:创建不同配置的中间件
// DefaultI18nMiddleware 使用默认配置的多语言中间件
func DefaultI18nMiddleware() gin.HandlerFunc {
config := &I18nConfig{
Enable: true,
DefaultLang: "zh-CN",
SupportLangs: []string{"zh-CN", "en-US", "ja-JP"},
}
return I18nMiddleware(config)
}
// SimpleI18nMiddleware 简化的多语言中间件,仅支持指定语言
func SimpleI18nMiddleware(defaultLang string, supportedLangs ...string) gin.HandlerFunc {
config := &I18nConfig{
Enable: true,
DefaultLang: defaultLang,
SupportLangs: supportedLangs,
}
return I18nMiddleware(config)
}
// DisableI18nMiddleware 禁用多语言的中间件,总是使用默认语言
func DisableI18nMiddleware(defaultLang string) gin.HandlerFunc {
return func(c *gin.Context) {
c.Set(LanguageKey, defaultLang)
c.Next()
}
}
// I18nResponseHelper 多语言响应辅助函数
type I18nResponseHelper struct {
Context *gin.Context
}
// NewI18nResponseHelper 创建多语言响应辅助器
func NewI18nResponseHelper(c *gin.Context) *I18nResponseHelper {
return &I18nResponseHelper{Context: c}
}
// GetLang 获取当前请求的语言
func (h *I18nResponseHelper) GetLang() string {
return GetLanguage(h.Context)
}
// ErrorResponse 创建多语言错误响应
func (h *I18nResponseHelper) ErrorResponse(code string, customMessage ...string) (int, string, string) {
lang := h.GetLang()
return i18n.NewI18nErrorResponse(code, lang, customMessage...)
}
// Context相关工具函数
// WithLanguage 将语言信息添加到Context中
func WithLanguage(ctx context.Context, c *gin.Context) context.Context {
lang := GetLanguage(c)
return pkgcontext.WithLanguage(ctx, lang)
}
+89 -13
View File
@@ -4,7 +4,11 @@ import (
_ "license-manager/docs/swagger" // swagger docs
"license-manager/internal/api/handlers"
"license-manager/internal/api/middleware"
"license-manager/internal/config"
"license-manager/internal/database"
"license-manager/internal/repository"
"license-manager/internal/service"
"license-manager/pkg/logger"
"github.com/gin-gonic/gin"
swaggerFiles "github.com/swaggo/files"
@@ -19,40 +23,112 @@ func SetupRouter() *gin.Engine {
router.Use(gin.Recovery())
router.Use(middleware.CORSMiddleware())
// 初始化服务
authService := service.NewAuthService()
systemService := service.NewSystemService()
// 多语言中间件
cfg := config.GetConfig()
if cfg.I18n.Enable {
i18nConfig := &middleware.I18nConfig{
Enable: cfg.I18n.Enable,
DefaultLang: cfg.I18n.DefaultLang,
SupportLangs: cfg.I18n.SupportLangs,
}
router.Use(middleware.I18nMiddleware(i18nConfig))
}
// 初始化处理器
// 初始化数据访问层
db := database.GetDB()
customerRepo := repository.NewCustomerRepository(db)
userRepo := repository.NewUserRepository(db)
authCodeRepo := repository.NewAuthorizationCodeRepository(db)
licenseRepo := repository.NewLicenseRepository(db)
dashboardRepo := repository.NewDashboardRepository(db)
// 获取logger实例
log := logger.GetLogger()
// 初始化服务层
authService := service.NewAuthService(userRepo)
systemService := service.NewSystemService()
customerService := service.NewCustomerService(customerRepo)
enumService := service.NewEnumService()
authCodeService := service.NewAuthorizationCodeService(authCodeRepo, customerRepo, licenseRepo)
licenseService := service.NewLicenseService(licenseRepo, db, log)
dashboardService := service.NewDashboardService(dashboardRepo)
// 初始化处理器层
authHandler := handlers.NewAuthHandler(authService)
systemHandler := handlers.NewSystemHandler(systemService)
customerHandler := handlers.NewCustomerHandler(customerService)
enumHandler := handlers.NewEnumHandler(enumService)
authCodeHandler := handlers.NewAuthorizationCodeHandler(authCodeService)
licenseHandler := handlers.NewLicenseHandler(licenseService)
dashboardHandler := handlers.NewDashboardHandler(dashboardService)
// 健康检测接口(无需认证)
router.GET("/health", systemHandler.HealthCheck)
// Swagger文档路由
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler))
router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler, ginSwagger.URL("/swagger/doc.json")))
// API v1 路由组
v1 := router.Group("/api/v1")
// API路由组
api := router.Group("/api")
{
// 公开接口(无需认证)
public := v1.Group("")
public := api.Group("")
{
public.POST("/login", authHandler.Login)
public.POST("/v1/login", authHandler.Login)
// 许可证激活接口(客户端软件使用)
public.POST("/v1/activate", licenseHandler.ActivateLicense)
public.POST("/v1/heartbeat", licenseHandler.Heartbeat)
}
// 需要认证的接口
auth := v1.Group("")
auth := api.Group("")
auth.Use(middleware.AuthMiddleware())
{
// 认证相关
auth.POST("/logout", authHandler.Logout)
auth.POST("/auth/refresh", authHandler.RefreshToken)
auth.POST("/v1/logout", authHandler.Logout)
auth.POST("/v1/auth/refresh", authHandler.RefreshToken)
// 客户管理
auth.GET("/customers", customerHandler.GetCustomerList)
auth.GET("/customers/:id", customerHandler.GetCustomer)
auth.POST("/customers", customerHandler.CreateCustomer)
auth.PUT("/customers/:id", customerHandler.UpdateCustomer)
auth.DELETE("/customers/:id", customerHandler.DeleteCustomer)
auth.PATCH("/customers/:id/status", customerHandler.UpdateCustomerStatus)
// 枚举管理
auth.GET("/enums", enumHandler.GetAllEnums)
auth.GET("/enums/:type", enumHandler.GetEnumsByType)
// 授权码管理
auth.GET("/v1/authorization-codes", authCodeHandler.GetAuthorizationCodeList)
auth.POST("/v1/authorization-codes", authCodeHandler.CreateAuthorizationCode)
auth.GET("/v1/authorization-codes/:id", authCodeHandler.GetAuthorizationCode)
auth.GET("/v1/authorization-codes/:id/download", authCodeHandler.DownloadAuthorizationFile)
auth.PUT("/v1/authorization-codes/:id", authCodeHandler.UpdateAuthorizationCode)
auth.PUT("/v1/authorization-codes/:id/lock", authCodeHandler.LockUnlockAuthorizationCode)
auth.DELETE("/v1/authorization-codes/:id", authCodeHandler.DeleteAuthorizationCode)
auth.GET("/v1/authorization-codes/:id/changes", authCodeHandler.GetAuthorizationChangeList)
// 许可证管理
auth.GET("/v1/licenses", licenseHandler.GetLicenseList)
auth.GET("/v1/licenses/:id", licenseHandler.GetLicense)
auth.POST("/v1/licenses", licenseHandler.CreateLicense)
auth.PUT("/v1/licenses/:id/revoke", licenseHandler.RevokeLicense)
auth.GET("/v1/licenses/:id/download", licenseHandler.DownloadLicenseFile)
// 统计分析
auth.GET("/v1/stats/overview", licenseHandler.GetStatsOverview)
// 仪表盘接口
auth.GET("/v1/dashboard/authorization-trend", dashboardHandler.GetAuthorizationTrend)
auth.GET("/v1/dashboard/recent-authorizations", dashboardHandler.GetRecentAuthorizations)
}
// 管理员接口
admin := v1.Group("/admin")
admin := api.Group("/v1/admin")
admin.Use(middleware.AuthMiddleware(), middleware.AdminOnlyMiddleware())
{
admin.GET("/system/info", systemHandler.GetSystemInfo)
+84 -24
View File
@@ -8,9 +8,12 @@ import (
)
type Config struct {
Server ServerConfig `mapstructure:"server"`
Auth AuthConfig `mapstructure:"auth"`
Log LogConfig `mapstructure:"log"`
Server ServerConfig `mapstructure:"server"`
Database DatabaseConfig `mapstructure:"database"`
Auth AuthConfig `mapstructure:"auth"`
Log LogConfig `mapstructure:"log"`
I18n I18nConfig `mapstructure:"i18n"`
License LicenseConfig `mapstructure:"license"`
}
type ServerConfig struct {
@@ -19,26 +22,36 @@ type ServerConfig struct {
Mode string `mapstructure:"mode"`
}
type DatabaseConfig struct {
Driver string `mapstructure:"driver"`
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
Database string `mapstructure:"database"`
Charset string `mapstructure:"charset"`
ParseTime bool `mapstructure:"parse_time"`
Loc string `mapstructure:"loc"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
MaxOpenConns int `mapstructure:"max_open_conns"`
ConnMaxLifetime int `mapstructure:"conn_max_lifetime"`
AutoMigrate bool `mapstructure:"auto_migrate"`
}
type AuthConfig struct {
JWT JWTConfig `mapstructure:"jwt"`
Admin AdminConfig `mapstructure:"admin"`
Security SecurityConfig `mapstructure:"security"`
}
type JWTConfig struct {
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"`
RefreshThresholdMinutes int `mapstructure:"refresh_threshold_minutes"`
}
type AdminConfig struct {
Username string `mapstructure:"username"`
Password string `mapstructure:"password"`
Secret string `mapstructure:"secret"`
ExpireHours int `mapstructure:"expire_hours"`
RefreshThresholdMinutes int `mapstructure:"refresh_threshold_minutes"`
}
type SecurityConfig struct {
MaxLoginAttempts int `mapstructure:"max_login_attempts"`
LockoutDurationMinutes int `mapstructure:"lockout_duration_minutes"`
MaxLoginAttempts int `mapstructure:"max_login_attempts"`
LockoutDurationMinutes int `mapstructure:"lockout_duration_minutes"`
}
type LogConfig struct {
@@ -46,12 +59,34 @@ type LogConfig struct {
Format string `mapstructure:"format"`
}
type I18nConfig struct {
Enable bool `mapstructure:"enable"` // 是否启用多语言
DefaultLang string `mapstructure:"default_lang"` // 默认语言
ConfigPath string `mapstructure:"config_path"` // 语言包路径
SupportLangs []string `mapstructure:"support_langs"` // 支持的语言列表
}
type LicenseConfig struct {
// RSA非对称加密配置
RSA RSAConfig `mapstructure:"rsa"`
HeartbeatTimeout int `mapstructure:"heartbeat_timeout"` // 心跳超时时间(秒)
OfflineTimeout int `mapstructure:"offline_timeout"` // 离线超时时间(分钟)
ExpiringDays int `mapstructure:"expiring_days"` // 即将过期天数
}
type RSAConfig struct {
PrivateKeyPath string `mapstructure:"private_key_path"` // RSA私钥文件路径
PublicKeyPath string `mapstructure:"public_key_path"` // RSA公钥文件路径
KeySize int `mapstructure:"key_size"` // 密钥大小(2048或4096),默认2048
}
var AppConfig *Config
func Load(configPath string) error {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
if configPath != "" {
viper.SetConfigFile(configPath)
} else {
@@ -66,11 +101,9 @@ func Load(configPath string) error {
viper.AutomaticEnv()
if err := viper.ReadInConfig(); err != nil {
log.Printf("Warning: Config file not found, using defaults: %v", err)
log.Printf("Searched paths: %v", viper.ConfigFileUsed())
} else {
log.Printf("Using config file: %s", viper.ConfigFileUsed())
return fmt.Errorf("failed to read config file: %w", err)
}
log.Printf("Using config file: %s", viper.ConfigFileUsed())
AppConfig = &Config{}
if err := viper.Unmarshal(AppConfig); err != nil {
@@ -86,22 +119,49 @@ func setDefaults() {
viper.SetDefault("server.port", 18888)
viper.SetDefault("server.mode", "debug")
// Database defaults
viper.SetDefault("database.driver", "mysql")
viper.SetDefault("database.host", "localhost")
viper.SetDefault("database.port", 3306)
viper.SetDefault("database.username", "root")
viper.SetDefault("database.password", "password")
viper.SetDefault("database.database", "license_manager")
viper.SetDefault("database.charset", "utf8mb4")
viper.SetDefault("database.parse_time", true)
viper.SetDefault("database.loc", "Local")
viper.SetDefault("database.max_idle_conns", 10)
viper.SetDefault("database.max_open_conns", 100)
viper.SetDefault("database.conn_max_lifetime", 3600)
viper.SetDefault("database.auto_migrate", true)
// Auth defaults
viper.SetDefault("auth.jwt.secret", "license-manager-default-secret-key")
viper.SetDefault("auth.jwt.expire_hours", 1)
viper.SetDefault("auth.jwt.refresh_threshold_minutes", 30)
viper.SetDefault("auth.admin.username", "admin")
viper.SetDefault("auth.admin.password", "admin@123")
viper.SetDefault("auth.security.max_login_attempts", 5)
viper.SetDefault("auth.security.lockout_duration_minutes", 15)
// Log defaults
viper.SetDefault("log.level", "info")
viper.SetDefault("log.format", "json")
// I18n defaults
viper.SetDefault("i18n.enable", true)
viper.SetDefault("i18n.default_lang", "zh-CN")
viper.SetDefault("i18n.config_path", "../configs/i18n/errors")
viper.SetDefault("i18n.support_langs", []string{"zh-CN", "en-US", "ja-JP"})
// License defaults
viper.SetDefault("license.encryption_key", "license-manager-secret-key-32b") // legacy AES key
viper.SetDefault("license.rsa.private_key_path", "configs/rsa_private_key.pem")
viper.SetDefault("license.rsa.public_key_path", "configs/rsa_public_key.pem")
viper.SetDefault("license.rsa.key_size", 2048)
viper.SetDefault("license.heartbeat_timeout", 300)
viper.SetDefault("license.offline_timeout", 1440)
viper.SetDefault("license.expiring_days", 30)
}
func GetConfig() *Config {
return AppConfig
}
}
+85
View File
@@ -0,0 +1,85 @@
package database
import (
"fmt"
"time"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"license-manager/internal/config"
)
var DB *gorm.DB
// InitDatabase 初始化数据库连接
func InitDatabase(cfg *config.DatabaseConfig) error {
dsn := buildDSN(cfg)
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(getLogLevel()),
})
if err != nil {
return fmt.Errorf("failed to connect database: %w", err)
}
// 获取底层的 SQL DB 实例
sqlDB, err := db.DB()
if err != nil {
return fmt.Errorf("failed to get underlying sql.DB: %w", err)
}
// 设置连接池参数
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
// 测试连接
if err := sqlDB.Ping(); err != nil {
return fmt.Errorf("failed to ping database: %w", err)
}
DB = db
return nil
}
// buildDSN 构建 MySQL 数据源名称
func buildDSN(cfg *config.DatabaseConfig) string {
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=%s&parseTime=%t&loc=%s",
cfg.Username,
cfg.Password,
cfg.Host,
cfg.Port,
cfg.Database,
cfg.Charset,
cfg.ParseTime,
cfg.Loc,
)
}
// getLogLevel 获取 GORM 日志级别
func getLogLevel() logger.LogLevel {
cfg := config.GetConfig()
if cfg != nil && cfg.Server.Mode == "debug" {
return logger.Info
}
return logger.Silent
}
// GetDB 获取数据库实例
func GetDB() *gorm.DB {
return DB
}
// Close 关闭数据库连接
func Close() error {
if DB != nil {
sqlDB, err := DB.DB()
if err != nil {
return err
}
return sqlDB.Close()
}
return nil
}
+116
View File
@@ -0,0 +1,116 @@
package database
import (
"fmt"
"log"
"time"
"license-manager/internal/config"
"license-manager/internal/models"
"license-manager/pkg/utils"
)
// AutoMigrate 执行自动数据库迁移
func AutoMigrate() error {
if DB == nil {
return fmt.Errorf("database connection not initialized")
}
log.Println("Starting database auto migration...")
// 迁移数据库表结构
err := DB.AutoMigrate(
&models.Customer{},
&models.CustomerCodeSequence{},
&models.User{},
&models.AuthorizationCode{},
&models.License{},
&models.AuthorizationChange{},
)
if err != nil {
return fmt.Errorf("failed to migrate database: %w", err)
}
// 初始化客户编码序列
if err := initCustomerCodeSequence(); err != nil {
return fmt.Errorf("failed to initialize customer code sequence: %w", err)
}
// 初始化默认管理员用户
if err := initDefaultAdminUser(); err != nil {
return fmt.Errorf("failed to initialize default admin user: %w", err)
}
log.Println("Database auto migration completed successfully")
return nil
}
// initCustomerCodeSequence 初始化客户编码序列
func initCustomerCodeSequence() error {
currentYear := time.Now().Year()
var sequence models.CustomerCodeSequence
result := DB.Where("year = ?", currentYear).First(&sequence)
if result.Error != nil {
// 记录不存在,创建新记录
sequence = models.CustomerCodeSequence{
Year: currentYear,
SequenceNumber: 0,
}
if err := DB.Create(&sequence).Error; err != nil {
return fmt.Errorf("failed to create customer code sequence: %w", err)
}
log.Printf("Initialized customer code sequence for year %d", currentYear)
}
return nil
}
// RunMigrationIfEnabled 根据配置决定是否运行迁移
func RunMigrationIfEnabled() error {
cfg := config.GetConfig()
if cfg == nil {
return fmt.Errorf("config not initialized")
}
if cfg.Database.AutoMigrate {
return AutoMigrate()
}
log.Println("Auto migration is disabled in config")
return nil
}
// initDefaultAdminUser 初始化默认管理员用户
func initDefaultAdminUser() error {
var user models.User
result := DB.Where("username = ?", "admin").First(&user)
if result.Error != nil {
// 用户不存在,创建默认管理员
passwordHash, err := utils.HashPassword("admin@123")
if err != nil {
return fmt.Errorf("failed to hash password: %w", err)
}
user = models.User{
Username: "admin",
Email: "admin@example.com",
PasswordHash: passwordHash,
FullName: "系统管理员",
Role: "admin",
Status: "active",
}
if err := DB.Create(&user).Error; err != nil {
return fmt.Errorf("failed to create default admin user: %w", err)
}
log.Println("Created default admin user: admin / admin@123")
} else {
log.Println("Default admin user already exists")
}
return nil
}
+243 -11
View File
@@ -1,27 +1,259 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// LoginRequest 登录请求结构
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Username string `json:"username" binding:"required"` // 用户名
Password string `json:"password" binding:"required"` // 密码
}
// LoginResponse 登录响应结构
type LoginResponse struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
Code string `json:"code"` // 响应代码
Message string `json:"message"` // 响应消息
Data interface{} `json:"data,omitempty"` // 响应数据
}
// LoginData 登录成功返回的数据
type LoginData struct {
Token string `json:"token"`
ExpiresIn int `json:"expires_in"`
UserInfo UserInfo `json:"user_info"`
Token string `json:"token"` // JWT令牌
ExpiresIn int `json:"expires_in"` // 过期时间(秒)
UserInfo UserInfo `json:"user_info"` // 用户信息
}
// UserInfo 用户信息
type UserInfo struct {
Username string `json:"username"`
Role string `json:"role"`
}
Username string `json:"username"` // 用户名
Role string `json:"role"` // 角色
}
// AuthorizationCode 授权码模型
type AuthorizationCode struct {
ID string `gorm:"type:varchar(36);primaryKey" json:"id"` // 授权码ID
Code string `gorm:"type:varchar(100);uniqueIndex;not null" json:"code"` // 授权码
CustomerID string `gorm:"type:varchar(36);not null;index" json:"customer_id"` // 客户ID
CustomerName string `gorm:"-" json:"customer_name,omitempty"` // 客户名称
CustomerNameDisplay string `gorm:"-" json:"customer_name_display,omitempty"` // 客户名称显示(多语言)
CreatedBy string `gorm:"type:varchar(36);not null" json:"created_by"` // 创建人ID
SoftwareID *string `gorm:"type:varchar(50)" json:"software_id"` // 软件ID
Description *string `gorm:"type:text" json:"description"` // 描述
StartDate time.Time `gorm:"type:datetime(3);not null" json:"start_date"` // 生效日期
EndDate time.Time `gorm:"type:datetime(3);not null" json:"end_date"` // 失效日期
DeploymentType string `gorm:"type:varchar(20);not null;default:'standalone'" json:"deployment_type"` // 部署类型:standalone/cloud/hybrid
DeploymentTypeDisplay string `gorm:"-" json:"deployment_type_display,omitempty"` // 部署类型显示(多语言)
EncryptionType *string `gorm:"type:varchar(20);default:'standard'" json:"encryption_type"` // 加密类型:standard/advanced
EncryptionTypeDisplay string `gorm:"-" json:"encryption_type_display,omitempty"` // 加密类型显示(多语言)
SoftwareVersion *string `gorm:"type:varchar(50)" json:"software_version"` // 软件版本
MaxActivations int `gorm:"not null;default:1" json:"max_activations"` // 最大激活次数
CurrentActivations int `gorm:"-" json:"current_activations,omitempty"` // 当前激活次数
IsLocked bool `gorm:"not null;default:false" json:"is_locked"` // 是否锁定
LockReason *string `gorm:"type:text" json:"lock_reason"` // 锁定原因
LockedAt *time.Time `gorm:"type:datetime(3)" json:"locked_at"` // 锁定时间
LockedBy *string `gorm:"type:varchar(36)" json:"locked_by"` // 锁定人ID
FeatureConfig JSON `gorm:"type:json" json:"feature_config" swaggertype:"object"` // 功能配置(JSON对象)
UsageLimits JSON `gorm:"type:json" json:"usage_limits" swaggertype:"object"` // 使用限制(JSON对象)
CustomParameters JSON `gorm:"type:json" json:"custom_parameters" swaggertype:"object"` // 自定义参数(JSON对象)
Status string `gorm:"-" json:"status,omitempty"` // 状态:normal/locked/expired
StatusDisplay string `gorm:"-" json:"status_display,omitempty"` // 状态显示(多语言)
CreatedAt time.Time `gorm:"type:datetime(3);not null" json:"created_at"` // 创建时间
UpdatedAt time.Time `gorm:"type:datetime(3);not null" json:"updated_at"` // 更新时间
CustomerInfo *CustomerInfoForAuthCode `gorm:"-" json:"customer_info,omitempty"` // 客户信息(仅在详情接口返回)
ActivatedLicensesCount int64 `gorm:"-" json:"activated_licenses_count,omitempty"` // 该授权码下已激活的许可证数量
}
// CustomerInfoForAuthCode 授权码详情中的客户信息结构
type CustomerInfoForAuthCode struct {
ID string `json:"id"` // 客户ID
CustomerCode string `json:"customer_code"` // 客户编号
CustomerName string `json:"customer_name"` // 客户名称
CustomerType string `json:"customer_type"` // 客户类型
CustomerTypeDisplay string `json:"customer_type_display,omitempty"` // 客户类型显示(多语言)
Status string `json:"status"` // 客户状态
StatusDisplay string `json:"status_display,omitempty"` // 客户状态显示(多语言)
CreatedAt string `json:"created_at"` // 客户创建时间
}
// TableName 指定表名
func (AuthorizationCode) TableName() string {
return "authorization_codes"
}
// BeforeCreate 创建前自动设置时间戳和ID
func (a *AuthorizationCode) BeforeCreate(tx *gorm.DB) error {
if a.ID == "" {
a.ID = uuid.New().String()
}
now := time.Now()
if a.CreatedAt.IsZero() {
a.CreatedAt = now
}
if a.UpdatedAt.IsZero() {
a.UpdatedAt = now
}
return nil
}
// BeforeUpdate 更新前自动刷新更新时间
func (a *AuthorizationCode) BeforeUpdate(tx *gorm.DB) error {
a.UpdatedAt = time.Now()
return nil
}
// AuthorizationCodeCreateRequest 创建授权码请求结构
type AuthorizationCodeCreateRequest struct {
CustomerID string `json:"customer_id" binding:"required"` // 客户ID
SoftwareID *string `json:"software_id" binding:"omitempty"` // 软件ID
Description *string `json:"description" binding:"omitempty,max=1000"` // 描述
ValidityDays int `json:"validity_days" binding:"required,min=1,max=365000"` // 有效天数(1-365000天,365000代表永久有效)
DeploymentType string `json:"deployment_type" binding:"required,oneof=standalone cloud hybrid"` // 部署类型:standalone/cloud/hybrid
EncryptionType *string `json:"encryption_type" binding:"omitempty,oneof=standard advanced"` // 加密类型:standard/advanced
SoftwareVersion *string `json:"software_version" binding:"omitempty"` // 软件版本
MaxActivations int `json:"max_activations" binding:"required,min=1"` // 最大激活次数
FeatureConfig interface{} `json:"feature_config" binding:"omitempty"` // 功能配置(JSON对象)
UsageLimits interface{} `json:"usage_limits" binding:"omitempty"` // 使用限制(JSON对象)
CustomParameters interface{} `json:"custom_parameters" binding:"omitempty"` // 自定义参数(JSON对象)
}
// AuthorizationCodeCreateResponse 创建授权码响应结构
type AuthorizationCodeCreateResponse struct {
ID string `json:"id"` // 授权码ID
Code string `json:"code"` // 授权码
}
// AuthorizationCodeListRequest 授权码列表查询请求结构
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筛选
Status string `form:"status" binding:"omitempty,oneof=normal locked expired"` // 状态筛选
StartDate string `form:"start_date" binding:"omitempty"` // 创建开始时间
EndDate string `form:"end_date" binding:"omitempty"` // 创建结束时间
Sort string `form:"sort" binding:"omitempty,oneof=created_at updated_at code"` // 排序字段,默认created_at
Order string `form:"order" binding:"omitempty,oneof=asc desc"` // 排序方向,默认desc
}
// AuthorizationCodeListItem 授权码列表项结构
type AuthorizationCodeListItem struct {
ID string `json:"id"` // 授权码ID
Code string `json:"code"` // 授权码
CustomerID string `json:"customer_id"` // 客户ID
CustomerName string `json:"customer_name"` // 客户名称
CustomerNameDisplay string `json:"customer_name_display,omitempty"` // 客户名称显示(多语言)
Status string `json:"status"` // 状态:normal/locked/expired
StatusDisplay string `json:"status_display,omitempty"` // 状态显示(多语言)
StartDate string `json:"start_date"` // 生效日期
EndDate string `json:"end_date"` // 失效日期
MaxActivations int `json:"max_activations"` // 最大激活次数
CurrentActivations int `json:"current_activations"` // 当前激活次数
DeploymentType string `json:"deployment_type"` // 部署类型:standalone/cloud/hybrid
DeploymentTypeDisplay string `json:"deployment_type_display,omitempty"` // 部署类型显示(多语言)
IsLocked bool `json:"is_locked"` // 是否锁定
Description *string `json:"description"` // 描述
CreatedAt string `json:"created_at"` // 创建时间
}
// AuthorizationCodeListResponse 授权码列表响应结构
type AuthorizationCodeListResponse struct {
List []AuthorizationCodeListItem `json:"list"` // 授权码列表
Total int64 `json:"total"` // 总记录数
Page int `json:"page"` // 当前页码
PageSize int `json:"page_size"` // 每页条数
TotalPages int `json:"total_pages"` // 总页数
}
// AuthorizationCodeUpdateRequest 更新授权码请求结构
type AuthorizationCodeUpdateRequest struct {
SoftwareID *string `json:"software_id" binding:"omitempty"` // 软件ID
Description *string `json:"description" binding:"omitempty,max=1000"` // 描述
ValidityDays *int `json:"validity_days" binding:"omitempty,min=1,max=365000"` // 有效天数(1-365000天,365000代表永久有效)
DeploymentType *string `json:"deployment_type" binding:"omitempty,oneof=standalone cloud hybrid"` // 部署类型:standalone/cloud/hybrid
EncryptionType *string `json:"encryption_type" binding:"omitempty,oneof=standard advanced"` // 加密类型:standard/advanced
SoftwareVersion *string `json:"software_version" binding:"omitempty"` // 软件版本
MaxActivations *int `json:"max_activations" binding:"omitempty,min=1"` // 最大激活次数
FeatureConfig interface{} `json:"feature_config" binding:"omitempty"` // 功能配置
UsageLimits interface{} `json:"usage_limits" binding:"omitempty"` // 使用限制
CustomParameters interface{} `json:"custom_parameters" binding:"omitempty"` // 自定义参数
ChangeType string `json:"change_type" binding:"required,oneof=renewal upgrade limit_change feature_toggle lock unlock other"` // 变更类型:renewal/upgrade/limit_change/feature_toggle/lock/unlock/other
Reason *string `json:"reason" binding:"omitempty,max=500"` // 变更原因
}
// AuthorizationCodeLockRequest 锁定/解锁授权码请求结构
type AuthorizationCodeLockRequest struct {
IsLocked bool `json:"is_locked" binding:""` // true-锁定,false-解锁
LockReason *string `json:"lock_reason" binding:"omitempty,max=500"` // 锁定原因
Reason *string `json:"reason" binding:"omitempty,max=500"` // 变更原因(记录到变更历史)
}
// AuthorizationChange 授权变更历史模型
type AuthorizationChange struct {
ID string `gorm:"type:varchar(36);primaryKey" json:"id"` // 变更记录ID
AuthorizationCodeID string `gorm:"type:varchar(36);not null;index" json:"authorization_code_id"` // 授权码ID
ChangeType string `gorm:"type:varchar(30);not null;index" json:"change_type"` // 变更类型
ChangeTypeDisplay string `gorm:"-" json:"change_type_display,omitempty"` // 变更类型显示(多语言)
OldConfig JSON `gorm:"type:json" json:"old_config,omitempty" swaggertype:"object"` // 变更前配置(JSON对象)
NewConfig JSON `gorm:"type:json" json:"new_config,omitempty" swaggertype:"object"` // 变更后配置(JSON对象)
OperatorID string `gorm:"type:varchar(36);not null;index" json:"operator_id"` // 操作人ID
OperatorName string `gorm:"-" json:"operator_name,omitempty"` // 操作人名称
Reason *string `gorm:"type:text" json:"reason"` // 变更原因
CreatedAt time.Time `gorm:"type:datetime(3);not null;index" json:"created_at"` // 创建时间
// 关联字段
AuthorizationCode *AuthorizationCode `gorm:"foreignKey:AuthorizationCodeID" json:"authorization_code,omitempty"` // 关联的授权码
Operator *User `gorm:"foreignKey:OperatorID" json:"operator,omitempty"` // 关联的操作人
}
// TableName 指定表名
func (AuthorizationChange) TableName() string {
return "authorization_changes"
}
// BeforeCreate 创建前自动设置时间戳和ID
func (a *AuthorizationChange) BeforeCreate(tx *gorm.DB) error {
if a.ID == "" {
a.ID = uuid.New().String()
}
if a.CreatedAt.IsZero() {
a.CreatedAt = time.Now()
}
return nil
}
// AuthorizationChangeListRequest 授权变更历史列表查询请求结构
type AuthorizationChangeListRequest struct {
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认20,最大100
ChangeType string `form:"change_type" binding:"omitempty"` // 变更类型筛选
OperatorID string `form:"operator_id" binding:"omitempty"` // 操作人筛选
StartDate string `form:"start_date" binding:"omitempty"` // 开始时间
EndDate string `form:"end_date" binding:"omitempty"` // 结束时间
Sort string `form:"sort" binding:"omitempty,oneof=created_at change_type"` // 排序字段,默认created_at
Order string `form:"order" binding:"omitempty,oneof=asc desc"` // 排序方向,默认desc
}
// AuthorizationChangeListItem 授权变更历史列表项结构
type AuthorizationChangeListItem struct {
ID string `json:"id"` // 变更记录ID
ChangeType string `json:"change_type"` // 变更类型
ChangeTypeDisplay string `json:"change_type_display,omitempty"` // 变更类型显示(多语言)
OperatorID string `json:"operator_id"` // 操作人ID
OperatorName string `json:"operator_name,omitempty"` // 操作人名称
Reason *string `json:"reason"` // 变更原因
CreatedAt string `json:"created_at"` // 创建时间
}
// AuthorizationChangeListResponse 授权变更历史列表响应结构
type AuthorizationChangeListResponse struct {
List []AuthorizationChangeListItem `json:"list"` // 变更历史列表
Total int64 `json:"total"` // 总记录数
Page int `json:"page"` // 当前页码
PageSize int `json:"page_size"` // 每页条数
TotalPages int `json:"total_pages"` // 总页数
}
+78 -6
View File
@@ -1,8 +1,62 @@
package models
import (
"database/sql/driver"
"encoding/json"
"errors"
)
// JSON 类型用于GORM JSON字段
// swagger:type object
type JSON json.RawMessage
// 实现 driver.Valuer 接口
func (j JSON) Value() (driver.Value, error) {
if len(j) == 0 {
return nil, nil
}
return json.RawMessage(j).MarshalJSON()
}
// 实现 sql.Scanner 接口
func (j *JSON) Scan(value interface{}) error {
if value == nil {
*j = nil
return nil
}
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return errors.New("cannot scan into JSON")
}
*j = append((*j)[0:0], bytes...)
return nil
}
// MarshalJSON 实现 json.Marshaler 接口
func (j JSON) MarshalJSON() ([]byte, error) {
if len(j) == 0 {
return []byte("null"), nil
}
return j, nil
}
// UnmarshalJSON 实现 json.Unmarshaler 接口
func (j *JSON) UnmarshalJSON(data []byte) error {
if j == nil {
return errors.New("JSON receiver is nil")
}
*j = append((*j)[0:0], data...)
return nil
}
// APIResponse 通用API响应结构
type APIResponse struct {
Code int `json:"code"`
Code string `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
@@ -10,8 +64,26 @@ type APIResponse struct {
// ErrorResponse 错误响应结构
type ErrorResponse struct {
Code int `json:"code"`
Error string `json:"error"`
Message string `json:"message"`
Timestamp string `json:"timestamp"`
}
Code string `json:"code"` // 业务错误码,如 "100001", "900001"
Message string `json:"message"` // 错误描述信息
Timestamp string `json:"timestamp"` // 错误发生时间
}
// 枚举相关结构
// EnumItem 枚举项结构
type EnumItem struct {
Key string `json:"key"` // 枚举值
Display string `json:"display"` // 多语言显示文本
}
// EnumTypeResponse 单个枚举类型响应
type EnumTypeResponse struct {
Type string `json:"type"` // 枚举类型名称
Items []EnumItem `json:"items"` // 枚举项列表
}
// EnumListResponse 枚举列表响应
type EnumListResponse struct {
Enums []EnumTypeResponse `json:"enums"` // 所有枚举类型
}
+162
View File
@@ -0,0 +1,162 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// Customer 客户信息模型
type Customer struct {
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
CustomerCode string `gorm:"type:varchar(20);uniqueIndex;not null" json:"customer_code"`
CustomerName string `gorm:"type:varchar(200);not null;index" json:"customer_name"`
CustomerType string `gorm:"type:varchar(20);not null;default:'enterprise';index" json:"customer_type"`
CustomerTypeDisplay string `gorm:"-" json:"customer_type_display,omitempty"`
ContactPerson string `gorm:"type:varchar(100);not null" json:"contact_person"`
ContactTitle *string `gorm:"type:varchar(100)" json:"contact_title"`
Email *string `gorm:"type:varchar(255)" json:"email"`
Phone *string `gorm:"type:varchar(20)" json:"phone"`
Address *string `gorm:"type:text" json:"address"`
CompanySize *string `gorm:"type:varchar(20)" json:"company_size"`
CompanySizeDisplay string `gorm:"-" json:"company_size_display,omitempty"`
CustomerLevel string `gorm:"type:varchar(20);not null;default:'basic';index" json:"customer_level"`
CustomerLevelDisplay string `gorm:"-" json:"customer_level_display,omitempty"`
Status string `gorm:"type:varchar(20);not null;default:'active';index" json:"status"`
StatusDisplay string `gorm:"-" json:"status_display,omitempty"`
Description *string `gorm:"type:text" json:"description"`
CreatedAt time.Time `gorm:"not null;index" json:"created_at"`
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
CreatedBy string `gorm:"type:varchar(36);not null;index" json:"created_by"`
UpdatedBy *string `gorm:"type:varchar(36)" json:"updated_by"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// 授权统计信息(仅在详情接口返回)
AuthorizationStats *AuthorizationStats `gorm:"-" json:"authorization_stats,omitempty"`
}
// CustomerCodeSequence 客户编码序列模型
type CustomerCodeSequence struct {
Year int `gorm:"primaryKey" json:"year"`
SequenceNumber int `gorm:"not null;default:0" json:"sequence_number"`
}
// TableName 指定表名
func (Customer) TableName() string {
return "customers"
}
// BeforeCreate 创建前自动设置时间戳和ID
// 确保数据完整性,避免依赖数据库默认值(符合失败快速原则)
func (c *Customer) BeforeCreate(tx *gorm.DB) error {
// 生成UUID作为主键ID
if c.ID == "" {
c.ID = uuid.New().String()
}
// 设置时间戳
now := time.Now()
if c.CreatedAt.IsZero() {
c.CreatedAt = now
}
if c.UpdatedAt.IsZero() {
c.UpdatedAt = now
}
return nil
}
// BeforeUpdate 更新前自动刷新更新时间
// 保持数据时间戳的准确性和一致性
func (c *Customer) BeforeUpdate(tx *gorm.DB) error {
c.UpdatedAt = time.Now()
return nil
}
// TableName 指定表名
func (CustomerCodeSequence) TableName() string {
return "customer_code_sequence"
}
// CustomerListRequest 客户列表查询请求结构
type CustomerListRequest struct {
Page int `form:"page" binding:"omitempty,min=1"` // 页码,默认1
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"` // 每页条数,默认20,最大100
Search string `form:"search" binding:"omitempty,max=100"` // 搜索关键词(支持客户编码、名称、联系人、邮箱)
CustomerType string `form:"customer_type" binding:"omitempty,oneof=individual enterprise government education"` // 客户类型筛选
CustomerLevel string `form:"customer_level" binding:"omitempty"` // 客户等级筛选
Status string `form:"status" binding:"omitempty,oneof=active disabled"` // 状态筛选
Sort string `form:"sort" binding:"omitempty,oneof=created_at updated_at customer_name customer_code"` // 排序字段,默认created_at
Order string `form:"order" binding:"omitempty,oneof=asc desc"` // 排序方向,默认desc
}
// CustomerListItem 客户列表项结构(用于列表展示,包含主要字段)
type CustomerListItem struct {
ID string `json:"id"`
CustomerCode string `json:"customer_code"`
CustomerName string `json:"customer_name"`
CustomerType string `json:"customer_type"`
CustomerTypeDisplay string `json:"customer_type_display,omitempty"`
ContactPerson string `json:"contact_person"`
Email *string `json:"email"`
CustomerLevel string `json:"customer_level"`
CustomerLevelDisplay string `json:"customer_level_display,omitempty"`
Status string `json:"status"`
StatusDisplay string `json:"status_display,omitempty"`
CreatedAt string `json:"created_at"`
}
// CustomerListResponse 客户列表响应结构
type CustomerListResponse struct {
List []CustomerListItem `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// CustomerCreateRequest 创建客户请求结构
type CustomerCreateRequest struct {
CustomerName string `json:"customer_name" binding:"required,max=200"` // 客户名称,必填
CustomerType string `json:"customer_type" binding:"required,oneof=individual enterprise government education"` // 客户类型,必填
ContactPerson string `json:"contact_person" binding:"required,max=100"` // 联系人姓名,必填
ContactTitle *string `json:"contact_title" binding:"omitempty,max=100"` // 联系人职位,可选
Email *string `json:"email" binding:"omitempty,email,max=255"` // 邮箱,可选
Phone *string `json:"phone" binding:"omitempty,max=20"` // 电话,可选
Address *string `json:"address" binding:"omitempty,max=500"` // 地址,可选
CompanySize *string `json:"company_size" binding:"omitempty"` // 企业规模,可选
CustomerLevel string `json:"customer_level" binding:"required"` // 客户等级,必填
Status string `json:"status" binding:"required,oneof=active disabled"` // 状态,必填
Description *string `json:"description" binding:"omitempty,max=1000"` // 描述,可选
}
// CustomerUpdateRequest 更新客户请求结构(所有字段都是可选的)
type CustomerUpdateRequest struct {
CustomerName *string `json:"customer_name" binding:"omitempty,max=200"` // 客户名称,可选
CustomerType *string `json:"customer_type" binding:"omitempty,oneof=individual enterprise government education"` // 客户类型,可选
ContactPerson *string `json:"contact_person" binding:"omitempty,max=100"` // 联系人姓名,可选
ContactTitle *string `json:"contact_title" binding:"omitempty,max=100"` // 联系人职位,可选
Email *string `json:"email" binding:"omitempty,email,max=255"` // 邮箱,可选
Phone *string `json:"phone" binding:"omitempty,max=20"` // 电话,可选
Address *string `json:"address" binding:"omitempty,max=500"` // 地址,可选
CompanySize *string `json:"company_size" binding:"omitempty"` // 企业规模,可选
CustomerLevel *string `json:"customer_level" binding:"omitempty"` // 客户等级,可选
Status *string `json:"status" binding:"omitempty,oneof=active disabled"` // 状态,可选
Description *string `json:"description" binding:"omitempty,max=1000"` // 描述,可选
}
// CustomerStatusUpdateRequest 客户状态更新请求结构
type CustomerStatusUpdateRequest struct {
Status string `json:"status" binding:"required,oneof=active disabled"` // 状态,必填
}
// AuthorizationStats 授权统计信息
type AuthorizationStats struct {
TotalAuthCodes int64 `json:"total_auth_codes"` // 总授权码数量
ExpiredAuthCodes int64 `json:"expired_auth_codes"` // 已过期授权码数量
ExpiringSoonAuthCodes int64 `json:"expiring_soon_auth_codes"` // 30日内即将到期授权码数量
TotalLicenses int64 `json:"total_licenses"` // 总许可证数量
ActiveLicenses int64 `json:"active_licenses"` // 已激活许可证数量
InactiveLicenses int64 `json:"inactive_licenses"` // 未激活许可证数量
ExpiredLicenses int64 `json:"expired_licenses"` // 已过期许可证数量
}
+72
View File
@@ -0,0 +1,72 @@
package models
import "time"
// DashboardAuthorizationTrendRequest 授权趋势查询请求
type DashboardAuthorizationTrendRequest struct {
Type string `form:"type" binding:"required,oneof=week month custom" json:"type"` // 时间类型: week/month/custom
StartDate string `form:"start_date" json:"start_date,omitempty"` // 开始日期 (YYYY-MM-DD格式,当type为custom时必填)
EndDate string `form:"end_date" json:"end_date,omitempty"` // 结束日期 (YYYY-MM-DD格式,当type为custom时必填)
Timezone string `form:"timezone" json:"timezone,omitempty"` // 时区 (如: Asia/Shanghai, UTC等,默认使用服务器本地时区)
}
// DashboardAuthorizationTrendResponse 授权趋势响应
type DashboardAuthorizationTrendResponse struct {
Period TrendPeriod `json:"period"` // 时间段信息
TrendData []TrendData `json:"trend_data"` // 趋势数据
Summary TrendSummary `json:"summary"` // 汇总信息
}
// TrendPeriod 时间段信息
type TrendPeriod struct {
Type string `json:"type"` // 时间类型
StartDate string `json:"start_date"` // 开始日期
EndDate string `json:"end_date"` // 结束日期
DescriptionDisplay string `json:"description_display"` // 多语言描述
}
// TrendData 单日趋势数据
type TrendData struct {
Date string `json:"date"` // 日期 (YYYY-MM-DD)
TotalAuthorizations int64 `json:"total_authorizations"` // 当日授权总数
NewAuthorizations int64 `json:"new_authorizations"` // 当日新增授权数
ExpiredAuthorizations int64 `json:"expired_authorizations"` // 当日过期授权数
}
// TrendSummary 趋势汇总信息
type TrendSummary struct {
TotalCount int64 `json:"total_count"` // 期间总授权数
NewCount int64 `json:"new_count"` // 期间新增授权数
ExpiredCount int64 `json:"expired_count"` // 期间过期授权数
GrowthRate float64 `json:"growth_rate"` // 增长率(百分比)
}
// DashboardRecentAuthorizationsRequest 最近授权列表查询请求
type DashboardRecentAuthorizationsRequest struct {
Limit int `form:"limit" json:"limit,omitempty"` // 返回数量限制(默认20,最大100)
CustomerID string `form:"customer_id" json:"customer_id,omitempty"` // 客户ID筛选(可选)
Status string `form:"status" json:"status,omitempty"` // 状态筛选 (normal/locked/expired) 可选
}
// DashboardRecentAuthorizationsResponse 最近授权列表响应
type DashboardRecentAuthorizationsResponse struct {
List []RecentAuthorization `json:"list"` // 授权列表
Total int64 `json:"total"` // 符合条件的授权总数量
}
// RecentAuthorization 最近授权信息
type RecentAuthorization struct {
ID string `json:"id"` // 授权码ID
Code string `json:"code"` // 授权码
CustomerID string `json:"customer_id"` // 客户ID
CustomerName string `json:"customer_name"` // 客户名称
Description string `json:"description"` // 授权描述
Status string `json:"status"` // 状态 (normal/locked/expired)
StatusDisplay string `json:"status_display"` // 状态显示文本(多语言)
StartDate time.Time `json:"start_date"` // 生效时间
EndDate time.Time `json:"end_date"` // 到期时间
MaxActivations int `json:"max_activations"` // 最大激活数量
CurrentActivations int `json:"current_activations"` // 当前激活数量
CreatedAt time.Time `json:"created_at"` // 创建时间
UpdatedAt time.Time `json:"updated_at"` // 更新时间
}
+185
View File
@@ -0,0 +1,185 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// License 许可证模型
type License struct {
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
LicenseKey string `gorm:"type:varchar(200);uniqueIndex;not null" json:"license_key"`
AuthorizationCodeID string `gorm:"type:varchar(36);not null;index" json:"authorization_code_id"`
CustomerID string `gorm:"type:varchar(36);not null;index" json:"customer_id"`
HardwareFingerprint string `gorm:"type:varchar(200);not null;index" json:"hardware_fingerprint"`
DeviceInfo JSON `gorm:"type:json" json:"device_info,omitempty" swaggertype:"object"`
ActivationIP *string `gorm:"type:varchar(45)" json:"activation_ip"`
Status string `gorm:"type:varchar(20);not null;default:'inactive';index" json:"status"`
StatusDisplay string `gorm:"-" json:"status_display,omitempty"`
ActivatedAt *time.Time `gorm:"index" json:"activated_at"`
LastHeartbeat *time.Time `gorm:"index" json:"last_heartbeat"`
LastOnlineIP *string `gorm:"type:varchar(45)" json:"last_online_ip"`
ConfigUpdatedAt *time.Time `gorm:"index" json:"config_updated_at"`
UsageData JSON `gorm:"type:json" json:"usage_data,omitempty" swaggertype:"object"`
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:"-"`
// 关联字段(用于查询时的JOIN)
AuthorizationCode *AuthorizationCode `gorm:"foreignKey:AuthorizationCodeID" json:"authorization_code,omitempty"`
Customer *Customer `gorm:"foreignKey:CustomerID" json:"customer,omitempty"`
IsOnline bool `gorm:"-" json:"is_online"`
IsOnlineDisplay string `gorm:"-" json:"is_online_display,omitempty"`
}
// TableName 指定表名
func (License) TableName() string {
return "licenses"
}
// BeforeCreate 创建前自动设置时间戳和ID
func (l *License) 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
}
return nil
}
// BeforeUpdate 更新前自动刷新更新时间
func (l *License) BeforeUpdate(tx *gorm.DB) error {
l.UpdatedAt = time.Now()
return nil
}
// 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
}
// LicenseListItem 许可证列表项结构
type LicenseListItem struct {
ID string `json:"id"` // 许可证ID
LicenseKey string `json:"license_key"` // 许可证密钥
AuthorizationCodeID string `json:"authorization_code_id"` // 授权码ID
AuthorizationCode string `json:"authorization_code"` // 授权码
CustomerName string `json:"customer_name"` // 客户名称
HardwareFingerprint string `json:"hardware_fingerprint"` // 硬件指纹
Status string `json:"status"` // 许可证状态
StatusDisplay string `json:"status_display,omitempty"` // 状态显示名称
IsOnline bool `json:"is_online"` // 是否在线
IsOnlineDisplay string `json:"is_online_display,omitempty"` // 在线状态显示名称
ActivationIP *string `json:"activation_ip"` // 激活IP
LastOnlineIP *string `json:"last_online_ip"` // 最后在线IP
ActivatedAt *string `json:"activated_at"` // 激活时间
LastHeartbeat *string `json:"last_heartbeat"` // 最后心跳时间
}
// LicenseListResponse 许可证列表响应结构
type LicenseListResponse struct {
List []LicenseListItem `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
// LicenseDetailResponse 许可证详情响应结构
type LicenseDetailResponse struct {
ID string `json:"id"` // 许可证ID
LicenseKey string `json:"license_key"` // 许可证密钥
AuthorizationCodeID string `json:"authorization_code_id"` // 授权码ID
AuthorizationCode string `json:"authorization_code"` // 授权码
CustomerID string `json:"customer_id"` // 客户ID
CustomerName string `json:"customer_name"` // 客户名称
HardwareFingerprint string `json:"hardware_fingerprint"` // 硬件指纹
DeviceInfo map[string]interface{} `json:"device_info,omitempty"` // 设备信息
ActivationIP *string `json:"activation_ip"` // 激活IP
Status string `json:"status"` // 许可证状态
StatusDisplay string `json:"status_display,omitempty"` // 状态显示名称
IsOnline bool `json:"is_online"` // 是否在线
IsOnlineDisplay string `json:"is_online_display,omitempty"` // 在线状态显示名称
ActivatedAt *string `json:"activated_at"` // 激活时间
LastHeartbeat *string `json:"last_heartbeat"` // 最后心跳时间
LastOnlineIP *string `json:"last_online_ip"` // 最后在线IP
ConfigUpdatedAt *string `json:"config_updated_at"` // 客户端配置更新时间
UsageData map[string]interface{} `json:"usage_data,omitempty"` // 使用数据
CreatedAt string `json:"created_at"` // 创建时间
UpdatedAt string `json:"updated_at"` // 更新时间
}
// LicenseCreateRequest 手动添加许可证请求结构
type LicenseCreateRequest struct {
AuthorizationCodeID string `json:"authorization_code_id" binding:"required"` // 授权码ID,必填
HardwareFingerprint string `json:"hardware_fingerprint" binding:"required"` // 硬件指纹,必填
DeviceInfo map[string]interface{} `json:"device_info,omitempty"` // 设备信息,可选
ActivationIP *string `json:"activation_ip" binding:"omitempty,ip"` // 激活IP,可选
}
// LicenseRevokeRequest 撤销许可证请求结构
type LicenseRevokeRequest struct {
Reason string `json:"reason" binding:"omitempty,max=500"` // 撤销原因,可选
}
// ActivateRequest 软件激活请求结构
type ActivateRequest struct {
AuthorizationCode string `json:"authorization_code" binding:"required"` // 授权码,必填
HardwareFingerprint string `json:"hardware_fingerprint" binding:"required"` // 硬件指纹,必填
DeviceInfo map[string]interface{} `json:"device_info,omitempty"` // 设备信息,可选
SoftwareVersion *string `json:"software_version" binding:"omitempty"` // 软件版本,可选
}
// ActivateResponse 软件激活响应结构
type ActivateResponse struct {
LicenseKey string `json:"license_key"` // 许可证密钥
LicenseFile string `json:"license_file"` // base64编码的加密许可证文件
HeartbeatInterval int `json:"heartbeat_interval"` // 心跳间隔(秒)
}
// HeartbeatRequest 心跳检测请求结构
type HeartbeatRequest struct {
LicenseKey string `json:"license_key" binding:"required"` // 许可证密钥,必填
HardwareFingerprint string `json:"hardware_fingerprint" binding:"required"` // 硬件指纹,必填
ConfigUpdatedAt *string `json:"config_updated_at,omitempty"` // 客户端配置更新时间,可选
UsageData map[string]interface{} `json:"usage_data,omitempty"` // 使用数据,可选
SoftwareVersion *string `json:"software_version,omitempty"` // 软件版本,可选
}
// HeartbeatResponse 心跳检测响应结构
type HeartbeatResponse struct {
Status string `json:"status"` // 许可证状态
ConfigUpdated bool `json:"config_updated"` // 配置是否有更新
LicenseFile *string `json:"license_file"` // base64编码的新许可证文件(如有更新)
HeartbeatInterval int `json:"heartbeat_interval"` // 下次心跳间隔(秒)
}
// StatsOverviewResponse 授权概览统计响应结构
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"` // 增长率(同比上月)
}
// GrowthRate 增长率结构(同比上月:当前值相比一个月前同一时刻值的增长率)
type GrowthRate struct {
AuthCodes float64 `json:"auth_codes"` // 授权码增长率(%)(当前总授权码数相比一个月前总授权码数的增长率)
Licenses float64 `json:"licenses"` // 许可证增长率(%)(当前活跃许可证数相比一个月前活跃许可证数的增长率)
}
+125
View File
@@ -0,0 +1,125 @@
package models
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// User 用户模型
type User struct {
ID string `gorm:"type:varchar(36);primaryKey" json:"id"`
Username string `gorm:"type:varchar(50);uniqueIndex;not null" json:"username"`
Email string `gorm:"type:varchar(255);uniqueIndex;not null" json:"email"`
PasswordHash string `gorm:"type:varchar(255);not null" json:"-"` // 不在JSON中显示密码哈希
FullName string `gorm:"type:varchar(100);not null" json:"full_name"`
Phone *string `gorm:"type:varchar(20)" json:"phone"`
Role string `gorm:"type:varchar(20);not null;default:'viewer';index" json:"role"`
Status string `gorm:"type:varchar(20);not null;default:'active';index" json:"status"`
LastLoginAt *time.Time `gorm:"index" json:"last_login_at"`
LastLoginIP *string `gorm:"type:varchar(45)" json:"last_login_ip"`
LoginAttempts int `gorm:"not null;default:0" json:"login_attempts"`
LockedUntil *time.Time `gorm:"index" json:"locked_until"`
CreatedAt time.Time `gorm:"not null;index" json:"created_at"`
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
}
// TableName 指定表名
func (User) TableName() string {
return "users"
}
// BeforeCreate 创建前自动设置时间戳和ID
func (u *User) BeforeCreate(tx *gorm.DB) error {
// 生成UUID作为主键ID
if u.ID == "" {
u.ID = uuid.New().String()
}
// 设置时间戳
now := time.Now()
if u.CreatedAt.IsZero() {
u.CreatedAt = now
}
if u.UpdatedAt.IsZero() {
u.UpdatedAt = now
}
return nil
}
// BeforeUpdate 更新前自动刷新更新时间
func (u *User) BeforeUpdate(tx *gorm.DB) error {
u.UpdatedAt = time.Now()
return nil
}
// IsAccountLocked 检查账号是否被锁定
func (u *User) IsAccountLocked() bool {
if u.Status == "locked" {
return true
}
if u.LockedUntil != nil && time.Now().Before(*u.LockedUntil) {
return true
}
return false
}
// UserCreateRequest 创建用户请求结构
type UserCreateRequest struct {
Username string `json:"username" binding:"required,min=3,max=50"`
Email string `json:"email" binding:"required,email,max=255"`
Password string `json:"password" binding:"required,min=8,max=50"`
FullName string `json:"full_name" binding:"required,max=100"`
Phone *string `json:"phone" binding:"omitempty,max=20"`
Role string `json:"role" binding:"required,oneof=admin operator viewer"`
Status string `json:"status" binding:"required,oneof=active disabled"`
}
// UserUpdateRequest 更新用户请求结构
type UserUpdateRequest struct {
Email *string `json:"email" binding:"omitempty,email,max=255"`
FullName *string `json:"full_name" binding:"omitempty,max=100"`
Phone *string `json:"phone" binding:"omitempty,max=20"`
Role *string `json:"role" binding:"omitempty,oneof=admin operator viewer"`
Status *string `json:"status" binding:"omitempty,oneof=active disabled locked"`
}
// ChangePasswordRequest 修改密码请求结构
type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=8,max=50"`
}
// UserListRequest 用户列表查询请求结构
type UserListRequest struct {
Page int `form:"page" binding:"omitempty,min=1"`
PageSize int `form:"page_size" binding:"omitempty,min=1,max=100"`
Search string `form:"search" binding:"omitempty,max=100"`
Role string `form:"role" binding:"omitempty,oneof=admin operator viewer"`
Status string `form:"status" binding:"omitempty,oneof=active disabled locked"`
Sort string `form:"sort" binding:"omitempty,oneof=created_at updated_at username full_name"`
Order string `form:"order" binding:"omitempty,oneof=asc desc"`
}
// UserListItem 用户列表项结构
type UserListItem struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
FullName string `json:"full_name"`
Phone *string `json:"phone"`
Role string `json:"role"`
Status string `json:"status"`
LastLoginAt *time.Time `json:"last_login_at"`
CreatedAt time.Time `json:"created_at"`
}
// UserListResponse 用户列表响应结构
type UserListResponse struct {
List []UserListItem `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
TotalPages int `json:"total_pages"`
}
@@ -0,0 +1,299 @@
package repository
import (
"context"
"license-manager/internal/models"
"math"
"time"
"gorm.io/gorm"
)
type authorizationCodeRepository struct {
db *gorm.DB
}
// NewAuthorizationCodeRepository 创建授权码仓储实例
func NewAuthorizationCodeRepository(db *gorm.DB) AuthorizationCodeRepository {
return &authorizationCodeRepository{
db: db,
}
}
// CreateAuthorizationCode 创建授权码
func (r *authorizationCodeRepository) CreateAuthorizationCode(ctx context.Context, authCode *models.AuthorizationCode) error {
return r.db.WithContext(ctx).Create(authCode).Error
}
// GetAuthorizationCodeByID 根据ID获取授权码
func (r *authorizationCodeRepository) GetAuthorizationCodeByID(ctx context.Context, id string) (*models.AuthorizationCode, error) {
var authCode models.AuthorizationCode
err := r.db.WithContext(ctx).First(&authCode, "id = ?", id).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrAuthorizationCodeNotFound
}
return nil, err
}
return &authCode, nil
}
// GetAuthorizationCodeList 查询授权码列表
func (r *authorizationCodeRepository) GetAuthorizationCodeList(ctx context.Context, req *models.AuthorizationCodeListRequest) (*models.AuthorizationCodeListResponse, error) {
// 设置默认值
page := req.Page
if page <= 0 {
page = 1
}
pageSize := req.PageSize
if pageSize <= 0 {
pageSize = 20
}
sort := req.Sort
if sort == "" {
sort = "created_at"
}
order := req.Order
if order == "" {
order = "desc"
}
// 构建查询,包含状态计算与当前激活数量
query := r.db.WithContext(ctx).Table("authorization_codes ac").
Select(`ac.id, ac.code, ac.customer_id, c.customer_name,
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
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'
ELSE 'expired'
END AS status`).
Joins("LEFT JOIN customers c ON ac.customer_id = c.id AND c.deleted_at IS NULL").
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`)
// 添加筛选条件
if req.CustomerID != "" {
query = query.Where("ac.customer_id = ?", req.CustomerID)
}
// 状态筛选 - 在 SQL 层面处理
if req.Status != "" {
switch req.Status {
case "locked":
query = query.Where("ac.is_locked = true")
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()")
}
}
if req.StartDate != "" {
query = query.Where("ac.created_at >= ?", req.StartDate)
}
if req.EndDate != "" {
query = query.Where("ac.created_at <= ?", req.EndDate)
}
// 获取总数
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
// 分页查询
var results []struct {
ID string `json:"id"`
Code string `json:"code"`
CustomerID string `json:"customer_id"`
CustomerName *string `json:"customer_name"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
MaxActivations int `json:"max_activations"`
CurrentActivations int `json:"current_activations" gorm:"column:current_activations"`
DeploymentType string `json:"deployment_type"`
IsLocked bool `json:"is_locked"`
Description *string `json:"description"`
CreatedAt string `json:"created_at"`
Status string `json:"status"` // 添加状态字段
}
offset := (page - 1) * pageSize
orderClause := sort + " " + order
if err := query.Order(orderClause).Limit(pageSize).Offset(offset).Scan(&results).Error; err != nil {
return nil, err
}
// 转换为响应格式
list := make([]models.AuthorizationCodeListItem, len(results))
for i, result := range results {
customerName := ""
if result.CustomerName != nil {
customerName = *result.CustomerName
}
list[i] = models.AuthorizationCodeListItem{
ID: result.ID,
Code: result.Code,
CustomerID: result.CustomerID,
CustomerName: customerName,
Status: result.Status, // 使用 SQL 计算的状态
StartDate: result.StartDate,
EndDate: result.EndDate,
MaxActivations: result.MaxActivations,
CurrentActivations: result.CurrentActivations,
DeploymentType: result.DeploymentType,
IsLocked: result.IsLocked,
Description: result.Description,
CreatedAt: result.CreatedAt,
}
}
totalPages := int((total + int64(pageSize) - 1) / int64(pageSize))
return &models.AuthorizationCodeListResponse{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}, nil
}
// UpdateAuthorizationCode 更新授权码
func (r *authorizationCodeRepository) UpdateAuthorizationCode(ctx context.Context, authCode *models.AuthorizationCode) error {
return r.db.WithContext(ctx).Save(authCode).Error
}
// DeleteAuthorizationCode 删除授权码(软删除)
func (r *authorizationCodeRepository) DeleteAuthorizationCode(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&models.AuthorizationCode{}, "id = ?", id).Error
}
// CheckCustomerExists 检查客户是否存在
func (r *authorizationCodeRepository) CheckCustomerExists(ctx context.Context, customerID string) (bool, error) {
var count int64
err := r.db.WithContext(ctx).Model(&models.Customer{}).Where("id = ? AND deleted_at IS NULL", customerID).Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// GetAuthorizationChangeList 查询授权变更历史列表
func (r *authorizationCodeRepository) GetAuthorizationChangeList(ctx context.Context, authCodeID string, req *models.AuthorizationChangeListRequest) (*models.AuthorizationChangeListResponse, error) {
// 设置默认值
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100
}
if req.Sort == "" {
req.Sort = "created_at"
}
if req.Order == "" {
req.Order = "desc"
}
// 构建查询
query := r.db.WithContext(ctx).Model(&models.AuthorizationChange{}).
Select(`authorization_changes.id, authorization_changes.change_type,
authorization_changes.operator_id, users.username as operator_name,
authorization_changes.reason, authorization_changes.created_at`).
Joins("LEFT JOIN users ON authorization_changes.operator_id = users.id").
Where("authorization_changes.authorization_code_id = ?", authCodeID)
// 变更类型筛选
if req.ChangeType != "" {
query = query.Where("authorization_changes.change_type = ?", req.ChangeType)
}
// 操作人筛选
if req.OperatorID != "" {
query = query.Where("authorization_changes.operator_id = ?", req.OperatorID)
}
// 时间范围筛选
if req.StartDate != "" {
startTime, err := time.Parse("2006-01-02", req.StartDate)
if err == nil {
query = query.Where("authorization_changes.created_at >= ?", startTime)
}
}
if req.EndDate != "" {
endTime, err := time.Parse("2006-01-02", req.EndDate)
if err == nil {
// 结束时间加一天,以包含当天的所有时间
endTime = endTime.AddDate(0, 0, 1)
query = query.Where("authorization_changes.created_at < ?", endTime)
}
}
// 获取总数
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
// 排序和分页
orderBy := "authorization_changes." + req.Sort + " " + req.Order
offset := (req.Page - 1) * req.PageSize
var changes []struct {
ID string `json:"id"`
ChangeType string `json:"change_type"`
OperatorID string `json:"operator_id"`
OperatorName *string `json:"operator_name"`
Reason *string `json:"reason"`
CreatedAt time.Time `json:"created_at"`
}
if err := query.Order(orderBy).Limit(req.PageSize).Offset(offset).Scan(&changes).Error; err != nil {
return nil, err
}
// 转换为响应格式
list := make([]models.AuthorizationChangeListItem, len(changes))
for i, change := range changes {
var operatorName string
if change.OperatorName != nil {
operatorName = *change.OperatorName
}
list[i] = models.AuthorizationChangeListItem{
ID: change.ID,
ChangeType: change.ChangeType,
OperatorID: change.OperatorID,
OperatorName: operatorName,
Reason: change.Reason,
CreatedAt: change.CreatedAt.Format(time.RFC3339),
}
}
// 计算总页数
totalPages := int(math.Ceil(float64(total) / float64(req.PageSize)))
return &models.AuthorizationChangeListResponse{
List: list,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
TotalPages: totalPages,
}, nil
}
// RecordAuthorizationChange 记录授权变更历史
func (r *authorizationCodeRepository) RecordAuthorizationChange(ctx context.Context, change *models.AuthorizationChange) error {
return r.db.WithContext(ctx).Create(change).Error
}
@@ -0,0 +1,307 @@
package repository
import (
"context"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"license-manager/internal/models"
)
type customerRepository struct {
db *gorm.DB
}
// NewCustomerRepository 创建客户数据访问实例
func NewCustomerRepository(db *gorm.DB) CustomerRepository {
return &customerRepository{
db: db,
}
}
// GetCustomerList 查询客户列表
func (r *customerRepository) GetCustomerList(ctx context.Context, req *models.CustomerListRequest) (*models.CustomerListResponse, error) {
// 设置默认值
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100
}
if req.Sort == "" {
req.Sort = "created_at"
}
if req.Order == "" {
req.Order = "desc"
}
// 构建查询
query := r.db.Model(&models.Customer{})
// 搜索关键词筛选
if req.Search != "" {
searchTerm := "%" + strings.TrimSpace(req.Search) + "%"
query = query.Where(
"customer_code LIKE ? OR customer_name LIKE ? OR contact_person LIKE ? OR email LIKE ?",
searchTerm, searchTerm, searchTerm, searchTerm,
)
}
// 客户类型筛选
if req.CustomerType != "" {
query = query.Where("customer_type = ?", req.CustomerType)
}
// 客户等级筛选
if req.CustomerLevel != "" {
query = query.Where("customer_level = ?", req.CustomerLevel)
}
// 状态筛选
if req.Status != "" {
query = query.Where("status = ?", req.Status)
}
// 计算总数
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, fmt.Errorf("failed to count customers: %w", err)
}
// 排序和分页
offset := (req.Page - 1) * req.PageSize
orderClause := fmt.Sprintf("%s %s", req.Sort, strings.ToUpper(req.Order))
query = query.Order(orderClause).Offset(offset).Limit(req.PageSize)
// 查询数据
var customers []models.Customer
if err := query.Find(&customers).Error; err != nil {
return nil, fmt.Errorf("failed to query customers: %w", err)
}
// 转换为响应格式
customerList := make([]models.CustomerListItem, 0, len(customers))
for _, customer := range customers {
customerList = append(customerList, models.CustomerListItem{
ID: customer.ID,
CustomerCode: customer.CustomerCode,
CustomerName: customer.CustomerName,
CustomerType: customer.CustomerType,
ContactPerson: customer.ContactPerson,
Email: customer.Email,
CustomerLevel: customer.CustomerLevel,
Status: customer.Status,
CreatedAt: customer.CreatedAt.Format(time.RFC3339),
})
}
// 计算总页数
totalPages := int(total) / req.PageSize
if int(total)%req.PageSize > 0 {
totalPages++
}
return &models.CustomerListResponse{
List: customerList,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
TotalPages: totalPages,
}, nil
}
// GetCustomerByID 根据ID获取客户信息
func (r *customerRepository) GetCustomerByID(ctx context.Context, id string) (*models.Customer, error) {
var customer models.Customer
if err := r.db.Where("id = ?", id).First(&customer).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrCustomerNotFound
}
return nil, fmt.Errorf("failed to get customer by id: %w", err)
}
return &customer, nil
}
// CreateCustomer 创建客户
func (r *customerRepository) CreateCustomer(ctx context.Context, customer *models.Customer) error {
// 使用事务确保数据一致性
return r.db.Transaction(func(tx *gorm.DB) error {
// 生成客户编码
customerCode, err := r.generateCustomerCode(tx)
if err != nil {
return fmt.Errorf("failed to generate customer code: %w", err)
}
customer.CustomerCode = customerCode
// 创建客户
if err := tx.Create(customer).Error; err != nil {
return fmt.Errorf("failed to create customer: %w", err)
}
return nil
})
}
// generateCustomerCode 生成客户编码
func (r *customerRepository) generateCustomerCode(tx *gorm.DB) (string, error) {
currentYear := time.Now().Year()
// 查询或创建当年的序列号记录
var sequence models.CustomerCodeSequence
err := tx.Where("year = ?", currentYear).First(&sequence).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
// 创建新年份的序列号记录
sequence = models.CustomerCodeSequence{
Year: currentYear,
SequenceNumber: 1,
}
if err := tx.Create(&sequence).Error; err != nil {
return "", fmt.Errorf("failed to create sequence: %w", err)
}
} else {
return "", fmt.Errorf("failed to query sequence: %w", err)
}
} else {
// 更新序列号
sequence.SequenceNumber++
if err := tx.Save(&sequence).Error; err != nil {
return "", fmt.Errorf("failed to update sequence: %w", err)
}
}
// 生成客户编码:CUS-YYYY-NNNN
customerCode := fmt.Sprintf("CUS-%d-%04d", currentYear, sequence.SequenceNumber)
return customerCode, nil
}
// UpdateCustomer 更新客户信息
func (r *customerRepository) UpdateCustomer(ctx context.Context, customer *models.Customer) error {
if err := r.db.Save(customer).Error; err != nil {
return fmt.Errorf("failed to update customer: %w", err)
}
return nil
}
// DeleteCustomer 删除客户(物理删除)
func (r *customerRepository) DeleteCustomer(ctx context.Context, id string) error {
result := r.db.Unscoped().Where("id = ?", id).Delete(&models.Customer{})
if result.Error != nil {
return fmt.Errorf("failed to delete customer: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrCustomerNotFound
}
return nil
}
// CheckCustomerHasAuthorizationCodes 检查客户是否有关联的授权码
func (r *customerRepository) CheckCustomerHasAuthorizationCodes(ctx context.Context, customerID string) (bool, error) {
var count int64
err := r.db.Model(&models.AuthorizationCode{}).Where("customer_id = ?", customerID).Count(&count).Error
if err != nil {
return false, fmt.Errorf("failed to check authorization codes: %w", err)
}
return count > 0, nil
}
// CheckCustomerHasLicenses 检查客户是否有关联的许可证
func (r *customerRepository) CheckCustomerHasLicenses(ctx context.Context, customerID string) (bool, error) {
var count int64
err := r.db.Model(&models.License{}).Where("customer_id = ?", customerID).Count(&count).Error
if err != nil {
return false, fmt.Errorf("failed to check licenses: %w", err)
}
return count > 0, nil
}
// GetCustomerCount 获取客户总数(用于统计)
func (r *customerRepository) GetCustomerCount(ctx context.Context, filters map[string]interface{}) (int64, error) {
var count int64
query := r.db.Model(&models.Customer{})
// 应用筛选条件
for key, value := range filters {
query = query.Where(fmt.Sprintf("%s = ?", key), value)
}
if err := query.Count(&count).Error; err != nil {
return 0, fmt.Errorf("failed to count customers: %w", err)
}
return count, nil
}
// GetCustomerAuthorizationStats 获取客户授权统计信息
func (r *customerRepository) GetCustomerAuthorizationStats(ctx context.Context, customerID string) (*models.AuthorizationStats, error) {
stats := &models.AuthorizationStats{}
// 1. 授权码统计
// 总授权码数量
var totalAuthCodes int64
if err := r.db.Model(&models.AuthorizationCode{}).
Where("customer_id = ?", customerID).
Count(&totalAuthCodes).Error; err != nil {
return nil, fmt.Errorf("failed to count total auth codes: %w", err)
}
stats.TotalAuthCodes = totalAuthCodes
// 已过期授权码数量(end_date < NOW(),不考虑锁定状态)
var expiredAuthCodes int64
if err := r.db.Model(&models.AuthorizationCode{}).
Where("customer_id = ? AND end_date < NOW()", customerID).
Count(&expiredAuthCodes).Error; err != nil {
return nil, fmt.Errorf("failed to count expired auth codes: %w", err)
}
stats.ExpiredAuthCodes = expiredAuthCodes
// 30日内即将到期授权码数量(end_date >= NOW() AND end_date <= DATE_ADD(NOW(), INTERVAL 30 DAY),不考虑锁定状态)
var expiringSoonAuthCodes int64
if err := r.db.Model(&models.AuthorizationCode{}).
Where("customer_id = ? AND end_date >= NOW() AND end_date <= DATE_ADD(NOW(), INTERVAL 30 DAY)", customerID).
Count(&expiringSoonAuthCodes).Error; err != nil {
return nil, fmt.Errorf("failed to count expiring soon auth codes: %w", err)
}
stats.ExpiringSoonAuthCodes = expiringSoonAuthCodes
// 2. 许可证统计
// 总许可证数量
var totalLicenses int64
if err := r.db.Model(&models.License{}).
Where("customer_id = ? AND deleted_at IS NULL", customerID).
Count(&totalLicenses).Error; err != nil {
return nil, fmt.Errorf("failed to count total licenses: %w", err)
}
stats.TotalLicenses = totalLicenses
// 已激活许可证数量(status = 'active')
var activeLicenses int64
if err := r.db.Model(&models.License{}).
Where("customer_id = ? AND status = 'active' AND deleted_at IS NULL", customerID).
Count(&activeLicenses).Error; err != nil {
return nil, fmt.Errorf("failed to count active licenses: %w", err)
}
stats.ActiveLicenses = activeLicenses
// 未激活许可证数量(total - active)
stats.InactiveLicenses = totalLicenses - activeLicenses
// 已过期许可证数量(关联的授权码已过期)
// 需要通过JOIN authorization_codes表来判断
var expiredLicenses int64
if err := r.db.Model(&models.License{}).
Joins("INNER JOIN authorization_codes ON licenses.authorization_code_id = authorization_codes.id").
Where("licenses.customer_id = ? AND authorization_codes.end_date < NOW() AND licenses.deleted_at IS NULL", customerID).
Count(&expiredLicenses).Error; err != nil {
return nil, fmt.Errorf("failed to count expired licenses: %w", err)
}
stats.ExpiredLicenses = expiredLicenses
return stats, nil
}
@@ -0,0 +1,206 @@
package repository
import (
"context"
"time"
"license-manager/internal/models"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
"gorm.io/gorm"
)
type dashboardRepository struct {
db *gorm.DB
}
// NewDashboardRepository 创建仪表盘数据访问层实例
func NewDashboardRepository(db *gorm.DB) DashboardRepository {
return &dashboardRepository{
db: db,
}
}
// GetAuthorizationTrendData 获取授权趋势数据
func (r *dashboardRepository) GetAuthorizationTrendData(ctx context.Context, startDate, endDate time.Time) ([]models.TrendData, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
var trendData []models.TrendData
// 生成日期范围
dates := make([]time.Time, 0)
for d := startDate; !d.After(endDate); d = d.AddDate(0, 0, 1) {
dates = append(dates, d)
}
// 查询每日数据
for _, date := range dates {
dayStart := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
dayEnd := dayStart.Add(24*time.Hour - time.Nanosecond)
// 统计当日的授权数据
var totalCount, newCount, expiredCount int64
// 当日总授权数(截至当日24:00的累计有效授权数)
err := r.db.WithContext(ctx).Model(&models.AuthorizationCode{}).
Where("created_at <= ?", dayEnd).
Count(&totalCount).Error
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 当日新增授权数
err = r.db.WithContext(ctx).Model(&models.AuthorizationCode{}).
Where("created_at >= ? AND created_at <= ?", dayStart, dayEnd).
Count(&newCount).Error
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 当日过期授权数
err = r.db.WithContext(ctx).Model(&models.AuthorizationCode{}).
Where("end_date >= ? AND end_date < ?", dayStart, dayEnd).
Count(&expiredCount).Error
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
trendData = append(trendData, models.TrendData{
Date: date.Format("2006-01-02"),
TotalAuthorizations: totalCount,
NewAuthorizations: newCount,
ExpiredAuthorizations: expiredCount,
})
}
return trendData, nil
}
// GetRecentAuthorizations 获取最近授权列表
func (r *dashboardRepository) GetRecentAuthorizations(ctx context.Context, req *models.DashboardRecentAuthorizationsRequest) (*models.DashboardRecentAuthorizationsResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 设置默认值
limit := req.Limit
if limit <= 0 {
limit = 20
}
if limit > 100 {
limit = 100
}
// 构建查询
query := r.db.WithContext(ctx).Table("authorization_codes ac").
Select(`ac.id, ac.code, ac.customer_id, c.customer_name as customer_name,
ac.description, ac.start_date, ac.end_date, ac.max_activations,
COALESCE(l.active_count, 0) as current_activations,
ac.created_at, ac.updated_at, ac.is_locked`).
Joins("LEFT JOIN customers c ON ac.customer_id = c.id").
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("1=1").
Order("ac.created_at DESC")
// 添加筛选条件
if req.CustomerID != "" {
query = query.Where("ac.customer_id = ?", req.CustomerID)
}
// 状态筛选(虚字段)
now := time.Now()
switch req.Status {
case "normal":
query = query.Where("ac.is_locked = ? AND ac.end_date > ?", false, now)
case "locked":
query = query.Where("ac.is_locked = ?", true)
case "expired":
query = query.Where("ac.end_date <= ?", now)
}
// 查询总数 - 使用简化查询避免JOIN复杂性
var total int64
countQuery := r.db.WithContext(ctx).Model(&models.AuthorizationCode{}).Where("1=1")
// 添加相同的筛选条件
if req.CustomerID != "" {
countQuery = countQuery.Where("customer_id = ?", req.CustomerID)
}
// 状态筛选
switch req.Status {
case "normal":
countQuery = countQuery.Where("is_locked = ? AND end_date > ?", false, now)
case "locked":
countQuery = countQuery.Where("is_locked = ?", true)
case "expired":
countQuery = countQuery.Where("end_date <= ?", now)
}
err := countQuery.Count(&total).Error
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 查询列表数据
type queryResult struct {
ID string `gorm:"column:id"`
Code string `gorm:"column:code"`
CustomerID string `gorm:"column:customer_id"`
CustomerName string `gorm:"column:customer_name"`
Description string `gorm:"column:description"`
StartDate time.Time `gorm:"column:start_date"`
EndDate time.Time `gorm:"column:end_date"`
MaxActivations int `gorm:"column:max_activations"`
CurrentActivations int `gorm:"column:current_activations"`
CreatedAt time.Time `gorm:"column:created_at"`
UpdatedAt time.Time `gorm:"column:updated_at"`
IsLocked bool `gorm:"column:is_locked"`
}
var results []queryResult
err = query.Limit(limit).Find(&results).Error
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 转换为响应格式
authorizations := make([]models.RecentAuthorization, 0, len(results))
for _, result := range results {
// 计算状态
status := "normal"
statusDisplay := i18n.GetEnumMessage("authorization_code_status", "normal", lang)
if result.IsLocked {
status = "locked"
statusDisplay = i18n.GetEnumMessage("authorization_code_status", "locked", lang)
} else if result.EndDate.Before(now) {
status = "expired"
statusDisplay = i18n.GetEnumMessage("authorization_code_status", "expired", lang)
}
authorizations = append(authorizations, models.RecentAuthorization{
ID: result.ID,
Code: result.Code,
CustomerID: result.CustomerID,
CustomerName: result.CustomerName,
Description: result.Description,
Status: status,
StatusDisplay: statusDisplay,
StartDate: result.StartDate,
EndDate: result.EndDate,
MaxActivations: result.MaxActivations,
CurrentActivations: result.CurrentActivations,
CreatedAt: result.CreatedAt,
UpdatedAt: result.UpdatedAt,
})
}
return &models.DashboardRecentAuthorizationsResponse{
List: authorizations,
Total: total,
}, nil
}
+50
View File
@@ -0,0 +1,50 @@
package repository
import "errors"
// 客户领域的业务错误(给开发者看的,英文即可)
var (
ErrCustomerNotFound = errors.New("customer not found")
ErrCustomerAlreadyExists = errors.New("customer already exists")
ErrCustomerCodeDuplicate = errors.New("customer code already exists")
)
// 授权码领域的业务错误
var (
ErrAuthorizationCodeNotFound = errors.New("authorization code not found")
ErrAuthorizationCodeAlreadyExists = errors.New("authorization code already exists")
ErrAuthorizationCodeDuplicate = errors.New("authorization code already exists")
)
// 许可证领域的业务错误
var (
ErrLicenseNotFound = errors.New("license not found")
ErrLicenseAlreadyExists = errors.New("license already exists")
ErrLicenseDuplicate = errors.New("license already exists")
)
// 通用的数据库/系统错误(给开发者看的)
var (
ErrDatabaseConnection = errors.New("database connection failed")
ErrDatabaseQuery = errors.New("database query failed")
ErrDatabaseCreate = errors.New("database create failed")
ErrDatabaseUpdate = errors.New("database update failed")
ErrDatabaseDelete = errors.New("database delete failed")
)
// 辅助函数:判断错误类型
func IsCustomerNotFound(err error) bool {
return errors.Is(err, ErrCustomerNotFound)
}
func IsCustomerAlreadyExists(err error) bool {
return errors.Is(err, ErrCustomerAlreadyExists)
}
func IsDatabaseError(err error) bool {
return errors.Is(err, ErrDatabaseConnection) ||
errors.Is(err, ErrDatabaseQuery) ||
errors.Is(err, ErrDatabaseCreate) ||
errors.Is(err, ErrDatabaseUpdate) ||
errors.Is(err, ErrDatabaseDelete)
}
+124
View File
@@ -0,0 +1,124 @@
package repository
import (
"context"
"license-manager/internal/models"
"time"
)
// CustomerRepository 客户数据访问接口
type CustomerRepository interface {
// GetCustomerList 查询客户列表
GetCustomerList(ctx context.Context, req *models.CustomerListRequest) (*models.CustomerListResponse, error)
// GetCustomerByID 根据ID获取客户信息
GetCustomerByID(ctx context.Context, id string) (*models.Customer, error)
// CreateCustomer 创建客户
CreateCustomer(ctx context.Context, customer *models.Customer) error
// UpdateCustomer 更新客户信息
UpdateCustomer(ctx context.Context, customer *models.Customer) error
// DeleteCustomer 删除客户(物理删除)
DeleteCustomer(ctx context.Context, id string) error
// GetCustomerCount 获取客户总数(用于统计)
GetCustomerCount(ctx context.Context, filters map[string]interface{}) (int64, error)
// CheckCustomerHasAuthorizationCodes 检查客户是否有关联的授权码
CheckCustomerHasAuthorizationCodes(ctx context.Context, customerID string) (bool, error)
// CheckCustomerHasLicenses 检查客户是否有关联的许可证
CheckCustomerHasLicenses(ctx context.Context, customerID string) (bool, error)
// GetCustomerAuthorizationStats 获取客户授权统计信息
GetCustomerAuthorizationStats(ctx context.Context, customerID string) (*models.AuthorizationStats, error)
}
// UserRepository 用户数据访问接口
type UserRepository interface {
// GetUserByUsername 根据用户名获取用户
GetUserByUsername(ctx context.Context, username string) (*models.User, error)
// GetUserByID 根据ID获取用户
GetUserByID(ctx context.Context, id string) (*models.User, error)
// UpdateUser 更新用户信息
UpdateUser(ctx context.Context, user *models.User) error
// IncrementLoginAttempts 增加登录失败次数
IncrementLoginAttempts(ctx context.Context, id string) error
// ResetLoginAttempts 重置登录失败次数
ResetLoginAttempts(ctx context.Context, id string) error
// LockUser 锁定用户账号
LockUser(ctx context.Context, id string, lockDuration int) error
}
// AuthorizationCodeRepository 授权码数据访问接口
type AuthorizationCodeRepository interface {
// CreateAuthorizationCode 创建授权码
CreateAuthorizationCode(ctx context.Context, authCode *models.AuthorizationCode) error
// GetAuthorizationCodeByID 根据ID获取授权码
GetAuthorizationCodeByID(ctx context.Context, id string) (*models.AuthorizationCode, error)
// GetAuthorizationCodeList 查询授权码列表
GetAuthorizationCodeList(ctx context.Context, req *models.AuthorizationCodeListRequest) (*models.AuthorizationCodeListResponse, error)
// UpdateAuthorizationCode 更新授权码
UpdateAuthorizationCode(ctx context.Context, authCode *models.AuthorizationCode) error
// DeleteAuthorizationCode 删除授权码(软删除)
DeleteAuthorizationCode(ctx context.Context, id string) error
// CheckCustomerExists 检查客户是否存在
CheckCustomerExists(ctx context.Context, customerID string) (bool, error)
// GetAuthorizationChangeList 查询授权变更历史列表
GetAuthorizationChangeList(ctx context.Context, authCodeID string, req *models.AuthorizationChangeListRequest) (*models.AuthorizationChangeListResponse, error)
// RecordAuthorizationChange 记录授权变更历史
RecordAuthorizationChange(ctx context.Context, change *models.AuthorizationChange) error
}
// LicenseRepository 许可证数据访问接口
type LicenseRepository interface {
// GetLicenseList 查询许可证列表
GetLicenseList(ctx context.Context, req *models.LicenseListRequest) (*models.LicenseListResponse, error)
// GetLicenseByID 根据ID获取许可证信息
GetLicenseByID(ctx context.Context, id string) (*models.License, error)
// CreateLicense 创建许可证
CreateLicense(ctx context.Context, license *models.License) error
// UpdateLicense 更新许可证信息
UpdateLicense(ctx context.Context, license *models.License) error
// CheckAuthorizationCodeExists 检查授权码是否存在
CheckAuthorizationCodeExists(ctx context.Context, authCodeID string) (bool, error)
// GetAuthorizationCodeByID 根据ID获取授权码信息
GetAuthorizationCodeByID(ctx context.Context, authCodeID string) (*models.AuthorizationCode, error)
// GetAuthorizationCodeByCode 根据授权码获取授权码信息
GetAuthorizationCodeByCode(ctx context.Context, code string) (*models.AuthorizationCode, error)
// GetLicenseByKey 根据许可证密钥获取许可证信息
GetLicenseByKey(ctx context.Context, licenseKey string) (*models.License, error)
// GetActiveLicenseCount 获取指定授权码的激活许可证数量
GetActiveLicenseCount(ctx context.Context, authCodeID string) (int64, error)
}
// DashboardRepository 仪表盘数据访问接口
type DashboardRepository interface {
// GetAuthorizationTrendData 获取授权趋势数据
GetAuthorizationTrendData(ctx context.Context, startDate, endDate time.Time) ([]models.TrendData, error)
// GetRecentAuthorizations 获取最近授权列表
GetRecentAuthorizations(ctx context.Context, req *models.DashboardRecentAuthorizationsRequest) (*models.DashboardRecentAuthorizationsResponse, error)
}
@@ -0,0 +1,254 @@
package repository
import (
"context"
"math"
"time"
"gorm.io/gorm"
"license-manager/internal/models"
)
type licenseRepository struct {
db *gorm.DB
}
// NewLicenseRepository 创建许可证数据访问实例
func NewLicenseRepository(db *gorm.DB) LicenseRepository {
return &licenseRepository{
db: db,
}
}
// GetLicenseList 查询许可证列表
func (r *licenseRepository) GetLicenseList(ctx context.Context, req *models.LicenseListRequest) (*models.LicenseListResponse, error) {
// 设置默认值
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100
}
if req.Sort == "" {
req.Sort = "created_at"
}
if req.Order == "" {
req.Order = "desc"
}
// 构建查询
query := r.db.Model(&models.License{}).
Select(`licenses.id, licenses.license_key, licenses.authorization_code_id,
authorization_codes.code as authorization_code, customers.customer_name,
licenses.hardware_fingerprint, licenses.status, licenses.activation_ip,
licenses.last_online_ip, licenses.activated_at, licenses.last_heartbeat`).
Joins("LEFT JOIN authorization_codes ON licenses.authorization_code_id = authorization_codes.id").
Joins("LEFT JOIN customers ON licenses.customer_id = customers.id")
// 授权码ID筛选
if req.AuthorizationCodeID != "" {
query = query.Where("licenses.authorization_code_id = ?", req.AuthorizationCodeID)
}
// 客户ID筛选
if req.CustomerID != "" {
query = query.Where("licenses.customer_id = ?", req.CustomerID)
}
// 状态筛选
if req.Status != "" {
query = query.Where("licenses.status = ?", req.Status)
}
// 在线状态筛选
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)
}
}
// 获取总数
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, err
}
// 排序和分页
orderBy := "licenses." + req.Sort + " " + req.Order
offset := (req.Page - 1) * req.PageSize
var licenses []struct {
ID string `json:"id"`
LicenseKey string `json:"license_key"`
AuthorizationCodeID string `json:"authorization_code_id"`
AuthorizationCode string `json:"authorization_code"`
CustomerName string `json:"customer_name"`
HardwareFingerprint string `json:"hardware_fingerprint"`
Status string `json:"status"`
ActivationIP *string `json:"activation_ip"`
LastOnlineIP *string `json:"last_online_ip"`
ActivatedAt *time.Time `json:"activated_at"`
LastHeartbeat *time.Time `json:"last_heartbeat"`
}
if err := query.Order(orderBy).Limit(req.PageSize).Offset(offset).Scan(&licenses).Error; err != nil {
return nil, err
}
// 转换为响应格式
list := make([]models.LicenseListItem, len(licenses))
for i, license := range licenses {
// 计算在线状态
isOnline := false
if license.LastHeartbeat != nil {
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
isOnline = license.LastHeartbeat.After(fiveMinutesAgo)
}
// 格式化时间
var activatedAtStr, lastHeartbeatStr *string
if license.ActivatedAt != nil {
str := license.ActivatedAt.Format(time.RFC3339)
activatedAtStr = &str
}
if license.LastHeartbeat != nil {
str := license.LastHeartbeat.Format(time.RFC3339)
lastHeartbeatStr = &str
}
list[i] = models.LicenseListItem{
ID: license.ID,
LicenseKey: license.LicenseKey,
AuthorizationCodeID: license.AuthorizationCodeID,
AuthorizationCode: license.AuthorizationCode,
CustomerName: license.CustomerName,
HardwareFingerprint: license.HardwareFingerprint,
Status: license.Status,
IsOnline: isOnline,
ActivationIP: license.ActivationIP,
LastOnlineIP: license.LastOnlineIP,
ActivatedAt: activatedAtStr,
LastHeartbeat: lastHeartbeatStr,
}
}
// 计算总页数
totalPages := int(math.Ceil(float64(total) / float64(req.PageSize)))
return &models.LicenseListResponse{
List: list,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
TotalPages: totalPages,
}, nil
}
// GetLicenseByID 根据ID获取许可证信息
func (r *licenseRepository) GetLicenseByID(ctx context.Context, id string) (*models.License, error) {
var license models.License
err := r.db.Preload("AuthorizationCode").Preload("Customer").
Where("id = ?", id).First(&license).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrLicenseNotFound
}
return nil, err
}
// 计算在线状态
if license.LastHeartbeat != nil {
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
license.IsOnline = license.LastHeartbeat.After(fiveMinutesAgo)
}
return &license, nil
}
// CreateLicense 创建许可证
func (r *licenseRepository) CreateLicense(ctx context.Context, license *models.License) error {
return r.db.Create(license).Error
}
// UpdateLicense 更新许可证信息
func (r *licenseRepository) UpdateLicense(ctx context.Context, license *models.License) error {
return r.db.Save(license).Error
}
// CheckAuthorizationCodeExists 检查授权码是否存在
func (r *licenseRepository) CheckAuthorizationCodeExists(ctx context.Context, authCodeID string) (bool, error) {
var count int64
err := r.db.Model(&models.AuthorizationCode{}).Where("id = ?", authCodeID).Count(&count).Error
if err != nil {
return false, err
}
return count > 0, nil
}
// GetAuthorizationCodeByID 根据ID获取授权码信息
func (r *licenseRepository) GetAuthorizationCodeByID(ctx context.Context, authCodeID string) (*models.AuthorizationCode, error) {
var authCode models.AuthorizationCode
err := r.db.Where("id = ?", authCodeID).First(&authCode).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrAuthorizationCodeNotFound
}
return nil, err
}
return &authCode, nil
}
// GetAuthorizationCodeByCode 根据授权码获取授权码信息
func (r *licenseRepository) GetAuthorizationCodeByCode(ctx context.Context, code string) (*models.AuthorizationCode, error) {
var authCode models.AuthorizationCode
err := r.db.Where("code = ?", code).First(&authCode).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrAuthorizationCodeNotFound
}
return nil, err
}
return &authCode, nil
}
// GetLicenseByKey 根据许可证密钥获取许可证信息
func (r *licenseRepository) GetLicenseByKey(ctx context.Context, licenseKey string) (*models.License, error) {
var license models.License
err := r.db.Preload("AuthorizationCode").Preload("Customer").
Where("license_key = ?", licenseKey).First(&license).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, ErrLicenseNotFound
}
return nil, err
}
// 计算在线状态
if license.LastHeartbeat != nil {
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
license.IsOnline = license.LastHeartbeat.After(fiveMinutesAgo)
}
return &license, nil
}
// GetActiveLicenseCount 获取指定授权码的激活许可证数量
func (r *licenseRepository) GetActiveLicenseCount(ctx context.Context, authCodeID string) (int64, error) {
var count int64
err := r.db.Model(&models.License{}).
Where("authorization_code_id = ? AND status = ?", authCodeID, "active").
Count(&count).Error
return count, err
}
@@ -0,0 +1,74 @@
package repository
import (
"context"
"time"
"license-manager/internal/models"
"gorm.io/gorm"
)
type userRepository struct {
db *gorm.DB
}
// NewUserRepository 创建用户仓储实例
func NewUserRepository(db *gorm.DB) UserRepository {
return &userRepository{db: db}
}
// GetUserByUsername 根据用户名获取用户
func (r *userRepository) GetUserByUsername(ctx context.Context, username string) (*models.User, error) {
var user models.User
err := r.db.WithContext(ctx).Where("username = ?", username).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
// GetUserByID 根据ID获取用户
func (r *userRepository) GetUserByID(ctx context.Context, id string) (*models.User, error) {
var user models.User
err := r.db.WithContext(ctx).Where("id = ?", id).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
// UpdateUser 更新用户信息
func (r *userRepository) UpdateUser(ctx context.Context, user *models.User) error {
return r.db.WithContext(ctx).Save(user).Error
}
// IncrementLoginAttempts 增加登录失败次数
func (r *userRepository) IncrementLoginAttempts(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Model(&models.User{}).
Where("id = ?", id).
UpdateColumn("login_attempts", gorm.Expr("login_attempts + 1")).Error
}
// ResetLoginAttempts 重置登录失败次数
func (r *userRepository) ResetLoginAttempts(ctx context.Context, id string) error {
now := time.Now()
return r.db.WithContext(ctx).Model(&models.User{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"login_attempts": 0,
"last_login_at": &now,
"locked_until": nil,
}).Error
}
// LockUser 锁定用户账号
func (r *userRepository) LockUser(ctx context.Context, id string, lockDuration int) error {
lockUntil := time.Now().Add(time.Duration(lockDuration) * time.Minute)
return r.db.WithContext(ctx).Model(&models.User{}).
Where("id = ?", id).
Updates(map[string]interface{}{
"status": "locked",
"locked_until": &lockUntil,
}).Error
}
+71 -16
View File
@@ -1,36 +1,91 @@
package service
import (
"errors"
"context"
"time"
"license-manager/internal/config"
"license-manager/internal/models"
"license-manager/internal/repository"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
"license-manager/pkg/utils"
"gorm.io/gorm"
)
type authService struct{}
type authService struct {
userRepo repository.UserRepository
}
// NewAuthService 创建认证服务实例
func NewAuthService() AuthService {
return &authService{}
func NewAuthService(userRepo repository.UserRepository) AuthService {
return &authService{
userRepo: userRepo,
}
}
// Login 用户登录
func (s *authService) Login(req *models.LoginRequest) (*models.LoginData, error) {
func (s *authService) Login(ctx context.Context, req *models.LoginRequest, clientIP string) (*models.LoginData, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
cfg := config.GetConfig()
if cfg == nil {
return nil, errors.New("配置未初始化")
return nil, i18n.NewI18nError("900004", lang, "配置未初始化")
}
// 验证用户名和密码
if req.Username != cfg.Auth.Admin.Username || req.Password != cfg.Auth.Admin.Password {
return nil, errors.New("用户名或密码错误")
// 业务逻辑:参数验证
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 生成JWT Token (用户ID设为1,因为只有一个管理员用户)
token, err := utils.GenerateToken(1, req.Username, "administrator")
// 根据用户名查找用户
user, err := s.userRepo.GetUserByUsername(ctx, req.Username)
if err != nil {
return nil, errors.New("生成令牌失败")
if err == gorm.ErrRecordNotFound {
return nil, i18n.NewI18nError("100003", lang) // 用户名或密码错误
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 检查账号状态
if user.Status == "disabled" {
return nil, i18n.NewI18nError("100005", lang) // 权限不足/账号已被禁用
}
// 检查账号是否被锁定
if user.IsAccountLocked() {
return nil, i18n.NewI18nError("100001", lang) // 认证已过期/账号已被锁定
}
// 验证密码
if !utils.CheckPasswordHash(req.Password, user.PasswordHash) {
// 增加登录失败次数
s.userRepo.IncrementLoginAttempts(ctx, user.ID)
// 检查是否需要锁定账号
if user.LoginAttempts >= 4 { // 第5次失败时锁定
s.userRepo.LockUser(ctx, user.ID, 30) // 锁定30分钟
return nil, i18n.NewI18nError("100001", lang) // 密码错误次数过多,账号已被锁定
}
return nil, i18n.NewI18nError("100003", lang) // 用户名或密码错误
}
// 登录成功,重置登录失败次数并更新登录信息
user.LastLoginIP = &clientIP
s.userRepo.ResetLoginAttempts(ctx, user.ID)
// 如果账号状态是locked但锁定时间已过期,更新状态为active
if user.Status == "locked" && (user.LockedUntil == nil || time.Now().After(*user.LockedUntil)) {
user.Status = "active"
s.userRepo.UpdateUser(ctx, user)
}
// 生成JWT Token
token, err := utils.GenerateToken(user.ID, user.Username, user.Role)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 计算过期时间(秒)
@@ -40,8 +95,8 @@ func (s *authService) Login(req *models.LoginRequest) (*models.LoginData, error)
Token: token,
ExpiresIn: expiresIn,
UserInfo: models.UserInfo{
Username: req.Username,
Role: "administrator",
Username: user.Username,
Role: user.Role,
},
}, nil
}
@@ -57,7 +112,7 @@ func (s *authService) RefreshToken(token string) (string, error) {
// 生成新Token
newToken, err := utils.GenerateToken(claims.UserID, claims.Username, claims.Role)
if err != nil {
return "", errors.New("生成新令牌失败")
return "", err
}
return newToken, nil
@@ -67,4 +122,4 @@ func (s *authService) RefreshToken(token string) (string, error) {
func (s *authService) ValidateToken(token string) error {
_, err := utils.ValidateToken(token)
return err
}
}
@@ -0,0 +1,734 @@
package service
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"log"
"strings"
"time"
"license-manager/internal/models"
"license-manager/internal/repository"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
)
type authorizationCodeService struct {
authCodeRepo repository.AuthorizationCodeRepository
customerRepo repository.CustomerRepository
licenseRepo repository.LicenseRepository
}
// NewAuthorizationCodeService 创建授权码服务实例
func NewAuthorizationCodeService(
authCodeRepo repository.AuthorizationCodeRepository,
customerRepo repository.CustomerRepository,
licenseRepo repository.LicenseRepository,
) AuthorizationCodeService {
return &authorizationCodeService{
authCodeRepo: authCodeRepo,
customerRepo: customerRepo,
licenseRepo: licenseRepo,
}
}
// CreateAuthorizationCode 创建授权码
func (s *authorizationCodeService) CreateAuthorizationCode(ctx context.Context, req *models.AuthorizationCodeCreateRequest) (*models.AuthorizationCodeCreateResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 验证客户是否存在并检查状态
customer, err := s.customerRepo.GetCustomerByID(ctx, req.CustomerID)
if err != nil {
if errors.Is(err, repository.ErrCustomerNotFound) {
return nil, i18n.NewI18nError("200001", lang) // 客户不存在
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 检查客户状态:如果客户状态为停用(disabled),不允许创建授权
if customer.Status == "disabled" {
return nil, i18n.NewI18nError("200007", lang) // 客户已停用,无法创建授权
}
// 业务逻辑:计算开始时间和结束时间
now := time.Now()
startDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) // 当天00:00:00开始
endDate := startDate.AddDate(0, 0, req.ValidityDays-1).Add(23*time.Hour + 59*time.Minute + 59*time.Second) // 有效期最后一天的23:59:59结束
// 获取当前用户ID
currentUserID := pkgcontext.GetUserIDFromContext(ctx)
if currentUserID == "" {
return nil, i18n.NewI18nError("100004", lang) // 缺少认证信息
}
// 生成授权码
authCode, err := s.generateAuthorizationCode(req.CustomerID)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 处理JSON字段
var featureConfig, usageLimits, customParameters models.JSON
if req.FeatureConfig != nil {
featureConfigBytes, err := json.Marshal(req.FeatureConfig)
if err != nil {
return nil, i18n.NewI18nError("300010", lang) // 配置参数错误
}
featureConfig = models.JSON(featureConfigBytes)
}
if req.UsageLimits != nil {
usageLimitsBytes, err := json.Marshal(req.UsageLimits)
if err != nil {
return nil, i18n.NewI18nError("300010", lang) // 配置参数错误
}
usageLimits = models.JSON(usageLimitsBytes)
}
if req.CustomParameters != nil {
customParametersBytes, err := json.Marshal(req.CustomParameters)
if err != nil {
return nil, i18n.NewI18nError("300010", lang) // 配置参数错误
}
customParameters = models.JSON(customParametersBytes)
}
// 设置默认加密类型
encryptionType := req.EncryptionType
if encryptionType == nil {
defaultEncryption := "standard"
encryptionType = &defaultEncryption
}
// 构建授权码实体
authCodeEntity := &models.AuthorizationCode{
Code: authCode,
CustomerID: req.CustomerID,
CreatedBy: currentUserID,
SoftwareID: req.SoftwareID,
Description: req.Description,
StartDate: startDate,
EndDate: endDate,
DeploymentType: req.DeploymentType,
EncryptionType: encryptionType,
SoftwareVersion: req.SoftwareVersion,
MaxActivations: req.MaxActivations,
IsLocked: false,
FeatureConfig: featureConfig,
UsageLimits: usageLimits,
CustomParameters: customParameters,
}
// 委托给Repository层进行数据创建
if err := s.authCodeRepo.CreateAuthorizationCode(ctx, authCodeEntity); err != nil {
// 根据错误类型包装为完整的I18nError
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
return nil, i18n.NewI18nError("300002", lang) // 授权码已存在
}
// 数据库相关错误
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
return &models.AuthorizationCodeCreateResponse{
ID: authCodeEntity.ID,
Code: authCodeEntity.Code,
}, nil
}
// GetAuthorizationCodeList 查询授权码列表
func (s *authorizationCodeService) GetAuthorizationCodeList(ctx context.Context, req *models.AuthorizationCodeListRequest) (*models.AuthorizationCodeListResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 委托给Repository层进行数据访问
result, err := s.authCodeRepo.GetAuthorizationCodeList(ctx, req)
if err != nil {
// 数据库相关错误
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 业务逻辑:添加多语言显示字段和状态计算
now := time.Now()
for i := range result.List {
item := &result.List[i]
s.fillAuthCodeDisplayFields(item, lang, now)
// 根据状态筛选
if req.Status != "" && item.Status != req.Status {
// 移除不匹配的项 - 这里简化处理,实际应该在查询时过滤
continue
}
}
return result, nil
}
// GetAuthorizationCode 获取单个授权码详情
func (s *authorizationCodeService) GetAuthorizationCode(ctx context.Context, id string) (*models.AuthorizationCode, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if id == "" {
return nil, i18n.NewI18nError("900001", lang)
}
// 委托给Repository层进行数据访问
authCode, err := s.authCodeRepo.GetAuthorizationCodeByID(ctx, id)
if err != nil {
// 根据Repository错误类型包装为完整的I18nError
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
return nil, i18n.NewI18nError("300001", lang)
}
// 数据库相关错误
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 查询客户信息
customer, err := s.customerRepo.GetCustomerByID(ctx, authCode.CustomerID)
if err != nil {
// 如果客户不存在,不影响授权码查询,只是不填充客户信息
// 记录错误日志但不返回错误
} else {
// 填充客户信息
authCode.CustomerInfo = &models.CustomerInfoForAuthCode{
ID: customer.ID,
CustomerCode: customer.CustomerCode,
CustomerName: customer.CustomerName,
CustomerType: customer.CustomerType,
CustomerTypeDisplay: i18n.GetEnumMessage("customer_type", customer.CustomerType, lang),
Status: customer.Status,
StatusDisplay: i18n.GetEnumMessage("customer_status", customer.Status, lang),
CreatedAt: customer.CreatedAt.Format(time.RFC3339),
}
}
// 查询已激活的许可证数量
activatedCount, err := s.licenseRepo.GetActiveLicenseCount(ctx, id)
if err != nil {
// 查询失败时,数量设为0,记录错误日志但不返回错误
activatedCount = 0
}
authCode.ActivatedLicensesCount = activatedCount
// 填充多语言显示字段和计算状态
s.fillAuthorizationCodeDisplayFields(authCode, lang)
return authCode, nil
}
// GenerateAuthorizationFile 生成授权码文件内容
func (s *authorizationCodeService) GenerateAuthorizationFile(ctx context.Context, id string) ([]byte, string, string, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
if id == "" {
return nil, "", "", i18n.NewI18nError("900001", lang)
}
authCode, err := s.authCodeRepo.GetAuthorizationCodeByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
return nil, "", "", i18n.NewI18nError("300001", lang) // 授权码不存在
}
return nil, "", "", i18n.NewI18nError("900004", lang, err.Error())
}
now := time.Now()
if authCode.IsLocked {
return nil, "", "", i18n.NewI18nError("300003", lang) // 授权码已锁定
}
if now.Before(authCode.StartDate) || now.After(authCode.EndDate) {
return nil, "", "", i18n.NewI18nError("300001", lang) // 授权码未生效或已过期
}
fileName := fmt.Sprintf("authorization_%s.txt", authCode.Code)
return []byte(authCode.Code), fileName, authCode.Code, nil
}
// fillAuthorizationCodeDisplayFields 填充完整授权码模型的多语言显示字段
func (s *authorizationCodeService) fillAuthorizationCodeDisplayFields(authCode *models.AuthorizationCode, lang string) {
// 计算状态
now := time.Now()
if authCode.IsLocked {
authCode.Status = "locked"
} else if now.After(authCode.EndDate) {
authCode.Status = "expired"
} else {
authCode.Status = "normal"
}
// 添加多语言显示字段
authCode.StatusDisplay = i18n.GetEnumMessage("authorization_status", authCode.Status, lang)
authCode.DeploymentTypeDisplay = i18n.GetEnumMessage("deployment_type", authCode.DeploymentType, lang)
if authCode.EncryptionType != nil {
authCode.EncryptionTypeDisplay = i18n.GetEnumMessage("encryption_type", *authCode.EncryptionType, lang)
}
// TODO: 统计当前激活数量
authCode.CurrentActivations = 0
// TODO: 获取客户名称
// 可以考虑在Repository层通过JOIN获取,或在这里单独查询
}
// UpdateAuthorizationCode 更新授权码
func (s *authorizationCodeService) UpdateAuthorizationCode(ctx context.Context, id string, req *models.AuthorizationCodeUpdateRequest) (*models.AuthorizationCode, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if id == "" {
return nil, i18n.NewI18nError("900001", lang)
}
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 先查询现有授权码
existingAuthCode, err := s.authCodeRepo.GetAuthorizationCodeByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
return nil, i18n.NewI18nError("300001", lang)
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 获取当前用户ID
currentUserID := pkgcontext.GetUserIDFromContext(ctx)
if currentUserID == "" {
return nil, i18n.NewI18nError("100004", lang)
}
// 记录变更前的配置
oldConfig := s.buildConfigSnapshot(existingAuthCode)
// 只更新提供的字段
if req.SoftwareID != nil {
existingAuthCode.SoftwareID = req.SoftwareID
}
if req.Description != nil {
existingAuthCode.Description = req.Description
}
if req.ValidityDays != nil {
// 重新计算开始时间和结束时间
now := time.Now()
startDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
endDate := startDate.AddDate(0, 0, *req.ValidityDays-1).Add(23*time.Hour + 59*time.Minute + 59*time.Second)
existingAuthCode.StartDate = startDate
existingAuthCode.EndDate = endDate
}
if req.DeploymentType != nil {
existingAuthCode.DeploymentType = *req.DeploymentType
}
if req.EncryptionType != nil {
existingAuthCode.EncryptionType = req.EncryptionType
}
if req.SoftwareVersion != nil {
existingAuthCode.SoftwareVersion = req.SoftwareVersion
}
if req.MaxActivations != nil {
existingAuthCode.MaxActivations = *req.MaxActivations
}
// 处理JSON字段
if req.FeatureConfig != nil {
featureConfigBytes, err := json.Marshal(req.FeatureConfig)
if err != nil {
return nil, i18n.NewI18nError("300010", lang)
}
existingAuthCode.FeatureConfig = models.JSON(featureConfigBytes)
}
if req.UsageLimits != nil {
usageLimitsBytes, err := json.Marshal(req.UsageLimits)
if err != nil {
return nil, i18n.NewI18nError("300010", lang)
}
existingAuthCode.UsageLimits = models.JSON(usageLimitsBytes)
}
if req.CustomParameters != nil {
customParametersBytes, err := json.Marshal(req.CustomParameters)
if err != nil {
return nil, i18n.NewI18nError("300010", lang)
}
existingAuthCode.CustomParameters = models.JSON(customParametersBytes)
}
// 委托给Repository层进行数据更新
if err := s.authCodeRepo.UpdateAuthorizationCode(ctx, existingAuthCode); err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 填充多语言显示字段和计算状态
s.fillAuthorizationCodeDisplayFields(existingAuthCode, lang)
// 记录变更历史到 authorization_changes 表
newConfig := s.buildConfigSnapshot(existingAuthCode)
if err := s.recordAuthorizationChange(ctx, id, req.ChangeType, req.Reason, currentUserID, oldConfig, newConfig); err != nil {
log.Printf("记录授权变更历史失败: %v", err)
}
return existingAuthCode, nil
}
// LockUnlockAuthorizationCode 锁定/解锁授权码
func (s *authorizationCodeService) LockUnlockAuthorizationCode(ctx context.Context, id string, req *models.AuthorizationCodeLockRequest) (*models.AuthorizationCode, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if id == "" {
return nil, i18n.NewI18nError("900001", lang)
}
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 先查询现有授权码
existingAuthCode, err := s.authCodeRepo.GetAuthorizationCodeByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
return nil, i18n.NewI18nError("300001", lang)
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 获取当前用户ID
currentUserID := pkgcontext.GetUserIDFromContext(ctx)
if currentUserID == "" {
return nil, i18n.NewI18nError("100004", lang)
}
// 记录变更前的配置
oldConfig := s.buildConfigSnapshot(existingAuthCode)
// 更新锁定状态
now := time.Now()
existingAuthCode.IsLocked = req.IsLocked
if req.IsLocked {
// 锁定
existingAuthCode.LockReason = req.LockReason
existingAuthCode.LockedAt = &now
existingAuthCode.LockedBy = &currentUserID
} else {
// 解锁
existingAuthCode.LockReason = nil
existingAuthCode.LockedAt = nil
existingAuthCode.LockedBy = nil
}
// 委托给Repository层进行数据更新
if err := s.authCodeRepo.UpdateAuthorizationCode(ctx, existingAuthCode); err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 填充多语言显示字段和计算状态
s.fillAuthorizationCodeDisplayFields(existingAuthCode, lang)
// 记录变更历史到 authorization_changes 表
changeType := "lock"
if !req.IsLocked {
changeType = "unlock"
}
newConfig := s.buildConfigSnapshot(existingAuthCode)
if err := s.recordAuthorizationChange(ctx, id, changeType, req.Reason, currentUserID, oldConfig, newConfig); err != nil {
log.Printf("记录授权变更历史失败: %v", err)
}
return existingAuthCode, nil
}
// DeleteAuthorizationCode 删除授权码(软删除)
func (s *authorizationCodeService) DeleteAuthorizationCode(ctx context.Context, id string) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if id == "" {
return i18n.NewI18nError("900001", lang)
}
// 获取当前用户ID
currentUserID := pkgcontext.GetUserIDFromContext(ctx)
if currentUserID == "" {
return i18n.NewI18nError("100004", lang)
}
// 先查询现有授权码,确保存在
existingAuthCode, err := s.authCodeRepo.GetAuthorizationCodeByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
return i18n.NewI18nError("300001", lang)
}
return i18n.NewI18nError("900004", lang, err.Error())
}
// 记录变更前的配置
oldConfig := s.buildConfigSnapshot(existingAuthCode)
// 委托给Repository层进行软删除
if err := s.authCodeRepo.DeleteAuthorizationCode(ctx, id); err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
// 记录变更历史到 authorization_changes 表
emptyConfig := make(map[string]interface{})
if err := s.recordAuthorizationChange(ctx, id, "delete", nil, currentUserID, oldConfig, emptyConfig); err != nil {
log.Printf("记录授权变更历史失败: %v", err)
}
return nil
}
// recordAuthorizationChange 记录授权变更历史
func (s *authorizationCodeService) recordAuthorizationChange(ctx context.Context, authCodeID string, changeType string, reason *string, operatorID string, oldConfig, newConfig map[string]interface{}) error {
// 构建变更历史记录
change := &models.AuthorizationChange{
AuthorizationCodeID: authCodeID,
ChangeType: changeType,
OperatorID: operatorID,
Reason: reason,
}
// 序列化配置为JSON
if oldConfig != nil {
oldConfigBytes, err := json.Marshal(oldConfig)
if err != nil {
return err
}
change.OldConfig = models.JSON(oldConfigBytes)
}
if newConfig != nil {
newConfigBytes, err := json.Marshal(newConfig)
if err != nil {
return err
}
change.NewConfig = models.JSON(newConfigBytes)
}
// 委托给Repository层记录变更历史
return s.authCodeRepo.RecordAuthorizationChange(ctx, change)
}
// buildConfigSnapshot 构建配置快照,用于记录变更历史
func (s *authorizationCodeService) buildConfigSnapshot(authCode *models.AuthorizationCode) map[string]interface{} {
config := make(map[string]interface{})
// 基础配置
config["code"] = authCode.Code
config["software_id"] = authCode.SoftwareID
config["description"] = authCode.Description
config["start_date"] = authCode.StartDate.Format(time.RFC3339)
config["end_date"] = authCode.EndDate.Format(time.RFC3339)
config["deployment_type"] = authCode.DeploymentType
config["encryption_type"] = authCode.EncryptionType
config["software_version"] = authCode.SoftwareVersion
config["max_activations"] = authCode.MaxActivations
config["is_locked"] = authCode.IsLocked
config["lock_reason"] = authCode.LockReason
// JSON配置字段
if len(authCode.FeatureConfig) > 0 {
var featureConfig map[string]interface{}
if err := json.Unmarshal(authCode.FeatureConfig, &featureConfig); err == nil {
config["feature_config"] = featureConfig
}
}
if len(authCode.UsageLimits) > 0 {
var usageLimits map[string]interface{}
if err := json.Unmarshal(authCode.UsageLimits, &usageLimits); err == nil {
config["usage_limits"] = usageLimits
}
}
if len(authCode.CustomParameters) > 0 {
var customParameters map[string]interface{}
if err := json.Unmarshal(authCode.CustomParameters, &customParameters); err == nil {
config["custom_parameters"] = customParameters
}
}
return config
}
// fillAuthCodeDisplayFields 填充授权码列表项多语言显示字段
func (s *authorizationCodeService) fillAuthCodeDisplayFields(item *models.AuthorizationCodeListItem, lang string, now time.Time) {
// 计算状态
endDate, _ := time.Parse("2006-01-02T15:04:05Z07:00", item.EndDate)
if item.IsLocked {
item.Status = "locked"
} else if now.After(endDate) {
item.Status = "expired"
} else {
item.Status = "normal"
}
// 添加多语言显示字段
item.StatusDisplay = i18n.GetEnumMessage("authorization_status", item.Status, lang)
item.DeploymentTypeDisplay = i18n.GetEnumMessage("deployment_type", item.DeploymentType, lang)
item.CustomerNameDisplay = item.CustomerName // 客户名称暂时不需要翻译
}
// generateAuthorizationCode 生成授权码
// 格式: LIC-{4位客户代码}-{12位随机}-{4位校验码}
// 安全性:12位随机字符(62字符集)提供约 3.23 × 10^21 种可能组合
func (s *authorizationCodeService) generateAuthorizationCode(customerID string) (string, error) {
// 获取客户编码的前4位作为前缀
customerCode := "COMP" // 默认前缀
if len(customerID) >= 4 {
customerCode = strings.ToUpper(customerID[:4])
}
// 生成12位随机字符串(从8位增加到12位以提升安全性)
randomStr, err := s.generateRandomString(12)
if err != nil {
return "", err
}
// 生成4位校验码
checksum := s.generateChecksum(customerCode + randomStr)
// 格式: LIC-{customer_code}-{random}-{checksum}
return fmt.Sprintf("LIC-%s-%s-%s", customerCode, randomStr, checksum), nil
}
// generateRandomString 生成随机字符串
// 使用62字符集(A-Z, a-z, 0-9)提供更高的熵值
func (s *authorizationCodeService) generateRandomString(length int) (string, error) {
// 扩展字符集到62字符(A-Z, a-z, 0-9)以提升安全性
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
for i, b := range bytes {
bytes[i] = chars[b%byte(len(chars))]
}
return string(bytes), nil
}
// generateChecksum 生成校验码
// 基于输入字符串的ASCII值计算,用于格式验证
func (s *authorizationCodeService) generateChecksum(input string) string {
sum := 0
for _, char := range input {
sum += int(char)
}
// 使用与随机字符串相同的62字符集以保持一致性
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
checksum := ""
for i := 0; i < 4; i++ {
checksum += string(chars[sum%len(chars)])
sum = sum / len(chars)
}
return checksum
}
// ValidateAuthorizationCodeFormat 验证授权码格式和校验和
// 格式: LIC-{4位客户代码}-{8或12位随机}-{4位校验码}
// 兼容旧格式(8位随机,36字符集)和新格式(12位随机,62字符集)
func (s *authorizationCodeService) ValidateAuthorizationCodeFormat(code string) bool {
// 解析格式
parts := strings.Split(code, "-")
if len(parts) != 4 || parts[0] != "LIC" {
return false
}
// 验证各部分长度(兼容旧格式8位和新格式12位)
customerCodeLen := len(parts[1])
randomLen := len(parts[2])
checksumLen := len(parts[3])
// 客户代码必须为4位,校验和必须为4位
if customerCodeLen != 4 || checksumLen != 4 {
return false
}
// 随机部分:兼容8位(旧格式)和12位(新格式)
if randomLen != 8 && randomLen != 12 {
return false
}
// 根据随机部分长度选择字符集验证校验和
// 8位:使用36字符集(旧格式)
// 12位:使用62字符集(新格式)
expectedChecksum := s.generateChecksumWithCharset(parts[1]+parts[2], randomLen == 12)
return expectedChecksum == parts[3]
}
// generateChecksumWithCharset 生成校验码(支持指定字符集)
// useExtendedCharset: true使用62字符集(新格式),false使用36字符集(旧格式)
func (s *authorizationCodeService) generateChecksumWithCharset(input string, useExtendedCharset bool) string {
sum := 0
for _, char := range input {
sum += int(char)
}
var chars string
if useExtendedCharset {
// 使用62字符集(A-Z, a-z, 0-9)用于新格式
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
} else {
// 使用36字符集(A-Z, 0-9)用于旧格式
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
}
checksum := ""
for i := 0; i < 4; i++ {
checksum += string(chars[sum%len(chars)])
sum = sum / len(chars)
}
return checksum
}
// GetAuthorizationChangeList 查询授权变更历史列表
func (s *authorizationCodeService) GetAuthorizationChangeList(ctx context.Context, authCodeID string, req *models.AuthorizationChangeListRequest) (*models.AuthorizationChangeListResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if authCodeID == "" {
return nil, i18n.NewI18nError("900001", lang)
}
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 验证授权码是否存在
_, err := s.authCodeRepo.GetAuthorizationCodeByID(ctx, authCodeID)
if err != nil {
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
return nil, i18n.NewI18nError("300001", lang) // 授权码不存在
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 委托给Repository层进行数据访问
result, err := s.authCodeRepo.GetAuthorizationChangeList(ctx, authCodeID, req)
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 业务逻辑:添加多语言显示字段
for i := range result.List {
s.fillChangeDisplayFields(&result.List[i], lang)
}
return result, nil
}
// fillChangeDisplayFields 填充变更历史列表项多语言显示字段
func (s *authorizationCodeService) fillChangeDisplayFields(item *models.AuthorizationChangeListItem, lang string) {
// 填充变更类型显示字段
item.ChangeTypeDisplay = i18n.GetEnumMessage("authorization_change_type", item.ChangeType, lang)
}
@@ -0,0 +1,313 @@
package service
import (
"context"
"errors"
"strings"
"license-manager/internal/models"
"license-manager/internal/repository"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
)
type customerService struct {
customerRepo repository.CustomerRepository
}
// NewCustomerService 创建客户服务实例
func NewCustomerService(customerRepo repository.CustomerRepository) CustomerService {
return &customerService{
customerRepo: customerRepo,
}
}
// GetCustomerList 查询客户列表
func (s *customerService) GetCustomerList(ctx context.Context, req *models.CustomerListRequest) (*models.CustomerListResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if req == nil {
return nil, i18n.NewI18nError("900001", lang) // 业务错误,不覆盖多语言message
}
// 委托给Repository层进行数据访问
result, err := s.customerRepo.GetCustomerList(ctx, req)
if err != nil {
// 根据Repository错误类型包装为完整的I18nError
if errors.Is(err, repository.ErrCustomerNotFound) {
return nil, i18n.NewI18nError("200001", lang) // 业务错误,保持多语言
}
// 数据库相关错误,使用系统错误码,覆盖message显示详细信息
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 业务逻辑:添加多语言显示字段
for i := range result.List {
s.fillDisplayFields(&result.List[i], lang)
}
return result, nil
}
// GetCustomer 获取单个客户详情
func (s *customerService) GetCustomer(ctx context.Context, id string) (*models.Customer, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if id == "" {
return nil, i18n.NewI18nError("900001", lang)
}
// 委托给Repository层进行数据访问
customer, err := s.customerRepo.GetCustomerByID(ctx, id)
if err != nil {
// 根据Repository错误类型包装为完整的I18nError
if errors.Is(err, repository.ErrCustomerNotFound) {
return nil, i18n.NewI18nError("200001", lang)
}
// 数据库相关错误
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 获取授权统计信息
stats, err := s.customerRepo.GetCustomerAuthorizationStats(ctx, id)
if err != nil {
// 统计信息获取失败不影响主流程,记录错误但继续执行
// 如果获取失败,stats 为 nil,客户详情仍然可以返回,只是没有统计信息
// 根据需求文档,空数据时应该返回0,所以这里创建一个空的统计对象
stats = &models.AuthorizationStats{}
}
customer.AuthorizationStats = stats
// 填充多语言显示字段
s.fillCustomerDisplayFields(customer, lang)
return customer, nil
}
// fillDisplayFields 填充列表项多语言显示字段
func (s *customerService) fillDisplayFields(item *models.CustomerListItem, lang string) {
item.CustomerTypeDisplay = i18n.GetEnumMessage("customer_type", item.CustomerType, lang)
item.CustomerLevelDisplay = i18n.GetEnumMessage("customer_level", item.CustomerLevel, lang)
item.StatusDisplay = i18n.GetEnumMessage("customer_status", item.Status, lang)
}
// CreateCustomer 创建客户
func (s *customerService) CreateCustomer(ctx context.Context, req *models.CustomerCreateRequest) (*models.Customer, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 获取当前用户ID(从Context中获取)
// TODO: 实现用户上下文获取,这里暂时使用硬编码
currentUserID := "admin_uuid" // 实际应该从JWT token中获取
// 构建客户实体
customer := &models.Customer{
CustomerName: req.CustomerName,
CustomerType: req.CustomerType,
ContactPerson: req.ContactPerson,
ContactTitle: req.ContactTitle,
Email: req.Email,
Phone: req.Phone,
Address: req.Address,
CompanySize: req.CompanySize,
CustomerLevel: req.CustomerLevel,
Status: req.Status,
Description: req.Description,
CreatedBy: currentUserID,
// CustomerCode 将在Repository层自动生成
// CreatedAt, UpdatedAt 将在模型的BeforeCreate中自动设置
}
// 委托给Repository层进行数据创建
if err := s.customerRepo.CreateCustomer(ctx, customer); err != nil {
// 根据错误类型包装为完整的I18nError
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
return nil, i18n.NewI18nError("200002", lang) // 客户已存在
}
// 数据库相关错误
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 填充多语言显示字段
s.fillCustomerDisplayFields(customer, lang)
return customer, nil
}
// UpdateCustomer 更新客户信息
func (s *customerService) UpdateCustomer(ctx context.Context, id string, req *models.CustomerUpdateRequest) (*models.Customer, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if id == "" {
return nil, i18n.NewI18nError("900001", lang)
}
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 先查询现有客户
existingCustomer, err := s.customerRepo.GetCustomerByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrCustomerNotFound) {
return nil, i18n.NewI18nError("200001", lang)
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 获取当前用户ID
currentUserID := "admin_uuid" // TODO: 从JWT token中获取
// 只更新提供的字段
if req.CustomerName != nil {
existingCustomer.CustomerName = *req.CustomerName
}
if req.CustomerType != nil {
existingCustomer.CustomerType = *req.CustomerType
}
if req.ContactPerson != nil {
existingCustomer.ContactPerson = *req.ContactPerson
}
if req.ContactTitle != nil {
existingCustomer.ContactTitle = req.ContactTitle
}
if req.Email != nil {
existingCustomer.Email = req.Email
}
if req.Phone != nil {
existingCustomer.Phone = req.Phone
}
if req.Address != nil {
existingCustomer.Address = req.Address
}
if req.CompanySize != nil {
existingCustomer.CompanySize = req.CompanySize
}
if req.CustomerLevel != nil {
existingCustomer.CustomerLevel = *req.CustomerLevel
}
if req.Status != nil {
existingCustomer.Status = *req.Status
}
if req.Description != nil {
existingCustomer.Description = req.Description
}
// 设置更新者
existingCustomer.UpdatedBy = &currentUserID
// 委托给Repository层进行数据更新
if err := s.customerRepo.UpdateCustomer(ctx, existingCustomer); err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 填充多语言显示字段
s.fillCustomerDisplayFields(existingCustomer, lang)
return existingCustomer, nil
}
// DeleteCustomer 删除客户(物理删除,删除前检查是否有授权)
func (s *customerService) DeleteCustomer(ctx context.Context, id string) error {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if id == "" {
return i18n.NewI18nError("900001", lang)
}
// 检查客户是否存在
_, err := s.customerRepo.GetCustomerByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrCustomerNotFound) {
return i18n.NewI18nError("200001", lang)
}
return i18n.NewI18nError("900004", lang, err.Error())
}
// 检查是否有关联的授权码
hasAuthCodes, err := s.customerRepo.CheckCustomerHasAuthorizationCodes(ctx, id)
if err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
if hasAuthCodes {
return i18n.NewI18nError("200006", lang)
}
// 检查是否有关联的许可证
hasLicenses, err := s.customerRepo.CheckCustomerHasLicenses(ctx, id)
if err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
if hasLicenses {
return i18n.NewI18nError("200006", lang)
}
// 委托给Repository层进行物理删除
if err := s.customerRepo.DeleteCustomer(ctx, id); err != nil {
if errors.Is(err, repository.ErrCustomerNotFound) {
return i18n.NewI18nError("200001", lang)
}
return i18n.NewI18nError("900004", lang, err.Error())
}
return nil
}
// UpdateCustomerStatus 更新客户状态
func (s *customerService) UpdateCustomerStatus(ctx context.Context, id string, req *models.CustomerStatusUpdateRequest) (*models.Customer, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if id == "" {
return nil, i18n.NewI18nError("900001", lang)
}
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 先查询现有客户
existingCustomer, err := s.customerRepo.GetCustomerByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrCustomerNotFound) {
return nil, i18n.NewI18nError("200001", lang)
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 获取当前用户ID
currentUserID := "admin_uuid" // TODO: 从JWT token中获取
// 更新状态
existingCustomer.Status = req.Status
existingCustomer.UpdatedBy = &currentUserID
// 委托给Repository层进行数据更新
if err := s.customerRepo.UpdateCustomer(ctx, existingCustomer); err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 填充多语言显示字段
s.fillCustomerDisplayFields(existingCustomer, lang)
return existingCustomer, nil
}
// fillCustomerDisplayFields 填充完整客户模型的多语言显示字段
func (s *customerService) fillCustomerDisplayFields(customer *models.Customer, lang string) {
customer.CustomerTypeDisplay = i18n.GetEnumMessage("customer_type", customer.CustomerType, lang)
customer.CustomerLevelDisplay = i18n.GetEnumMessage("customer_level", customer.CustomerLevel, lang)
customer.StatusDisplay = i18n.GetEnumMessage("customer_status", customer.Status, lang)
if customer.CompanySize != nil {
customer.CompanySizeDisplay = i18n.GetEnumMessage("company_size", *customer.CompanySize, lang)
}
}
@@ -0,0 +1,174 @@
package service
import (
"context"
"fmt"
"time"
"license-manager/internal/models"
"license-manager/internal/repository"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
)
type dashboardService struct {
dashboardRepo repository.DashboardRepository
}
// NewDashboardService 创建仪表盘服务实例
func NewDashboardService(dashboardRepo repository.DashboardRepository) DashboardService {
return &dashboardService{
dashboardRepo: dashboardRepo,
}
}
// GetAuthorizationTrend 获取授权趋势数据
func (s *dashboardService) GetAuthorizationTrend(ctx context.Context, req *models.DashboardAuthorizationTrendRequest) (*models.DashboardAuthorizationTrendResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
var startDate, endDate time.Time
var descriptionDisplay string
// 获取时区,默认使用本地时区
loc := time.Local
if req.Timezone != "" {
var err error
loc, err = time.LoadLocation(req.Timezone)
if err != nil {
// 时区解析失败,使用本地时区
loc = time.Local
}
}
now := time.Now().In(loc)
// 根据类型计算时间范围
switch req.Type {
case "week":
// 本周:从周一到周日
weekday := int(now.Weekday())
if weekday == 0 { // Sunday
weekday = 7
}
startDate = now.AddDate(0, 0, -(weekday-1))
startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, loc)
endDate = startDate.AddDate(0, 0, 6)
endDate = time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 999999999, loc)
descriptionDisplay = i18n.GetEnumMessage("dashboard_period", "week", lang)
case "month":
// 本月:从1号到月末
startDate = time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
endDate = startDate.AddDate(0, 1, -1)
endDate = time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 999999999, loc)
descriptionDisplay = i18n.GetEnumMessage("dashboard_period", "month", lang)
case "custom":
// 自定义时间范围
if req.StartDate == "" || req.EndDate == "" {
return nil, i18n.NewI18nError("400002", lang) // 开始日期和结束日期都是必填的
}
var err error
startDate, err = time.ParseInLocation("2006-01-02", req.StartDate, loc)
if err != nil {
return nil, i18n.NewI18nError("400002", lang) // 开始日期格式错误
}
endDate, err = time.ParseInLocation("2006-01-02", req.EndDate, loc)
if err != nil {
return nil, i18n.NewI18nError("400003", lang) // 结束日期格式错误
}
// 验证时间范围
if startDate.After(endDate) {
return nil, i18n.NewI18nError("400005", lang) // 开始日期不能晚于结束日期
}
// 验证时间跨度不超过一年
if endDate.Sub(startDate) > 365*24*time.Hour {
return nil, i18n.NewI18nError("400004", lang) // 时间范围超过限制
}
// 设置为全天
startDate = time.Date(startDate.Year(), startDate.Month(), startDate.Day(), 0, 0, 0, 0, startDate.Location())
endDate = time.Date(endDate.Year(), endDate.Month(), endDate.Day(), 23, 59, 59, 999999999, endDate.Location())
descriptionDisplay = fmt.Sprintf("%s - %s", req.StartDate, req.EndDate)
default:
return nil, i18n.NewI18nError("400001", lang) // 时间类型参数错误
}
// 获取趋势数据
trendData, err := s.dashboardRepo.GetAuthorizationTrendData(ctx, startDate, endDate)
if err != nil {
return nil, err
}
// 计算汇总信息
var totalCount, newCount, expiredCount int64
for _, data := range trendData {
newCount += data.NewAuthorizations
expiredCount += data.ExpiredAuthorizations
}
// 获取期间结束时的总授权数
if len(trendData) > 0 {
totalCount = trendData[len(trendData)-1].TotalAuthorizations
}
// 计算增长率(本期新增相对于期初的百分比)
var growthRate float64
if totalCount > 0 && newCount > 0 {
periodStartCount := totalCount - newCount
if periodStartCount > 0 {
growthRate = float64(newCount) / float64(periodStartCount) * 100
} else {
growthRate = 100.0 // 如果期初为0,则增长率为100%
}
}
return &models.DashboardAuthorizationTrendResponse{
Period: models.TrendPeriod{
Type: req.Type,
StartDate: startDate.Format("2006-01-02"),
EndDate: endDate.Format("2006-01-02"),
DescriptionDisplay: descriptionDisplay,
},
TrendData: trendData,
Summary: models.TrendSummary{
TotalCount: totalCount,
NewCount: newCount,
ExpiredCount: expiredCount,
GrowthRate: growthRate,
},
}, nil
}
// GetRecentAuthorizations 获取最近授权列表
func (s *dashboardService) GetRecentAuthorizations(ctx context.Context, req *models.DashboardRecentAuthorizationsRequest) (*models.DashboardRecentAuthorizationsResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 验证limit参数
if req.Limit < 0 || req.Limit > 100 {
return nil, i18n.NewI18nError("400006", lang) // 数量限制参数错误
}
// 验证status参数
if req.Status != "" && req.Status != "normal" && req.Status != "locked" && req.Status != "expired" {
return nil, i18n.NewI18nError("400001", lang) // 状态参数错误
}
// 调用repository获取数据
return s.dashboardRepo.GetRecentAuthorizations(ctx, req)
}
+70
View File
@@ -0,0 +1,70 @@
package service
import (
"context"
"license-manager/internal/models"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
)
type enumService struct{}
// NewEnumService 创建枚举服务实例
func NewEnumService() EnumService {
return &enumService{}
}
// GetAllEnums 获取所有枚举类型及其值
func (s *enumService) GetAllEnums(ctx context.Context) (*models.EnumListResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 从i18n管理器获取所有枚举值
enumsMap := i18n.GetAllEnums(lang)
var enums []models.EnumTypeResponse
for enumType, enumItems := range enumsMap {
var items []models.EnumItem
for key, display := range enumItems {
items = append(items, models.EnumItem{
Key: key,
Display: display,
})
}
enums = append(enums, models.EnumTypeResponse{
Type: enumType,
Items: items,
})
}
return &models.EnumListResponse{
Enums: enums,
}, nil
}
// GetEnumsByType 获取指定类型的枚举值
func (s *enumService) GetEnumsByType(ctx context.Context, enumType string) (*models.EnumTypeResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if enumType == "" {
return nil, i18n.NewI18nError("900001", lang)
}
// 从i18n管理器获取指定类型的枚举值
enumItems := i18n.GetEnumsByType(enumType, lang)
var items []models.EnumItem
for key, display := range enumItems {
items = append(items, models.EnumItem{
Key: key,
Display: display,
})
}
return &models.EnumTypeResponse{
Type: enumType,
Items: items,
}, nil
}
+59 -3
View File
@@ -1,10 +1,13 @@
package service
import "license-manager/internal/models"
import (
"context"
"license-manager/internal/models"
)
// AuthService 认证服务接口
type AuthService interface {
Login(req *models.LoginRequest) (*models.LoginData, error)
Login(ctx context.Context, req *models.LoginRequest, clientIP string) (*models.LoginData, error)
RefreshToken(token string) (string, error)
ValidateToken(token string) error
}
@@ -13,4 +16,57 @@ type AuthService interface {
type SystemService interface {
GetHealthStatus() *models.HealthResponse
GetSystemInfo() map[string]interface{}
}
}
// CustomerService 客户服务接口
type CustomerService interface {
GetCustomerList(ctx context.Context, req *models.CustomerListRequest) (*models.CustomerListResponse, error)
GetCustomer(ctx context.Context, id string) (*models.Customer, error)
CreateCustomer(ctx context.Context, req *models.CustomerCreateRequest) (*models.Customer, error)
UpdateCustomer(ctx context.Context, id string, req *models.CustomerUpdateRequest) (*models.Customer, error)
DeleteCustomer(ctx context.Context, id string) error
UpdateCustomerStatus(ctx context.Context, id string, req *models.CustomerStatusUpdateRequest) (*models.Customer, error)
}
// EnumService 枚举服务接口
type EnumService interface {
GetAllEnums(ctx context.Context) (*models.EnumListResponse, error)
GetEnumsByType(ctx context.Context, enumType string) (*models.EnumTypeResponse, error)
}
// AuthorizationCodeService 授权码服务接口
type AuthorizationCodeService interface {
CreateAuthorizationCode(ctx context.Context, req *models.AuthorizationCodeCreateRequest) (*models.AuthorizationCodeCreateResponse, error)
GetAuthorizationCodeList(ctx context.Context, req *models.AuthorizationCodeListRequest) (*models.AuthorizationCodeListResponse, error)
GetAuthorizationCode(ctx context.Context, id string) (*models.AuthorizationCode, error)
UpdateAuthorizationCode(ctx context.Context, id string, req *models.AuthorizationCodeUpdateRequest) (*models.AuthorizationCode, error)
LockUnlockAuthorizationCode(ctx context.Context, id string, req *models.AuthorizationCodeLockRequest) (*models.AuthorizationCode, error)
DeleteAuthorizationCode(ctx context.Context, id string) error
GetAuthorizationChangeList(ctx context.Context, authCodeID string, req *models.AuthorizationChangeListRequest) (*models.AuthorizationChangeListResponse, error)
GenerateAuthorizationFile(ctx context.Context, id string) ([]byte, string, string, error)
}
// LicenseService 许可证服务接口
type LicenseService interface {
GetLicenseList(ctx context.Context, req *models.LicenseListRequest) (*models.LicenseListResponse, error)
GetLicense(ctx context.Context, id string) (*models.LicenseDetailResponse, error)
CreateLicense(ctx context.Context, req *models.LicenseCreateRequest) (*models.License, error)
RevokeLicense(ctx context.Context, id string, req *models.LicenseRevokeRequest) (*models.License, error)
GenerateLicenseFile(ctx context.Context, id string) ([]byte, string, string, error)
// 客户端激活和心跳接口
ActivateLicense(ctx context.Context, req *models.ActivateRequest, clientIP string) (*models.ActivateResponse, error)
Heartbeat(ctx context.Context, req *models.HeartbeatRequest, clientIP string) (*models.HeartbeatResponse, error)
// 统计接口
GetStatsOverview(ctx context.Context) (*models.StatsOverviewResponse, error)
}
// DashboardService 仪表盘服务接口
type DashboardService interface {
// 获取授权趋势数据
GetAuthorizationTrend(ctx context.Context, req *models.DashboardAuthorizationTrendRequest) (*models.DashboardAuthorizationTrendResponse, error)
// 获取最近授权列表
GetRecentAuthorizations(ctx context.Context, req *models.DashboardRecentAuthorizationsRequest) (*models.DashboardRecentAuthorizationsResponse, error)
}
+851
View File
@@ -0,0 +1,851 @@
package service
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"license-manager/internal/config"
"license-manager/internal/models"
"license-manager/internal/repository"
pkgcontext "license-manager/pkg/context"
"license-manager/pkg/i18n"
"license-manager/pkg/utils"
"github.com/sirupsen/logrus"
"gorm.io/gorm"
)
type licenseService struct {
licenseRepo repository.LicenseRepository
db *gorm.DB
logger *logrus.Logger
rsaPrivateKey *utils.RSAPrivateKey // RSA私钥(缓存)
}
// NewLicenseService 创建许可证服务实例
func NewLicenseService(licenseRepo repository.LicenseRepository, db *gorm.DB, logger *logrus.Logger) LicenseService {
return &licenseService{
licenseRepo: licenseRepo,
db: db,
logger: logger,
}
}
// GetLicenseList 查询许可证列表
func (s *licenseService) GetLicenseList(ctx context.Context, req *models.LicenseListRequest) (*models.LicenseListResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if req == nil {
return nil, i18n.NewI18nError("900001", lang) // 业务错误,不覆盖多语言message
}
// 委托给Repository层进行数据访问
result, err := s.licenseRepo.GetLicenseList(ctx, req)
if err != nil {
// 数据库相关错误,使用系统错误码,覆盖message显示详细信息
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 业务逻辑:添加多语言显示字段
for i := range result.List {
s.fillDisplayFields(&result.List[i], lang)
}
return result, nil
}
// fillDisplayFields 填充列表项多语言显示字段
func (s *licenseService) fillDisplayFields(item *models.LicenseListItem, lang string) {
// 填充状态显示字段
item.StatusDisplay = i18n.GetEnumMessage("license_status", item.Status, lang)
// 填充在线状态显示字段
if item.IsOnline {
item.IsOnlineDisplay = i18n.GetEnumMessage("license_online_status", "online", lang)
} else {
item.IsOnlineDisplay = i18n.GetEnumMessage("license_online_status", "offline", lang)
}
}
// GetLicense 获取单个许可证详情
func (s *licenseService) GetLicense(ctx context.Context, id string) (*models.LicenseDetailResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if id == "" {
return nil, i18n.NewI18nError("900001", lang)
}
// 委托给Repository层进行数据访问
license, err := s.licenseRepo.GetLicenseByID(ctx, id)
if err != nil {
// 根据Repository错误类型包装为完整的I18nError
if errors.Is(err, repository.ErrLicenseNotFound) {
return nil, i18n.NewI18nError("300006", lang) // 许可证不存在
}
// 数据库相关错误,使用系统错误码,覆盖message显示详细信息
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 转换为详情响应格式
response := s.convertToDetailResponse(license, lang)
return response, nil
}
// CreateLicense 创建许可证
func (s *licenseService) CreateLicense(ctx context.Context, req *models.LicenseCreateRequest) (*models.License, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 获取授权码信息以获取客户ID
authCode, err := s.licenseRepo.GetAuthorizationCodeByID(ctx, req.AuthorizationCodeID)
if err != nil {
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
return nil, i18n.NewI18nError("300001", lang) // 授权码不存在
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 生成许可证密钥
licenseKey, err := s.generateLicenseKey()
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
now := time.Now()
// 构建许可证实体,手动添加的离线许可证直接视为已激活
license := &models.License{
LicenseKey: licenseKey,
AuthorizationCodeID: req.AuthorizationCodeID,
CustomerID: authCode.CustomerID, // 从授权码获取客户ID
HardwareFingerprint: req.HardwareFingerprint,
ActivationIP: req.ActivationIP,
Status: "active",
ActivatedAt: &now,
}
// 设置设备信息
if req.DeviceInfo != nil {
deviceInfoBytes, err := json.Marshal(req.DeviceInfo)
if err == nil {
license.DeviceInfo = models.JSON(deviceInfoBytes)
}
}
// 委托给Repository层进行数据创建
if err := s.licenseRepo.CreateLicense(ctx, license); err != nil {
// 根据错误类型包装为完整的I18nError
if strings.Contains(err.Error(), "duplicate") || strings.Contains(err.Error(), "unique") {
return nil, i18n.NewI18nError("300007", lang) // 许可证已存在
}
// 数据库相关错误
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
return license, nil
}
// convertToDetailResponse 转换为详情响应格式
func (s *licenseService) convertToDetailResponse(license *models.License, lang string) *models.LicenseDetailResponse {
response := &models.LicenseDetailResponse{
ID: license.ID,
LicenseKey: license.LicenseKey,
AuthorizationCodeID: license.AuthorizationCodeID,
CustomerID: license.CustomerID,
HardwareFingerprint: license.HardwareFingerprint,
ActivationIP: license.ActivationIP,
Status: license.Status,
IsOnline: license.IsOnline,
LastOnlineIP: license.LastOnlineIP,
CreatedAt: license.CreatedAt.Format(time.RFC3339),
UpdatedAt: license.UpdatedAt.Format(time.RFC3339),
}
// 格式化时间字段
if license.ActivatedAt != nil {
activatedAt := license.ActivatedAt.Format(time.RFC3339)
response.ActivatedAt = &activatedAt
}
if license.LastHeartbeat != nil {
lastHeartbeat := license.LastHeartbeat.Format(time.RFC3339)
response.LastHeartbeat = &lastHeartbeat
}
if license.ConfigUpdatedAt != nil {
configUpdatedAt := license.ConfigUpdatedAt.Format(time.RFC3339)
response.ConfigUpdatedAt = &configUpdatedAt
}
// 设置关联数据
if license.AuthorizationCode != nil {
response.AuthorizationCode = license.AuthorizationCode.Code
}
if license.Customer != nil {
response.CustomerName = license.Customer.CustomerName
}
// 解析JSON字段
if len(license.DeviceInfo) > 0 {
var deviceInfo map[string]interface{}
if err := json.Unmarshal(license.DeviceInfo, &deviceInfo); err == nil {
response.DeviceInfo = deviceInfo
}
}
if len(license.UsageData) > 0 {
var usageData map[string]interface{}
if err := json.Unmarshal(license.UsageData, &usageData); err == nil {
response.UsageData = usageData
}
}
// 填充多语言显示字段
response.StatusDisplay = i18n.GetEnumMessage("license_status", license.Status, lang)
if license.IsOnline {
response.IsOnlineDisplay = i18n.GetEnumMessage("license_online_status", "online", lang)
} else {
response.IsOnlineDisplay = i18n.GetEnumMessage("license_online_status", "offline", lang)
}
return response
}
// RevokeLicense 撤销许可证
func (s *licenseService) RevokeLicense(ctx context.Context, id string, req *models.LicenseRevokeRequest) (*models.License, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 业务逻辑:参数验证
if id == "" {
return nil, i18n.NewI18nError("900001", lang)
}
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 先查询现有许可证
existingLicense, err := s.licenseRepo.GetLicenseByID(ctx, id)
if err != nil {
if errors.Is(err, repository.ErrLicenseNotFound) {
return nil, i18n.NewI18nError("300006", lang) // 许可证不存在
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 检查许可证是否已经被撤销
if existingLicense.Status == "revoked" {
return nil, i18n.NewI18nError("300007", lang) // 许可证已被撤销
}
// 更新许可证状态为撤销
existingLicense.Status = "revoked"
// 如果提供了撤销原因,保存到使用数据中
if req.Reason != "" {
revokeData := map[string]interface{}{
"revoked_at": time.Now().Format(time.RFC3339),
"revoke_reason": req.Reason,
}
// 保留原有的使用数据
if len(existingLicense.UsageData) > 0 {
var existingUsageData map[string]interface{}
if err := json.Unmarshal(existingLicense.UsageData, &existingUsageData); err == nil {
for k, v := range existingUsageData {
revokeData[k] = v
}
}
}
usageDataBytes, err := json.Marshal(revokeData)
if err == nil {
existingLicense.UsageData = models.JSON(usageDataBytes)
}
}
// 委托给Repository层进行数据更新
if err := s.licenseRepo.UpdateLicense(ctx, existingLicense); err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
return existingLicense, nil
}
// GenerateLicenseFile 生成许可证文件
func (s *licenseService) GenerateLicenseFile(ctx context.Context, id string) ([]byte, string, string, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
s.logger.Infof("[GenerateLicenseFile] 开始生成许可证文件,license_id: %s", id)
// 业务逻辑:参数验证
if id == "" {
s.logger.Error("[GenerateLicenseFile] 错误:许可证ID为空")
return nil, "", "", i18n.NewI18nError("900001", lang)
}
// 获取许可证信息
s.logger.Infof("[GenerateLicenseFile] 查询许可证信息,license_id: %s", id)
license, err := s.licenseRepo.GetLicenseByID(ctx, id)
if err != nil {
s.logger.Errorf("[GenerateLicenseFile] 查询许可证失败,license_id: %s, error: %v", id, err)
if errors.Is(err, repository.ErrLicenseNotFound) {
return nil, "", "", i18n.NewI18nError("300006", lang) // 许可证不存在
}
return nil, "", "", i18n.NewI18nError("900004", lang, err.Error())
}
s.logger.Infof("[GenerateLicenseFile] 许可证信息查询成功,license_key: %s, status: %s", license.LicenseKey, license.Status)
// 检查许可证状态
if license.Status == "revoked" {
s.logger.Warnf("[GenerateLicenseFile] 许可证已被撤销,license_id: %s", id)
return nil, "", "", i18n.NewI18nError("300007", lang) // 许可证已被撤销
}
// 构建许可证文件内容
licenseFileData := map[string]interface{}{
"license_key": license.LicenseKey,
"authorization_code_id": license.AuthorizationCodeID,
"hardware_fingerprint": license.HardwareFingerprint,
"status": license.Status,
"activated_at": license.ActivatedAt,
"config_updated_at": license.ConfigUpdatedAt,
"generated_at": time.Now().Format(time.RFC3339),
}
// 包含授权码信息
if license.AuthorizationCode != nil {
licenseFileData["authorization_code"] = license.AuthorizationCode.Code
licenseFileData["start_date"] = license.AuthorizationCode.StartDate
licenseFileData["end_date"] = license.AuthorizationCode.EndDate
licenseFileData["deployment_type"] = license.AuthorizationCode.DeploymentType
licenseFileData["max_activations"] = license.AuthorizationCode.MaxActivations
// 包含功能配置
if featureConfig := parseJSONField(license.AuthorizationCode.FeatureConfig); len(featureConfig) > 0 {
licenseFileData["feature_config"] = featureConfig
}
if usageLimits := parseJSONField(license.AuthorizationCode.UsageLimits); len(usageLimits) > 0 {
licenseFileData["usage_limits"] = usageLimits
}
if customParameters := parseJSONField(license.AuthorizationCode.CustomParameters); len(customParameters) > 0 {
licenseFileData["custom_parameters"] = customParameters
}
}
// 序列化许可证数据
licenseJSON, err := json.Marshal(licenseFileData)
if err != nil {
return nil, "", "", i18n.NewI18nError("300009", lang) // 许可证文件生成失败
}
// 使用RSA数字签名
encryptedData, err := s.signLicenseFile(licenseJSON)
if err != nil {
return nil, "", "", i18n.NewI18nError("300009", lang) // 许可证文件生成失败
}
// 生成文件名
fileName := fmt.Sprintf("license_%s.lic", license.LicenseKey)
return encryptedData, fileName, license.LicenseKey, nil
}
// generateLicenseKey 生成许可证密钥
func (s *licenseService) generateLicenseKey() (string, error) {
// 生成12字节随机数据
bytes := make([]byte, 12)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
// 转换为16进制字符串并格式化
hex := strings.ToUpper(hex.EncodeToString(bytes))
return fmt.Sprintf("LIC-DEVICE-%s", hex), nil
}
// validateAuthorizationCodeFormat 验证授权码格式和校验和
// 格式: LIC-{4位客户代码}-{8或12位随机}-{4位校验码}
// 兼容旧格式(8位随机,36字符集)和新格式(12位随机,62字符集)
func validateAuthorizationCodeFormat(code string) bool {
// 解析格式
parts := strings.Split(code, "-")
if len(parts) != 4 || parts[0] != "LIC" {
return false
}
// 验证各部分长度(兼容旧格式8位和新格式12位)
customerCodeLen := len(parts[1])
randomLen := len(parts[2])
checksumLen := len(parts[3])
// 客户代码必须为4位,校验和必须为4位
if customerCodeLen != 4 || checksumLen != 4 {
return false
}
// 随机部分:兼容8位(旧格式)和12位(新格式)
if randomLen != 8 && randomLen != 12 {
return false
}
// 根据随机部分长度选择字符集验证校验和
// 8位:使用36字符集(旧格式)
// 12位:使用62字符集(新格式)
expectedChecksum := generateChecksumForCode(parts[1]+parts[2], randomLen == 12)
return expectedChecksum == parts[3]
}
// generateChecksumForCode 生成校验码(用于验证)
// 基于输入字符串的ASCII值计算
// useExtendedCharset: true使用62字符集(新格式),false使用36字符集(旧格式)
func generateChecksumForCode(input string, useExtendedCharset bool) string {
sum := 0
for _, char := range input {
sum += int(char)
}
var chars string
if useExtendedCharset {
// 使用62字符集(A-Z, a-z, 0-9)用于新格式
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
} else {
// 使用36字符集(A-Z, 0-9)用于旧格式
chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
}
checksum := ""
for i := 0; i < 4; i++ {
checksum += string(chars[sum%len(chars)])
sum = sum / len(chars)
}
return checksum
}
// ActivateLicense 激活许可证
func (s *licenseService) ActivateLicense(ctx context.Context, req *models.ActivateRequest, clientIP string) (*models.ActivateResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 参数验证
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 验证授权码格式(在查询数据库前先验证格式,避免无效查询)
if !validateAuthorizationCodeFormat(req.AuthorizationCode) {
return nil, i18n.NewI18nError("300001", lang) // 授权码格式无效
}
// 获取授权码信息
authCode, err := s.licenseRepo.GetAuthorizationCodeByCode(ctx, req.AuthorizationCode)
if err != nil {
if errors.Is(err, repository.ErrAuthorizationCodeNotFound) {
return nil, i18n.NewI18nError("300001", lang) // 授权码不存在
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 检查授权码状态和有效期
now := time.Now()
if authCode.IsLocked {
return nil, i18n.NewI18nError("300003", lang) // 授权码已被锁定
}
if now.Before(authCode.StartDate) || now.After(authCode.EndDate) {
return nil, i18n.NewI18nError("300001", lang) // 授权码已过期
}
// 使用事务确保并发安全
var response *models.ActivateResponse
err = s.db.Transaction(func(tx *gorm.DB) error {
// 检查当前激活数量
count, err := s.licenseRepo.GetActiveLicenseCount(ctx, authCode.ID)
if err != nil {
return err
}
// 检查是否已存在相同硬件指纹的许可证
var existingLicense models.License
err = tx.Where("authorization_code_id = ? AND hardware_fingerprint = ?",
authCode.ID, req.HardwareFingerprint).First(&existingLicense).Error
if err == nil {
// 已存在,直接激活
existingLicense.Status = "active"
existingLicense.ActivationIP = &clientIP
now := time.Now()
existingLicense.ActivatedAt = &now
existingLicense.LastHeartbeat = &now
existingLicense.LastOnlineIP = &clientIP
if err := tx.Save(&existingLicense).Error; err != nil {
return err
}
// 生成许可证文件
licenseFile, err := s.generateLicenseFileContent(&existingLicense, authCode)
if err != nil {
return err
}
response = &models.ActivateResponse{
LicenseKey: existingLicense.LicenseKey,
LicenseFile: licenseFile,
HeartbeatInterval: 300, // 5分钟
}
return nil
} else if err != gorm.ErrRecordNotFound {
return err
}
// 检查激活数量限制
if count >= int64(authCode.MaxActivations) {
return repository.ErrLicenseNotFound // 使用已有错误,表示激活数量已达上限
}
// 生成新的许可证
licenseKey, err := s.generateLicenseKey()
if err != nil {
return err
}
license := &models.License{
LicenseKey: licenseKey,
AuthorizationCodeID: authCode.ID,
CustomerID: authCode.CustomerID,
HardwareFingerprint: req.HardwareFingerprint,
ActivationIP: &clientIP,
Status: "active",
ActivatedAt: &now,
LastHeartbeat: &now,
LastOnlineIP: &clientIP,
}
// 设置设备信息
if req.DeviceInfo != nil {
deviceInfoBytes, err := json.Marshal(req.DeviceInfo)
if err == nil {
license.DeviceInfo = models.JSON(deviceInfoBytes)
}
}
if err := tx.Create(license).Error; err != nil {
return err
}
// 生成许可证文件
licenseFile, err := s.generateLicenseFileContent(license, authCode)
if err != nil {
return err
}
response = &models.ActivateResponse{
LicenseKey: license.LicenseKey,
LicenseFile: licenseFile,
HeartbeatInterval: 300,
}
return nil
})
if err != nil {
if errors.Is(err, repository.ErrLicenseNotFound) {
return nil, i18n.NewI18nError("300004", lang) // 激活数量已达上限
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
return response, nil
}
// Heartbeat 心跳检测
func (s *licenseService) Heartbeat(ctx context.Context, req *models.HeartbeatRequest, clientIP string) (*models.HeartbeatResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 参数验证
if req == nil {
return nil, i18n.NewI18nError("900001", lang)
}
// 获取许可证信息
license, err := s.licenseRepo.GetLicenseByKey(ctx, req.LicenseKey)
if err != nil {
if errors.Is(err, repository.ErrLicenseNotFound) {
return nil, i18n.NewI18nError("300006", lang) // 许可证不存在
}
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
// 检查许可证状态
if license.Status == "revoked" {
return nil, i18n.NewI18nError("300007", lang) // 许可证已被撤销
}
// 更新心跳时间和使用数据
now := time.Now()
license.LastHeartbeat = &now
license.LastOnlineIP = &clientIP
// 更新使用数据
if req.UsageData != nil {
usageDataBytes, err := json.Marshal(req.UsageData)
if err == nil {
license.UsageData = models.JSON(usageDataBytes)
}
}
// 检查配置是否有更新
configUpdated := false
var licenseFile *string
if req.ConfigUpdatedAt != nil && license.AuthorizationCode != nil {
clientConfigTime, err := time.Parse(time.RFC3339, *req.ConfigUpdatedAt)
if err == nil && license.AuthorizationCode.UpdatedAt.After(clientConfigTime) {
configUpdated = true
// 生成新的许可证文件
fileContent, err := s.generateLicenseFileContent(license, license.AuthorizationCode)
if err == nil {
licenseFile = &fileContent
license.ConfigUpdatedAt = &now
}
}
}
// 保存更新
if err := s.licenseRepo.UpdateLicense(ctx, license); err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
response := &models.HeartbeatResponse{
Status: license.Status,
ConfigUpdated: configUpdated,
LicenseFile: licenseFile,
HeartbeatInterval: 300,
}
return response, nil
}
// generateLicenseFileContent 生成许可证文件内容
func (s *licenseService) generateLicenseFileContent(license *models.License, authCode *models.AuthorizationCode) (string, error) {
// 构建许可证文件内容
licenseFileData := map[string]interface{}{
"license_key": license.LicenseKey,
"authorization_code_id": license.AuthorizationCodeID,
"hardware_fingerprint": license.HardwareFingerprint,
"status": license.Status,
"activated_at": license.ActivatedAt,
"config_updated_at": license.ConfigUpdatedAt,
"generated_at": time.Now().Format(time.RFC3339),
}
if authCode != nil {
licenseFileData["authorization_code"] = authCode.Code
licenseFileData["start_date"] = authCode.StartDate
licenseFileData["end_date"] = authCode.EndDate
licenseFileData["deployment_type"] = authCode.DeploymentType
licenseFileData["max_activations"] = authCode.MaxActivations
// 包含功能配置等
if featureConfig := parseJSONField(authCode.FeatureConfig); len(featureConfig) > 0 {
licenseFileData["feature_config"] = featureConfig
}
if usageLimits := parseJSONField(authCode.UsageLimits); len(usageLimits) > 0 {
licenseFileData["usage_limits"] = usageLimits
}
if customParameters := parseJSONField(authCode.CustomParameters); len(customParameters) > 0 {
licenseFileData["custom_parameters"] = customParameters
}
}
// 序列化
licenseJSON, err := json.Marshal(licenseFileData)
if err != nil {
return "", err
}
// 使用RSA数字签名
encryptedData, err := s.signLicenseFile(licenseJSON)
if err != nil {
return "", err
}
return string(encryptedData), nil
}
// loadRSAPrivateKey 加载RSA私钥(懒加载)
func (s *licenseService) loadRSAPrivateKey() (*utils.RSAPrivateKey, error) {
if s.rsaPrivateKey != nil {
return s.rsaPrivateKey, nil
}
cfg := config.GetConfig()
if cfg == nil {
return nil, fmt.Errorf("configuration not initialized")
}
privateKeyPath := cfg.License.RSA.PrivateKeyPath
if privateKeyPath == "" {
return nil, fmt.Errorf("RSA private key path not configured")
}
key, err := utils.LoadRSAPrivateKeyFromFile(privateKeyPath)
if err != nil {
return nil, fmt.Errorf("加载RSA私钥失败: %w", err)
}
s.rsaPrivateKey = key
return key, nil
}
// signLicenseFile 使用RSA数字签名许可证文件
func (s *licenseService) signLicenseFile(data []byte) ([]byte, error) {
// 加载RSA私钥
privateKey, err := s.loadRSAPrivateKey()
if err != nil {
return nil, fmt.Errorf("加载RSA私钥失败: %w", err)
}
// 对数据进行数字签名
signature, err := privateKey.SignData(data)
if err != nil {
return nil, fmt.Errorf("签名失败: %w", err)
}
// 构建签名后的许可证文件结构
licenseWithSignature := map[string]interface{}{
"data": string(data), // 原始数据(JSON字符串)
"signature": signature, // 数字签名
"algorithm": "RSA-PSS-SHA256",
}
// 序列化为JSON
licenseJSON, err := json.Marshal(licenseWithSignature)
if err != nil {
return nil, fmt.Errorf("序列化失败: %w", err)
}
// 使用base64编码
encoded := base64.StdEncoding.EncodeToString(licenseJSON)
return []byte(encoded), nil
}
// parseJSONField 将数据库中的 JSON 字段解析为 map,兼容被字符串包裹的情况
func parseJSONField(raw models.JSON) map[string]interface{} {
if len(raw) == 0 {
return nil
}
var result map[string]interface{}
if err := json.Unmarshal(raw, &result); err == nil {
return result
}
// 兼容字段以字符串形式保存 JSON 的情况,例如 "\"{\\\"key\\\":123}\""
var rawString string
if err := json.Unmarshal(raw, &rawString); err != nil {
return nil
}
if err := json.Unmarshal([]byte(rawString), &result); err != nil {
return nil
}
return result
}
// GetStatsOverview 获取授权概览统计
func (s *licenseService) GetStatsOverview(ctx context.Context) (*models.StatsOverviewResponse, error) {
lang := pkgcontext.GetLanguageFromContext(ctx)
// 获取配置信息
cfg := config.GetConfig()
offlineTimeout := time.Duration(cfg.License.HeartbeatTimeout) * time.Second
// 计算时间点
now := time.Now()
onlineThreshold := now.Add(-offlineTimeout)
lastMonth := now.AddDate(0, -1, 0)
var stats models.StatsOverviewResponse
// 执行统计查询
err := s.db.Transaction(func(tx *gorm.DB) error {
// 1. 总授权码数量
if err := tx.Model(&models.AuthorizationCode{}).Count(&stats.TotalAuthCodes).Error; err != nil {
return err
}
// 2. 活跃许可证数量(状态为active)
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 {
return err
}
// 4. 异常告警数量(许可证超时未心跳)
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. 计算同比上月的授权码和许可证数量(用于计算增长率)
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
}
if lastMonthActiveLicenses > 0 {
stats.GrowthRate.Licenses = float64(stats.ActiveLicenses-lastMonthActiveLicenses) / float64(lastMonthActiveLicenses) * 100
} else {
stats.GrowthRate.Licenses = 0
}
return nil
})
if err != nil {
return nil, i18n.NewI18nError("900004", lang, err.Error())
}
return &stats, nil
}
@@ -0,0 +1,69 @@
-- 客户表结构
-- 基于客户属性设计与Web显示方案创建
-- MySQL版本
-- 创建客户表
CREATE TABLE customers (
-- 基础标识
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
customer_code VARCHAR(20) NOT NULL UNIQUE,
customer_name VARCHAR(200) NOT NULL,
customer_type VARCHAR(20) NOT NULL DEFAULT 'enterprise' COMMENT '客户类型: individual, enterprise, government, education',
-- 联系信息
contact_person VARCHAR(100) NOT NULL,
contact_title VARCHAR(100),
email VARCHAR(255),
phone VARCHAR(20),
-- 地址信息
address TEXT,
-- 商业属性
company_size VARCHAR(20) COMMENT '企业规模: small, medium, large, enterprise',
-- 客户分级
customer_level VARCHAR(20) NOT NULL DEFAULT 'basic' COMMENT '客户等级: basic, active, vip, strategic',
-- 状态管理
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active, disabled',
description TEXT,
-- 时间字段 (使用DATETIME(3)匹配Go的time.Time和GORM的精度要求)
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
created_by VARCHAR(36) NOT NULL,
updated_by VARCHAR(36),
deleted_at DATETIME(3) NULL -- 软删除字段,匹配gorm.DeletedAt
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户信息表';
-- MySQL的列注释已在CREATE TABLE语句中添加
-- 创建索引
CREATE INDEX idx_customers_customer_name ON customers(customer_name);
CREATE INDEX idx_customers_customer_type ON customers(customer_type);
CREATE INDEX idx_customers_customer_level ON customers(customer_level);
CREATE INDEX idx_customers_status ON customers(status);
CREATE INDEX idx_customers_created_at ON customers(created_at);
CREATE INDEX idx_customers_created_by ON customers(created_by);
CREATE INDEX idx_customers_deleted_at ON customers(deleted_at); -- 软删除索引,提高查询性能
-- 注意:时间戳由Go应用程序管理,不依赖数据库默认值
-- 这样可以确保跨数据库兼容性并符合失败快速原则
-- 创建客户编码序列表
CREATE TABLE customer_code_sequence (
year INT PRIMARY KEY,
sequence_number INT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户编码序列表';
-- 插入当前年份的序列记录
INSERT INTO customer_code_sequence (year, sequence_number)
VALUES (YEAR(NOW()), 0)
ON DUPLICATE KEY UPDATE sequence_number = sequence_number;
-- 注意:MySQL版本简化了客户编码生成逻辑
-- 客户编码将在应用层生成,格式:CUS-YYYY-NNNN
-- 可以通过以下SQL获取下一个序列号:
-- UPDATE customer_code_sequence SET sequence_number = sequence_number + 1 WHERE year = YEAR(NOW());
-- SELECT sequence_number FROM customer_code_sequence WHERE year = YEAR(NOW());
@@ -0,0 +1,98 @@
-- 示例数据插入
-- 基于客户属性设计文档的示例数据
-- MySQL版本
-- 先更新客户编码序列表
INSERT INTO customer_code_sequence (year, sequence_number)
VALUES (YEAR(NOW()), 5)
ON DUPLICATE KEY UPDATE sequence_number = 5;
-- 插入示例客户数据
-- 注意:created_at和updated_at将由应用程序自动设置,这里不需要指定
INSERT INTO customers (
customer_code, customer_name, customer_type,
contact_person, contact_title, email, phone,
address, company_size,
customer_level, status, description,
created_at, updated_at, created_by
) VALUES
-- 示例1:VIP企业客户
(
CONCAT('CUS-', YEAR(NOW()), '-0001'),
'北京科技有限公司',
'enterprise',
'张三',
'技术总监',
'zhangsan@example.com',
'13800138000',
'北京市朝阳区科技园区100号',
'medium',
'vip',
'active',
'重要客户,IoT平台采购方,需要优先技术支持',
NOW(3), NOW(3), UUID()
),
-- 示例2:普通个人客户
(
CONCAT('CUS-', YEAR(NOW()), '-0002'),
'李四',
'individual',
'李四',
NULL,
'lisi@personal.com',
'13900139000',
'上海市浦东新区张江高科技园区200号',
NULL,
'normal',
'active',
'个人开发者,主要用于学习和小型项目',
NOW(3), NOW(3), UUID()
),
-- 示例3:政府客户
(
CONCAT('CUS-', YEAR(NOW()), '-0003'),
'某市政府信息化中心',
'government',
'王五',
'信息化处处长',
'wangwu@gov.cn',
'010-12345678',
'某市政府大楼信息化中心',
'large',
'enterprise',
'active',
'政府客户,智慧城市项目,需要专项技术支持',
NOW(3), NOW(3), UUID()
),
-- 示例4:教育客户
(
CONCAT('CUS-', YEAR(NOW()), '-0004'),
'清华大学计算机学院',
'education',
'赵六',
'实验室主任',
'zhaoliu@tsinghua.edu.cn',
'010-62785678',
'北京市海淀区清华大学计算机学院',
'large',
'strategic',
'active',
'重点高校合作伙伴,科研项目合作,享受教育优惠政策',
NOW(3), NOW(3), UUID()
),
-- 示例5:禁用状态的客户
(
CONCAT('CUS-', YEAR(NOW()), '-0005'),
'测试公司',
'enterprise',
'测试用户',
'测试经理',
'test@disabled.com',
'13700137000',
'测试地址',
'small',
'normal',
'disabled',
'测试客户,已禁用,用于演示禁用状态',
NOW(3), NOW(3), UUID()
);
@@ -0,0 +1,64 @@
-- 授权码表结构
-- 基于授权模块属性设计创建
-- MySQL版本
-- 创建授权码表
CREATE TABLE authorization_codes (
-- 基础信息属性
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
code VARCHAR(100) NOT NULL UNIQUE COMMENT '唯一授权码,软件身份凭证',
customer_id VARCHAR(36) NOT NULL COMMENT '关联客户ID',
created_by VARCHAR(36) NOT NULL COMMENT '创建人ID',
software_id VARCHAR(50) COMMENT '目标软件产品ID',
description TEXT COMMENT '授权码描述',
-- 授权核心属性
start_date DATETIME(3) NOT NULL COMMENT '授权开始时间',
end_date DATETIME(3) NOT NULL COMMENT '授权结束时间',
deployment_type VARCHAR(20) NOT NULL DEFAULT 'standalone' COMMENT '部署类型: standalone单机版/cloud云端版/hybrid混合版',
encryption_type VARCHAR(20) DEFAULT 'standard' COMMENT '加密类型: standard标准加密/advanced高级加密',
software_version VARCHAR(50) COMMENT '支持的软件版本范围',
-- 激活控制属性
max_activations INT NOT NULL DEFAULT 1 COMMENT '最大许可证数量',
-- 状态管理属性
is_locked BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否被锁定',
lock_reason TEXT COMMENT '锁定原因,自定义文本输入',
locked_at DATETIME(3) NULL COMMENT '锁定时间',
locked_by VARCHAR(36) COMMENT '锁定操作人',
-- 通用功能控制属性
feature_config JSON COMMENT '功能配置,软件产品基础功能',
usage_limits JSON COMMENT '使用量限制,如用户数、API调用次数等',
custom_parameters JSON COMMENT '自定义参数,软件特定功能或用户定制功能',
-- 时间字段 (使用DATETIME(3)匹配Go的time.Time和GORM的精度要求)
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
-- 外键约束
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE RESTRICT
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='授权码表 - 业务配置容器';
-- 创建索引
CREATE INDEX idx_authorization_codes_code ON authorization_codes(code);
CREATE INDEX idx_authorization_codes_customer_id ON authorization_codes(customer_id);
CREATE INDEX idx_authorization_codes_software_id ON authorization_codes(software_id);
CREATE INDEX idx_authorization_codes_deployment_type ON authorization_codes(deployment_type);
CREATE INDEX idx_authorization_codes_start_date ON authorization_codes(start_date);
CREATE INDEX idx_authorization_codes_end_date ON authorization_codes(end_date);
CREATE INDEX idx_authorization_codes_created_by ON authorization_codes(created_by);
CREATE INDEX idx_authorization_codes_created_at ON authorization_codes(created_at);
CREATE INDEX idx_authorization_codes_is_locked ON authorization_codes(is_locked);
CREATE INDEX idx_authorization_codes_locked_at ON authorization_codes(locked_at);
-- 复合索引用于常见查询
CREATE INDEX idx_authorization_codes_customer_status ON authorization_codes(customer_id, start_date, end_date);
CREATE INDEX idx_authorization_codes_software_status ON authorization_codes(software_id, deployment_type);
-- 注意:时间戳由Go应用程序管理,不依赖数据库默认值
-- 状态字段(status)是虚字段,通过应用逻辑计算得出:
-- normal正常: 当前时间在start_date和end_date之间且is_locked=false
-- locked已锁定: is_locked=true
-- expired已过期: 当前时间超过end_date
@@ -0,0 +1,67 @@
-- 许可证表结构
-- 基于授权模块属性设计创建
-- MySQL版本
-- 创建许可证表
CREATE TABLE licenses (
-- 基础信息属性
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
license_key VARCHAR(200) NOT NULL UNIQUE COMMENT '许可证密钥,设备特定',
authorization_code_id VARCHAR(36) NOT NULL COMMENT '关联的授权码ID',
customer_id VARCHAR(36) NOT NULL COMMENT '客户ID,冗余字段便于查询',
-- 硬件绑定属性
hardware_fingerprint VARCHAR(200) NOT NULL COMMENT '绑定的硬件指纹',
device_info JSON COMMENT '设备信息,包含CPU、内存等',
activation_ip VARCHAR(45) COMMENT '激活时的IP地址',
-- 激活状态属性
status VARCHAR(20) NOT NULL DEFAULT 'inactive' COMMENT '许可证状态: active激活/inactive未激活/revoked已撤销',
activated_at DATETIME(3) NULL COMMENT '激活时间',
last_heartbeat DATETIME(3) NULL COMMENT '最后心跳时间',
last_online_ip VARCHAR(45) COMMENT '最后在线IP',
-- 配置同步属性
config_updated_at DATETIME(3) NULL COMMENT '配置更新时间',
-- 使用统计属性
usage_data JSON COMMENT '使用数据,软件上报的统计信息',
-- 时间字段 (使用DATETIME(3)匹配Go的time.Time和GORM的精度要求)
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
-- 外键约束
FOREIGN KEY (authorization_code_id) REFERENCES authorization_codes(id) ON DELETE CASCADE,
FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE RESTRICT,
-- 唯一约束:同一授权码下的硬件指纹应该唯一
UNIQUE KEY uk_licenses_auth_hardware (authorization_code_id, hardware_fingerprint)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='许可证表 - 硬件激活凭证';
-- 创建索引
CREATE INDEX idx_licenses_license_key ON licenses(license_key);
CREATE INDEX idx_licenses_authorization_code_id ON licenses(authorization_code_id);
CREATE INDEX idx_licenses_customer_id ON licenses(customer_id);
CREATE INDEX idx_licenses_hardware_fingerprint ON licenses(hardware_fingerprint);
CREATE INDEX idx_licenses_status ON licenses(status);
CREATE INDEX idx_licenses_activated_at ON licenses(activated_at);
CREATE INDEX idx_licenses_last_heartbeat ON licenses(last_heartbeat);
CREATE INDEX idx_licenses_config_updated_at ON licenses(config_updated_at);
CREATE INDEX idx_licenses_created_at ON licenses(created_at);
-- 复合索引用于常见查询
CREATE INDEX idx_licenses_customer_status ON licenses(customer_id, status);
CREATE INDEX idx_licenses_auth_status ON licenses(authorization_code_id, status);
CREATE INDEX idx_licenses_status_activated ON licenses(status, activated_at);
CREATE INDEX idx_licenses_heartbeat_status ON licenses(last_heartbeat, status);
-- 注意:时间戳由Go应用程序管理,不依赖数据库默认值
-- 许可证状态说明:
-- active激活: 设备已激活并可正常使用
-- inactive未激活: 许可证已生成但设备尚未激活
-- revoked已撤销: 由于硬件更换、设备丢失或违规使用而被撤销
-- 在线状态(is_online)是虚字段,通过last_heartbeat时间计算:
-- 在线: last_heartbeat在5分钟内
-- 离线: last_heartbeat超过5分钟但在24小时内
-- 异常: last_heartbeat超过24小时
@@ -0,0 +1,44 @@
-- 授权变更历史表结构
-- 基于授权模块属性设计创建
-- MySQL版本
-- 创建授权变更历史表
CREATE TABLE authorization_changes (
-- 基础属性
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
authorization_code_id VARCHAR(36) NOT NULL COMMENT '授权码ID',
change_type VARCHAR(30) NOT NULL COMMENT '变更类型: renewal续费/upgrade升级/limit_change限制调整/feature_toggle功能开关/lock锁定/unlock解锁/other其他',
old_config JSON COMMENT '变更前配置',
new_config JSON COMMENT '变更后配置',
operator_id VARCHAR(36) NOT NULL COMMENT '操作人ID',
reason TEXT COMMENT '变更原因',
-- 时间字段 (使用DATETIME(3)匹配Go的time.Time和GORM的精度要求)
created_at DATETIME(3) NOT NULL COMMENT '记录创建时间',
-- 外键约束 - 修改为级联删除
FOREIGN KEY (authorization_code_id) REFERENCES authorization_codes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='授权变更历史表 - 记录授权码的所有变更操作';
-- 创建索引
CREATE INDEX idx_authorization_changes_authorization_code_id ON authorization_changes(authorization_code_id);
CREATE INDEX idx_authorization_changes_change_type ON authorization_changes(change_type);
CREATE INDEX idx_authorization_changes_operator_id ON authorization_changes(operator_id);
CREATE INDEX idx_authorization_changes_created_at ON authorization_changes(created_at);
-- 复合索引用于常见查询
CREATE INDEX idx_authorization_changes_auth_type ON authorization_changes(authorization_code_id, change_type);
CREATE INDEX idx_authorization_changes_auth_time ON authorization_changes(authorization_code_id, created_at);
CREATE INDEX idx_authorization_changes_operator_time ON authorization_changes(operator_id, created_at);
-- 注意:
-- 1. 该表只有created_at字段,没有updated_at和deleted_at,因为历史记录不应被修改或删除
-- 2. 变更类型包括:
-- - renewal: 续费操作
-- - upgrade: 升级操作
-- - limit_change: 限制调整(如修改max_activations)
-- - feature_toggle: 功能开关(修改feature_config)
-- - lock: 锁定操作
-- - unlock: 解锁操作
-- - other: 其他类型的变更
-- 3. old_config和new_config存储变更前后的完整配置快照,便于审计和回滚
@@ -0,0 +1,48 @@
-- 用户表结构
-- 基于用户管理模块设计创建
-- MySQL版本
-- 创建用户表
CREATE TABLE users (
-- 基础信息属性
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名,唯一',
email VARCHAR(255) NOT NULL UNIQUE COMMENT '邮箱地址,唯一',
password_hash VARCHAR(255) NOT NULL COMMENT '密码哈希值',
full_name VARCHAR(100) NOT NULL COMMENT '真实姓名',
phone VARCHAR(20) COMMENT '手机号码',
-- 角色权限属性
role VARCHAR(20) NOT NULL DEFAULT 'viewer' COMMENT '用户角色: admin管理员/operator操作员/viewer查看者',
status VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '账号状态: active活跃/disabled禁用/locked锁定',
-- 登录相关属性
last_login_at DATETIME(3) NULL COMMENT '最后登录时间',
last_login_ip VARCHAR(45) COMMENT '最后登录IP',
login_attempts INT NOT NULL DEFAULT 0 COMMENT '登录失败次数',
locked_until DATETIME(3) NULL COMMENT '账号锁定到期时间',
-- 时间字段 (使用DATETIME(3)匹配Go的time.Time和GORM的精度要求)
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表 - 系统用户管理';
-- 创建索引
CREATE INDEX idx_users_username ON users(username);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_role ON users(role);
CREATE INDEX idx_users_status ON users(status);
CREATE INDEX idx_users_last_login_at ON users(last_login_at);
CREATE INDEX idx_users_created_at ON users(created_at);
CREATE INDEX idx_users_locked_until ON users(locked_until);
-- 复合索引用于常见查询
CREATE INDEX idx_users_role_status ON users(role, status);
CREATE INDEX idx_users_status_locked ON users(status, locked_until);
-- 注意:
-- 1. 时间戳由Go应用程序管理,不依赖数据库默认值
-- 2. password_hash使用bcrypt算法,成本因子建议设置为12
-- 3. 账号锁定机制:连续5次登录失败锁定30分钟
-- 4. 角色权限通过中间件在应用层控制
-- 5. 默认管理员账号需要在应用初始化时创建
+79
View File
@@ -0,0 +1,79 @@
# SQL 数据库迁移文件
本目录包含许可证管理系统的数据库结构定义和数据迁移文件。
## 目录结构
```
sql/
├── migrations/ # 数据库迁移文件
│ ├── 001_create_customers_table.sql # 客户表结构
│ └── 002_insert_sample_data.sql # 示例数据
└── README.md # 本文件
```
## 数据库支持
- **MySQL** (推荐版本 8.0+)
- 兼容 MariaDB (版本 10.3+)
## 迁移文件说明
### 001_create_customers_table.sql
创建客户相关的数据库结构,包括:
- **客户主表** (`customers`):存储客户的所有基本信息,使用VARCHAR类型替代枚举
- **客户编码序列表** (`customer_code_sequence`):用于自动生成客户编码
- **自动时间戳**:MySQL原生支持自动更新时间戳功能
- **索引优化**:为常用查询字段创建索引
### 002_insert_sample_data.sql
插入示例数据,包括:
- VIP企业客户
- 普通个人客户
- 政府客户
- 教育客户
- 禁用状态客户
## 执行顺序
按文件名的数字前缀顺序执行:
```bash
# MySQL
mysql -u username -p database_name < migrations/001_create_customers_table.sql
mysql -u username -p database_name < migrations/002_insert_sample_data.sql
```
## 客户编码规则
- 格式:`CUS-YYYY-NNNN`
- 示例:`CUS-2025-0001`
- 自动递增,按年度重置
## 主要功能特性
1. **自动时间戳**:创建和更新时间自动管理(使用MySQL原生功能)
2. **UUID主键**:使用UUID作为主键,保证全局唯一性
3. **字符串约束**:使用VARCHAR类型替代枚举,通过注释说明取值范围
4. **索引优化**:为常用查询字段创建索引
5. **简化设计**:移除复杂的触发器和函数,提高可维护性
6. **注释完整**:表和字段都有详细的中文注释
## 数据类型映射
| 设计文档字段 | MySQL类型 | 说明 |
|-------------|-----------|------|
| id | VARCHAR(36) | 主键,UUID格式 |
| customer_code | VARCHAR(20) | 唯一客户编码 |
| customer_type | VARCHAR(20) | 字符串:individual/enterprise/government/education |
| email | VARCHAR(255) | 唯一邮箱地址 |
| created_at | TIMESTAMP | 时间戳,自动生成 |
| status | VARCHAR(20) | 字符串:active/disabled |
## 注意事项
1. 执行前请确保MySQL版本支持 `UUID()` 函数(MySQL 8.0+)
2. 客户编码生成逻辑简化,建议在应用层实现自动生成
3. 字段约束通过应用层验证,确保数据一致性
4. 示例数据仅用于开发和测试,生产环境请谨慎使用
+162
View File
@@ -0,0 +1,162 @@
package context
import (
"context"
"github.com/gin-gonic/gin"
)
// 定义Context键的类型,避免键冲突
type contextKey string
const (
// LanguageKey 存储语言信息的上下文键
LanguageKey contextKey = "language"
// RequestIDKey 存储请求ID的上下文键
RequestIDKey contextKey = "request_id"
// UserIDKey 存储用户ID的上下文键
UserIDKey contextKey = "user_id"
// TraceIDKey 存储链路追踪ID的上下文键
TraceIDKey contextKey = "trace_id"
)
// DefaultLanguage 默认语言
const DefaultLanguage = "zh-CN"
// WithLanguage 将语言信息添加到Context中
func WithLanguage(ctx context.Context, lang string) context.Context {
if lang == "" {
lang = DefaultLanguage
}
return context.WithValue(ctx, LanguageKey, lang)
}
// GetLanguageFromContext 从Context中获取语言信息
func GetLanguageFromContext(ctx context.Context) string {
if lang, ok := ctx.Value(LanguageKey).(string); ok && lang != "" {
return lang
}
return DefaultLanguage
}
// WithRequestID 将请求ID添加到Context中
func WithRequestID(ctx context.Context, requestID string) context.Context {
return context.WithValue(ctx, RequestIDKey, requestID)
}
// GetRequestIDFromContext 从Context中获取请求ID
func GetRequestIDFromContext(ctx context.Context) string {
if requestID, ok := ctx.Value(RequestIDKey).(string); ok {
return requestID
}
return ""
}
// WithUserID 将用户ID添加到Context中
func WithUserID(ctx context.Context, userID string) context.Context {
return context.WithValue(ctx, UserIDKey, userID)
}
// GetUserIDFromContext 从context中获取当前用户ID
func GetUserIDFromContext(ctx context.Context) string {
// 尝试从gin.Context获取
if ginCtx, ok := ctx.(*gin.Context); ok {
if userID, exists := ginCtx.Get("user_id"); exists {
if id, ok := userID.(string); ok {
return id
}
}
}
// 尝试从标准context.Context获取
if userID := ctx.Value("user_id"); userID != nil {
if id, ok := userID.(string); ok {
return id
}
}
return ""
}
// GetUsernameFromContext 从context中获取当前用户名
func GetUsernameFromContext(ctx context.Context) string {
if ginCtx, ok := ctx.(*gin.Context); ok {
if username, exists := ginCtx.Get("username"); exists {
if name, ok := username.(string); ok {
return name
}
}
}
if username := ctx.Value("username"); username != nil {
if name, ok := username.(string); ok {
return name
}
}
return ""
}
// GetUserRoleFromContext 从context中获取当前用户角色
func GetUserRoleFromContext(ctx context.Context) string {
if ginCtx, ok := ctx.(*gin.Context); ok {
if role, exists := ginCtx.Get("role"); exists {
if r, ok := role.(string); ok {
return r
}
}
}
if role := ctx.Value("role"); role != nil {
if r, ok := role.(string); ok {
return r
}
}
return ""
}
// WithTraceID 将链路追踪ID添加到Context中
func WithTraceID(ctx context.Context, traceID string) context.Context {
return context.WithValue(ctx, TraceIDKey, traceID)
}
// GetTraceIDFromContext 从Context中获取链路追踪ID
func GetTraceIDFromContext(ctx context.Context) string {
if traceID, ok := ctx.Value(TraceIDKey).(string); ok {
return traceID
}
return ""
}
// ContextInfo 上下文信息结构体
type ContextInfo struct {
Language string
RequestID string
UserID string
TraceID string
}
// GetContextInfo 获取Context中的所有信息
func GetContextInfo(ctx context.Context) *ContextInfo {
return &ContextInfo{
Language: GetLanguageFromContext(ctx),
RequestID: GetRequestIDFromContext(ctx),
UserID: GetUserIDFromContext(ctx),
TraceID: GetTraceIDFromContext(ctx),
}
}
// WithContextInfo 将ContextInfo添加到Context中
func WithContextInfo(ctx context.Context, info *ContextInfo) context.Context {
if info == nil {
return ctx
}
ctx = WithLanguage(ctx, info.Language)
ctx = WithRequestID(ctx, info.RequestID)
ctx = WithUserID(ctx, info.UserID)
ctx = WithTraceID(ctx, info.TraceID)
return ctx
}
+222
View File
@@ -0,0 +1,222 @@
package i18n
import (
"fmt"
)
// ErrorI18n 错误信息国际化封装
type ErrorI18n struct {
manager I18nManager
}
// NewErrorI18n 创建新的错误国际化实例
func NewErrorI18n(manager I18nManager) *ErrorI18n {
return &ErrorI18n{
manager: manager,
}
}
// NewI18nErrorResponse 创建多语言错误响应
// 扩展现有错误响应函数,支持多语言
// 返回:HTTP状态码、错误码、本地化错误消息
func NewI18nErrorResponse(code, lang string, customMessage ...string) (int, string, string) {
// 获取本地化错误信息
message := GetI18nErrorMessage(code, lang)
// 支持自定义消息覆盖
if len(customMessage) > 0 && customMessage[0] != "" {
message = customMessage[0]
}
// 获取对应的HTTP状态码
httpStatus := getHTTPStatusByCode(code)
return httpStatus, code, message
}
// GetI18nErrorMessage 获取多语言错误信息
func GetI18nErrorMessage(code, lang string) string {
if globalManager == nil {
// 如果多语言系统未初始化,降级到默认错误处理
return getDefaultErrorMessage(code)
}
return globalManager.GetErrorMessage(code, lang)
}
// GetI18nErrorMessageWithManager 使用指定管理器获取多语言错误信息
func GetI18nErrorMessageWithManager(manager I18nManager, code, lang string) string {
if manager == nil {
return getDefaultErrorMessage(code)
}
return manager.GetErrorMessage(code, lang)
}
// getDefaultErrorMessage 获取默认错误信息(作为降级方案)
func getDefaultErrorMessage(code string) string {
// 这里可以集成原有的 ErrorMessages 映射作为降级方案
defaultMessages := map[string]string{
"000000": "Success",
"100001": "Authentication expired",
"100002": "Invalid authentication",
"100003": "Invalid username or password",
"100004": "Authentication required",
"100005": "Insufficient permissions",
"200001": "Customer not found",
"200002": "Customer already exists",
"200003": "Customer name is required",
"200004": "Invalid customer information format",
"200005": "Failed to query customer list",
"300001": "Invalid license key",
"300002": "License expired",
"300003": "License key is required",
"300004": "License key already used",
"900001": "Invalid request parameters",
"900002": "Resource not found",
"900003": "Resource conflict",
"900004": "Internal server error",
}
if msg, ok := defaultMessages[code]; ok {
return msg
}
return "Unknown error"
}
// getHTTPStatusByCode 根据业务错误码获取对应的 HTTP 状态码
// 保持与原有错误处理逻辑一致
func getHTTPStatusByCode(code string) int {
const (
StatusOK = 200
StatusBadRequest = 400
StatusUnauthorized = 401
StatusForbidden = 403
StatusNotFound = 404
StatusConflict = 409
StatusInternalServerError = 500
)
switch code {
case "000000": // 成功
return StatusOK
case "900001": // 请求参数无效
return StatusBadRequest
case "100001", "100002", "100003", "100004": // 认证相关错误
return StatusUnauthorized
case "100005": // 权限不足
return StatusForbidden
case "900002", "200001", "300001": // 资源不存在
return StatusNotFound
case "900003", "200002", "200006", "300004": // 资源冲突
return StatusConflict
case "900004": // 服务器内部错误
return StatusInternalServerError
case "200005": // 客户列表查询失败 - 根据设计理念返回200,通过响应体区分
return StatusOK
default:
// 根据错误码前缀判断
if len(code) >= 2 {
prefix := code[:2]
switch prefix {
case "10": // 认证模块错误,默认401
return StatusUnauthorized
case "20": // 客户模块错误,默认200(业务错误通过响应体区分)
return StatusOK
case "30": // 授权模块错误,默认200(业务错误通过响应体区分)
return StatusOK
case "90": // 系统错误,默认500
return StatusInternalServerError
}
}
return StatusInternalServerError
}
}
// I18nError 多语言错误结构体
type I18nError struct {
Code string
Lang string
Message string
HttpCode int
}
// Error 实现 error 接口
func (e *I18nError) Error() string {
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
// NewI18nError 创建多语言错误
func NewI18nError(code, lang string, customMessage ...string) *I18nError {
httpCode, _, message := NewI18nErrorResponse(code, lang, customMessage...)
return &I18nError{
Code: code,
Lang: lang,
Message: message,
HttpCode: httpCode,
}
}
// 便利函数:常用错误码的快速创建方法
// NewAuthError 创建认证错误
func NewAuthError(lang string, code ...string) *I18nError {
errorCode := "100001" // 默认认证过期
if len(code) > 0 && code[0] != "" {
errorCode = code[0]
}
return NewI18nError(errorCode, lang)
}
// NewValidationError 创建验证错误
func NewValidationError(lang string) *I18nError {
return NewI18nError("900001", lang)
}
// NewNotFoundError 创建资源不存在错误
func NewNotFoundError(lang string) *I18nError {
return NewI18nError("900002", lang)
}
// NewConflictError 创建资源冲突错误
func NewConflictError(lang string) *I18nError {
return NewI18nError("900003", lang)
}
// NewInternalError 创建内部服务器错误
func NewInternalError(lang string) *I18nError {
return NewI18nError("900004", lang)
}
// 批量错误处理函数
// ValidateI18nSupport 验证多语言支持状态
func ValidateI18nSupport() error {
if globalManager == nil {
return fmt.Errorf("i18n manager not initialized")
}
supportedLangs := globalManager.SupportedLanguages()
if len(supportedLangs) == 0 {
return fmt.Errorf("no languages loaded")
}
return nil
}
// GetSupportedLanguages 获取支持的语言列表
func GetSupportedLanguages() []string {
if globalManager == nil {
return []string{}
}
return globalManager.SupportedLanguages()
}
// IsLanguageSupported 检查语言是否支持
func IsLanguageSupported(lang string) bool {
if globalManager == nil {
return false
}
return globalManager.IsLanguageSupported(lang)
}
+397
View File
@@ -0,0 +1,397 @@
package i18n
import (
"fmt"
"io/ioutil"
"path/filepath"
"strings"
"sync"
"gopkg.in/yaml.v2"
)
// SystemInfo 语言包系统信息
type SystemInfo struct {
Name string `yaml:"name"`
Locale string `yaml:"locale"`
}
// LanguageData 语言包数据结构
type LanguageData struct {
System SystemInfo `yaml:"system"`
Errors map[string]interface{} `yaml:"errors"` // 灵活结构:支持直接字符串和嵌套对象
Enums map[string]interface{} `yaml:"enums"` // 枚举值多语言映射
DefaultError string `yaml:"default_error"`
flatErrors map[string]string // 扁平化的错误码映射(内部使用)
flatEnums map[string]string // 扁平化的枚举值映射(内部使用)
}
// Manager 多语言管理器
type Manager struct {
defaultLang string
loadedLangs map[string]*LanguageData
configPath string
mutex sync.RWMutex
}
// I18nManager 多语言管理器接口
type I18nManager interface {
LoadLanguage(lang string) error
GetErrorMessage(code, lang string) string
GetEnumMessage(enumType, enumValue, lang string) string
GetAllEnums(lang string) map[string]map[string]string
GetEnumsByType(enumType, lang string) map[string]string
SupportedLanguages() []string
SetDefaultLanguage(lang string)
IsLanguageSupported(lang string) bool
}
// NewManager 创建新的多语言管理器
func NewManager(configPath, defaultLang string) *Manager {
return &Manager{
defaultLang: defaultLang,
loadedLangs: make(map[string]*LanguageData),
configPath: configPath,
}
}
// LoadLanguage 加载指定语言包
func (m *Manager) LoadLanguage(lang string) error {
m.mutex.Lock()
defer m.mutex.Unlock()
// 检查是否已加载
if _, exists := m.loadedLangs[lang]; exists {
return nil
}
// 构建语言文件路径
filePath := filepath.Join(m.configPath, fmt.Sprintf("%s.yaml", lang))
// 读取文件内容
data, err := ioutil.ReadFile(filePath)
if err != nil {
return fmt.Errorf("failed to read language file %s: %v", filePath, err)
}
// 解析YAML
var langData LanguageData
if err := yaml.Unmarshal(data, &langData); err != nil {
return fmt.Errorf("failed to parse language file %s: %v", filePath, err)
}
// 扁平化错误码映射,便于快速查找
langData.flatErrors = make(map[string]string)
langData.flatEnums = make(map[string]string)
// 处理错误码映射
for key, value := range langData.Errors {
switch v := value.(type) {
case string:
// 直接定义的错误码(如 "000000": "成功")
langData.flatErrors[key] = v
case map[interface{}]interface{}:
// 嵌套模块的错误码(如 auth: {"100001": "xxx"})
for subKey, subValue := range v {
if code, ok := subKey.(string); ok {
if msg, ok := subValue.(string); ok {
langData.flatErrors[code] = msg
}
}
}
}
}
// 处理枚举值映射
for enumType, enumData := range langData.Enums {
if enumMap, ok := enumData.(map[interface{}]interface{}); ok {
for enumKey, enumValue := range enumMap {
if key, ok := enumKey.(string); ok {
if value, ok := enumValue.(string); ok {
// 使用 "enumType.enumKey" 作为扁平化的键
flatKey := fmt.Sprintf("%s.%s", enumType, key)
langData.flatEnums[flatKey] = value
}
}
}
}
}
// 存储加载的语言数据
m.loadedLangs[lang] = &langData
return nil
}
// GetErrorMessage 获取指定语言的错误信息
func (m *Manager) GetErrorMessage(code, lang string) string {
m.mutex.RLock()
defer m.mutex.RUnlock()
// 首先尝试获取请求语言的错误信息
if langData, exists := m.loadedLangs[lang]; exists {
if message, found := langData.flatErrors[code]; found {
return message
}
// 如果错误码不存在,返回该语言的默认错误信息
if langData.DefaultError != "" {
return langData.DefaultError
}
}
// 如果请求语言不存在或错误码不存在,降级到默认语言
if lang != m.defaultLang {
if langData, exists := m.loadedLangs[m.defaultLang]; exists {
if message, found := langData.flatErrors[code]; found {
return message
}
// 返回默认语言的默认错误信息
if langData.DefaultError != "" {
return langData.DefaultError
}
}
}
// 最终降级:返回硬编码的默认错误信息
return "Unknown error"
}
// GetEnumMessage 获取指定语言的枚举值显示文本
func (m *Manager) GetEnumMessage(enumType, enumValue, lang string) string {
m.mutex.RLock()
defer m.mutex.RUnlock()
// 构建查找键
flatKey := fmt.Sprintf("%s.%s", enumType, enumValue)
// 首先尝试获取请求语言的枚举值
if langData, exists := m.loadedLangs[lang]; exists {
if message, found := langData.flatEnums[flatKey]; found {
return message
}
}
// 如果请求语言不存在,降级到默认语言
if lang != m.defaultLang {
if langData, exists := m.loadedLangs[m.defaultLang]; exists {
if message, found := langData.flatEnums[flatKey]; found {
return message
}
}
}
// 最终降级:返回原始枚举值
return enumValue
}
// GetAllEnums 获取指定语言的所有枚举值
func (m *Manager) GetAllEnums(lang string) map[string]map[string]string {
m.mutex.RLock()
defer m.mutex.RUnlock()
result := make(map[string]map[string]string)
// 首先尝试获取请求语言的枚举值
if langData, exists := m.loadedLangs[lang]; exists {
result = m.buildEnumMap(langData)
if len(result) > 0 {
return result
}
}
// 如果请求语言不存在,降级到默认语言
if lang != m.defaultLang {
if langData, exists := m.loadedLangs[m.defaultLang]; exists {
result = m.buildEnumMap(langData)
}
}
return result
}
// GetEnumsByType 获取指定类型的枚举值
func (m *Manager) GetEnumsByType(enumType, lang string) map[string]string {
m.mutex.RLock()
defer m.mutex.RUnlock()
// 首先尝试获取请求语言的枚举值
if langData, exists := m.loadedLangs[lang]; exists {
if enumMap := m.getEnumMapByType(langData, enumType); len(enumMap) > 0 {
return enumMap
}
}
// 如果请求语言不存在,降级到默认语言
if lang != m.defaultLang {
if langData, exists := m.loadedLangs[m.defaultLang]; exists {
return m.getEnumMapByType(langData, enumType)
}
}
return make(map[string]string)
}
// buildEnumMap 构建完整的枚举映射
func (m *Manager) buildEnumMap(langData *LanguageData) map[string]map[string]string {
result := make(map[string]map[string]string)
for enumType, enumData := range langData.Enums {
if enumMap, ok := enumData.(map[interface{}]interface{}); ok {
typeMap := make(map[string]string)
for enumKey, enumValue := range enumMap {
if key, ok := enumKey.(string); ok {
if value, ok := enumValue.(string); ok {
typeMap[key] = value
}
}
}
if len(typeMap) > 0 {
result[enumType] = typeMap
}
}
}
return result
}
// getEnumMapByType 获取指定类型的枚举映射
func (m *Manager) getEnumMapByType(langData *LanguageData, enumType string) map[string]string {
result := make(map[string]string)
if enumData, exists := langData.Enums[enumType]; exists {
if enumMap, ok := enumData.(map[interface{}]interface{}); ok {
for enumKey, enumValue := range enumMap {
if key, ok := enumKey.(string); ok {
if value, ok := enumValue.(string); ok {
result[key] = value
}
}
}
}
}
return result
}
// SupportedLanguages 返回已加载的语言列表
func (m *Manager) SupportedLanguages() []string {
m.mutex.RLock()
defer m.mutex.RUnlock()
languages := make([]string, 0, len(m.loadedLangs))
for lang := range m.loadedLangs {
languages = append(languages, lang)
}
return languages
}
// SetDefaultLanguage 设置默认语言
func (m *Manager) SetDefaultLanguage(lang string) {
m.mutex.Lock()
defer m.mutex.Unlock()
m.defaultLang = lang
}
// IsLanguageSupported 检查语言是否被支持
func (m *Manager) IsLanguageSupported(lang string) bool {
m.mutex.RLock()
defer m.mutex.RUnlock()
_, exists := m.loadedLangs[lang]
return exists
}
// GetLanguageInfo 获取语言包信息
func (m *Manager) GetLanguageInfo(lang string) *SystemInfo {
m.mutex.RLock()
defer m.mutex.RUnlock()
if langData, exists := m.loadedLangs[lang]; exists {
return &langData.System
}
return nil
}
// parseAcceptLanguage 解析 Accept-Language 头部
func ParseAcceptLanguage(acceptLang string) string {
if acceptLang == "" {
return ""
}
// 简单解析 Accept-Language 头部,获取权重最高的语言
// 例:zh-CN,zh;q=0.9,en;q=0.8
languages := strings.Split(acceptLang, ",")
if len(languages) > 0 {
// 取第一个语言(权重最高)
firstLang := strings.TrimSpace(languages[0])
// 移除权重信息
if idx := strings.Index(firstLang, ";"); idx != -1 {
firstLang = firstLang[:idx]
}
return strings.TrimSpace(firstLang)
}
return ""
}
// 全局管理器实例(单例模式)
var globalManager *Manager
var once sync.Once
// InitGlobalManager 初始化全局管理器
func InitGlobalManager(configPath, defaultLang string) error {
var initErr error
once.Do(func() {
globalManager = NewManager(configPath, defaultLang)
// 预加载默认语言
if err := globalManager.LoadLanguage(defaultLang); err != nil {
initErr = fmt.Errorf("failed to load default language %s: %v", defaultLang, err)
}
})
return initErr
}
// GetGlobalManager 获取全局管理器实例
func GetGlobalManager() *Manager {
return globalManager
}
// GetErrorMessage 全局函数:获取错误信息
func GetErrorMessage(code, lang string) string {
if globalManager == nil {
return "I18n not initialized"
}
return globalManager.GetErrorMessage(code, lang)
}
// LoadLanguage 全局函数:加载语言
func LoadLanguage(lang string) error {
if globalManager == nil {
return fmt.Errorf("I18n not initialized")
}
return globalManager.LoadLanguage(lang)
}
// GetEnumMessage 全局函数:获取枚举值显示文本
func GetEnumMessage(enumType, enumValue, lang string) string {
if globalManager == nil {
return enumValue
}
return globalManager.GetEnumMessage(enumType, enumValue, lang)
}
// GetAllEnums 全局函数:获取所有枚举值
func GetAllEnums(lang string) map[string]map[string]string {
if globalManager == nil {
return make(map[string]map[string]string)
}
return globalManager.GetAllEnums(lang)
}
// GetEnumsByType 全局函数:获取指定类型的枚举值
func GetEnumsByType(enumType, lang string) map[string]string {
if globalManager == nil {
return make(map[string]string)
}
return globalManager.GetEnumsByType(enumType, lang)
}
+179 -1
View File
@@ -1,6 +1,17 @@
package utils
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"fmt"
"os"
"golang.org/x/crypto/bcrypt"
)
@@ -14,4 +25,171 @@ func HashPassword(password string) (string, error) {
func CheckPasswordHash(password, hash string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
return err == nil
}
}
// RSAPrivateKey RSA私钥结构
type RSAPrivateKey struct {
*rsa.PrivateKey
}
// RSAPublicKey RSA公钥结构
type RSAPublicKey struct {
*rsa.PublicKey
}
// LoadRSAPrivateKeyFromFile 从文件加载RSA私钥
func LoadRSAPrivateKeyFromFile(filePath string) (*RSAPrivateKey, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("读取私钥文件失败: %w", err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("无法解析PEM格式的私钥")
}
var key *rsa.PrivateKey
var errParse error
// 尝试PKCS1格式
key, errParse = x509.ParsePKCS1PrivateKey(block.Bytes)
if errParse != nil {
// 尝试PKCS8格式
parsedKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("解析私钥失败: %w", err)
}
var ok bool
key, ok = parsedKey.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("不是有效的RSA私钥")
}
}
return &RSAPrivateKey{PrivateKey: key}, nil
}
// LoadRSAPublicKeyFromFile 从文件加载RSA公钥
func LoadRSAPublicKeyFromFile(filePath string) (*RSAPublicKey, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("读取公钥文件失败: %w", err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("无法解析PEM格式的公钥")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("解析公钥失败: %w", err)
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, errors.New("不是有效的RSA公钥")
}
return &RSAPublicKey{PublicKey: rsaPub}, nil
}
// LoadRSAPublicKeyFromString 从字符串加载RSA公钥(支持PEM格式字符串)
func LoadRSAPublicKeyFromString(pubKeyStr string) (*RSAPublicKey, error) {
block, _ := pem.Decode([]byte(pubKeyStr))
if block == nil {
return nil, errors.New("无法解析PEM格式的公钥")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("解析公钥失败: %w", err)
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, errors.New("不是有效的RSA公钥")
}
return &RSAPublicKey{PublicKey: rsaPub}, nil
}
// SignData 使用RSA私钥对数据进行数字签名
func (key *RSAPrivateKey) SignData(data []byte) (string, error) {
// 计算数据的SHA256哈希
hashed := sha256.Sum256(data)
// 使用PSS模式进行签名(更安全)
signature, err := rsa.SignPSS(rand.Reader, key.PrivateKey, crypto.SHA256, hashed[:], &rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthEqualsHash,
Hash: crypto.SHA256,
})
if err != nil {
return "", fmt.Errorf("签名失败: %w", err)
}
// 返回base64编码的签名
return base64.StdEncoding.EncodeToString(signature), nil
}
// VerifySignature 使用RSA公钥验证数字签名
func (key *RSAPublicKey) VerifySignature(data []byte, signatureBase64 string) error {
// 解码base64签名
signature, err := base64.StdEncoding.DecodeString(signatureBase64)
if err != nil {
return fmt.Errorf("解码签名失败: %w", err)
}
// 计算数据的SHA256哈希
hashed := sha256.Sum256(data)
// 验证签名
err = rsa.VerifyPSS(key.PublicKey, crypto.SHA256, hashed[:], signature, &rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthEqualsHash,
Hash: crypto.SHA256,
})
if err != nil {
return fmt.Errorf("签名验证失败: %w", err)
}
return nil
}
// GenerateRSAKeyPair 生成RSA密钥对并保存到文件
func GenerateRSAKeyPair(privateKeyPath, publicKeyPath string, keySize int) error {
if keySize < 2048 {
keySize = 2048 // 最小2048位
}
// 生成密钥对
privateKey, err := rsa.GenerateKey(rand.Reader, keySize)
if err != nil {
return fmt.Errorf("生成RSA密钥对失败: %w", err)
}
// 保存私钥(PKCS1格式)
privateKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
})
if err := os.WriteFile(privateKeyPath, privateKeyPEM, 0600); err != nil {
return fmt.Errorf("保存私钥失败: %w", err)
}
// 保存公钥
publicKeyBytes, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
return fmt.Errorf("序列化公钥失败: %w", err)
}
publicKeyPEM := pem.EncodeToMemory(&pem.Block{
Type: "PUBLIC KEY",
Bytes: publicKeyBytes,
})
if err := os.WriteFile(publicKeyPath, publicKeyPEM, 0644); err != nil {
return fmt.Errorf("保存公钥失败: %w", err)
}
return nil
}
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff. Show more