315 Commits
Author SHA1 Message Date
wood911010 aa3ae38968 feat: Implement initial License Manager backend service with API endpoints and Swagger documentation. 2026-03-03 18:04:09 +08:00
wood911010 9fd545797f Merge branch 'main' of https://github.com/cedar-v/license-manager 2026-03-02 14:13:08 +08:00
wood911010 e84ae246b4 feat: Update LeadCreateRequest to make requirement field optional and set default value in lead creation 2026-03-02 14:12:59 +08:00
小屁孩 d48961ecf4 fix: 套餐管理国际化配置优化 2026-03-01 17:13:22 +08:00
wood911010 b5eda1d6d1 feat: Add packages table migration, initial configuration, and build executables. 2026-02-27 22:13:03 +08:00
wood911010 b903af8402 feat: Implement customer order management, including package listing, price calculation, and order creation. 2026-02-27 22:10:20 +08:00
wood911010 5fdbe4b023 Merge branch 'main' of https://github.com/cedar-v/license-manager 2026-02-27 00:55:08 +08:00
wood911010 7dd00dc074 feat: Add license management repository, models, and configuration for license and authorization code operations. 2026-02-27 00:55:04 +08:00
小屁孩 d56dfc2bad fix: bug修改 2026-02-25 21:58:26 +08:00
小屁孩 4de1f4d2e7 fix: bug修改 2026-02-25 21:27:00 +08:00
wood911010 a95322b6c5 feat: Implement CuOrderRepository for managing customer orders, including CRUD, listing, and summary operations. 2026-02-24 23:11:36 +08:00
wood911010 a1f411639d feat: Add customer order management module including repository, service, and configuration. 2026-02-24 23:04:59 +08:00
wood911010 560adafe27 feat: Implement comprehensive client and administrator invoice management features. 2026-02-24 22:26:09 +08:00
wood911010 b65b4ac801 feat: Implement initial customer authorization code and invoice management modules with API documentation and configuration. 2026-02-24 18:50:47 +08:00
小屁孩 c3d0f4aa2a fix: bug修改 2026-02-24 17:49:51 +08:00
wood911010 dec473a62a feat: Add database migration for packages table, backend configuration, and frontend dependency lock files. 2026-02-15 11:12:02 +08:00
Coldroads c9ced7dc2a Merge pull request #8 from mosshello/main
feat: 优化全局字体加载
2026-02-15 00:33:55 +08:00
jie 27a2e41c93 feat: 优化全局字体加载 2026-02-15 00:05:07 +08:00
wood911010 a91b4e0deb add: sync 2026-02-14 20:59:08 +08:00
wood911010 91fe674dea feat: Add initial license manager backend application, configuration, and development installation documentation. 2026-02-14 19:22:07 +08:00
wood911010 5f944dd88a chore: Remove src/components/common/Header.tsx. 2026-02-13 13:54:26 +08:00
wood911010 8a8630416e fix: 修改文件静态路径 2026-02-11 23:08:17 +08:00
wood911010 b4e7d954b0 Merge branch 'main' of https://github.com/cedar-v/license-manager 2026-02-11 22:36:08 +08:00
wood911010 fa715abbc2 feat: 添加订单列表查询接口的发票申请状态字段 2026-02-11 22:34:47 +08:00
小屁孩 3570167a83 feat: 最新代码提交 2026-02-11 20:42:16 +08:00
小屁孩 7224504ee0 feat: 最新代码提交 2026-02-11 20:38:14 +08:00
小屁孩 fd19bc4070 feat: 最新代码提交 2026-02-10 18:18:20 +08:00
wood911010 f02c00f9fe fix: 试用版更新 2026-02-10 01:07:35 +08:00
wood911010 d4dad1537f chore: update swagger documentation and improve API response structure 2026-02-10 00:34:25 +08:00
wood911010 679d94abd0 fix: 更新action 2026-02-10 00:31:03 +08:00
wood911010 5a5e9a7171 Merge branch 'main' of https://github.com/cedar-v/license-manager 2026-02-10 00:25:33 +08:00
wood911010 cdf00189aa feat: 增加继续支付接口 2026-02-10 00:24:53 +08:00
小屁孩 07fbbdd2a5 feat: 最新代码提交 2026-02-09 18:59:34 +08:00
wood911010 ab983e5dd6 fix: add retry logic for git pull in backend deployment workflow and improve nginx reload process in frontend workflow 2026-02-09 16:54:47 +08:00
wood911010 5908e3e9fd fix: update frontend directory permissions for nginx user in deployment workflow 2026-02-09 16:44:57 +08:00
wood911010 c716b2bea2 refactor: remove unused icon import from Dashboard.vue 2026-02-09 16:04:03 +08:00
Coldroads 67099d21a1 Merge pull request #7 from mosshello/feature/style_refactor0208
refactor: 全局字体、样式、间距统一优化
2026-02-09 15:55:24 +08:00
wood911010 2934364013 refactor: 后端部署流程更新、套餐 接口升级 2026-02-09 15:48:17 +08:00
小屁孩 98c49e91f7 feat: 最新代码提交 2026-02-09 11:00:58 +08:00
wood911010 7b1122e125 fix: change SSH username from 'pi' to 'root' in deployment workflow 2026-02-08 19:31:16 +08:00
wood911010 c3d5b891b6 chore: 更新自动化 2026-02-08 18:49:47 +08:00
小屁孩 ea37b2ebb1 feat: 最新代码提交 2026-02-08 09:33:07 +08:00
小屁孩 89cb1d92c8 feat: 最新代码提交 2026-02-08 09:29:26 +08:00
jie 601658c992 refactor: 全局字体、样式、间距统一优化 2026-02-08 03:41:38 +08:00
wood911010 0659c7624d fix: 登录被锁错误返回优化 2026-02-07 22:47:38 +08:00
wood911010 2555b07abe fix:用户端发票管理查询接口增加结束时间 2026-02-07 21:58:56 +08:00
wood911010 01b27fdde6 fix: enable customer authentication middleware in router setup 2026-02-05 13:50:19 +08:00
wood911010 33881795c5 docs: update README to clarify official documentation and website links 2026-02-03 10:17:23 +08:00
wood911010 26dd1b9615 feat: 添加发票文件代理和静态文件服务支持 2026-02-02 21:20:39 +08:00
wood911010 eba7baaa5f Merge branch 'main' of github.com:cedar-v/license-manager 2026-02-02 19:58:38 +08:00
wood911010 97bda15450 fix: update swagger documentation comments for clarity and formatting 2026-02-02 19:58:31 +08:00
小屁孩 ce5bd6d3ba feat: 添加功能添加 2026-02-02 18:47:27 +08:00
小屁孩 41e00fcb90 feat: 添加功能添加 2026-02-02 16:20:10 +08:00
wood911010 7642e0d1ea Fix:增加缺少的备注字段 2026-02-02 16:07:27 +08:00
wood911010 e6899d26bc docs:更新swagger文档 2026-02-02 14:40:53 +08:00
wood911010 8b758cc40c feat: 增加线索汇总 2026-02-02 14:37:10 +08:00
wood911010 f2bb2ef952 fix: 更新发票请求结构体,修正内容字段为非必填,更新注释以提高可读性 2026-02-02 09:29:12 +08:00
wood911010 3edbaef170 fix: 修正删除客户时的错误代码,更新授权码和许可证相关的错误信息 2026-02-02 01:43:27 +08:00
wood911010 251fcba655 feat: 更新开发提示词文档,增加开发原则、沟通协作和行为准则,添加授权码模糊匹配参数支持 2026-02-02 01:34:42 +08:00
wood911010 b8c21c24cd feat: 添加企业线索管理功能,包括线索的创建、更新、删除和查询接口,更新相关文档和数据库结构 2026-02-01 13:19:29 +08:00
wood911010 d6890462a3 feat: 添加套餐管理功能,包括套餐的创建、更新、删除和查询接口,更新相关文档 2026-02-01 00:11:51 +08:00
小屁孩 2032950bb7 feat: 发票管理 2026-01-31 14:05:06 +08:00
wood911010 fa056ae272 Merge branch 'main' of github.com:cedar-v/license-manager 2026-01-30 23:30:51 +08:00
wood911010 f6376fbf2b feat: 添加管理端用户管理接口文档,更新发票管理相关接口的安全性和描述信息 2026-01-30 23:30:42 +08:00
duanjianhong d548f07c9f fix: 页面优化 2026-01-30 09:21:00 +08:00
小屁孩 7f4e2592b7 fix: 发票 2026-01-30 08:46:45 +08:00
wood911010 8f18d1e442 feat: 添加令牌刷新机制,支持自动延长token有效期,更新相关文档描述 2026-01-27 01:00:17 +08:00
wood911010 41b499cde7 fix: 修正授权码状态判断逻辑,确保正常状态的条件判断准确 2026-01-27 00:21:30 +08:00
wood911010 33d348c692 refactor: 修改用户授权码相关接口,将用户ID参数更改为客户ID,并更新相关文档注释以提高可读性 2026-01-27 00:01:25 +08:00
wood911010 f14f2a536f feat: 更新订单创建逻辑,根据套餐类型和支付方式处理试用版和免费订单 2026-01-24 20:00:25 +08:00
wood911010 a4a5870925 fix: 更新默认国家代码为 +86,以支持正确的手机号格式 2026-01-23 21:44:46 +08:00
wood911010 b93d51b4ad feat: 更新授权码分享功能,支持通过手机号或邮箱分享授权码,并更新相关文档描述 2026-01-23 15:10:27 +08:00
wood911010 b80f5cfdd9 feat: 更新订单删除功能,支持物理删除订单及相关支付记录,确保数据一致性 2026-01-23 09:36:51 +08:00
wood911010 af1b7d49a2 feat: 更新后端设计文档,添加发票管理模块相关内容,支持缓存系统和国际化,优化架构描述 2026-01-23 01:11:23 +08:00
wood911010 969b36f1d1 feat: 添加发票管理模块,支持客户申请发票、管理员处理发票、文件上传及下载功能,更新相关文档和国际化错误码 2026-01-23 00:38:00 +08:00
wood911010 3192b79aa4 feat: 更新订单处理接口,新增删除订单功能并更新相关文档,调整成功响应格式 2026-01-22 23:30:21 +08:00
wood911010 afa9e8204f feat: 添加取消订单接口,支持取消未支付订单并返回相应错误信息,更新相关文档和国际化错误码 2026-01-22 01:22:52 +08:00
wood911010 874820fabd feat: 增加订单号查询筛选功能,支持模糊匹配、状态和时间筛选,更新相关接口文档 2026-01-18 16:46:00 +08:00
wood911010 4c48344fc1 refactor: 更新CI/CD流程,构建后端时跳过测试以避免外部API依赖,同时删除不再需要的测试文件 2026-01-18 00:16:03 +08:00
wood911010 78a35f71e1 refactor: 删除TestSMSEnabledService测试以避免在CI/CD环境中调用真实的外部API,建议在本地开发环境中手动测试SMS功能 2026-01-17 23:13:02 +08:00
wood911010 39957295af docs:更新README 2026-01-17 23:01:07 +08:00
wood911010 9aa4688ac8 chore: update backend and frontend images to version 1.1.0 in docker-compose configuration 2026-01-17 22:53:22 +08:00
wood911010 33767d4e71 feat: 发布 v1.1.0,新增客户用户管理、产品套餐体系、授权码增强、设备管理、支付宝支付集成、短信验证码服务及缓存模块重构,修复授权码格式和支付流程问题 2026-01-17 22:48:44 +08:00
wood911010 b5fa708c28 refactor: 优化支付宝回调处理逻辑,简化空值检查和JSON序列化过程 2026-01-17 00:28:33 +08:00
wood911010 8eca9f92bb feat: 优化支付宝回调处理逻辑,支持多种套餐类型的使用限制映射,简化自定义参数处理 2026-01-17 00:18:06 +08:00
wood911010 612bf9090e feat: 更新支付宝支付回调处理逻辑,支持新旧接口兼容,优化回调数据处理和订单状态更新 2026-01-17 00:00:23 +08:00
wood911010 973b549298 feat: 添加设备和订单汇总接口,支持获取当前用户的设备和订单统计信息,优化授权码管理和设备筛选功能 2026-01-16 22:42:27 +08:00
wood911010 e91775ea76 feat: 添加产品激活码接口,支持生成包含授权码和配置签名的产品激活码,优化授权码管理流程 2026-01-16 21:21:28 +08:00
wood911010 04af5eca00 fix: 更新企业定制版套餐价格字段,将其改为定制报价以更好地反映服务内容 2026-01-15 09:18:59 +08:00
wood911010 3f6cca7364 feat: 添加企业定制版套餐到订单处理逻辑,支持按需定制的服务选项 2026-01-14 18:49:18 +08:00
wood911010 6bfde74e21 feat: 添加用户订单和设备汇总接口设计文档,支持获取当前登录用户的订单和设备统计信息 2026-01-13 23:47:42 +08:00
wood911010 194accefd8 fix: 更新短信服务配置,将最大发送次数从5增加到25,以支持更高的短信发送需求 2026-01-13 09:41:44 +08:00
wood911010 19a80887b3 fix: 更新短信发送错误处理,返回详细错误信息以便调试,改善用户体验 2026-01-13 01:05:59 +08:00
wood911010 7062b9f1e8 feat: 增强短信服务功能,添加验证码相关错误处理和国际化文本支持,更新错误码以改善用户体验 2026-01-12 19:54:00 +08:00
wood911010 d2423f3705 feat: 添加品牌与商标使用条款文档,明确品牌材料的使用规则和限制 2026-01-10 18:28:22 +08:00
wood911010 d9cb5361b1 feat: 添加缓存模块,支持内存和Redis缓存,优化短信服务功能,更新相关配置和文档 2026-01-10 18:18:15 +08:00
wood911010 19e0c6706a feat: 添加短信服务功能,支持发送当前手机号和新手机号验证码,更新相关配置和文档 2026-01-10 16:54:44 +08:00
wood911010 9c2b503d2b feat: 添加注册发送验证码功能,支持手机号验证及频率限制,更新相关文档和国际化文本 2026-01-10 15:44:49 +08:00
wood911010 af76eaf1b1 feat: 添加支付模块,支持支付宝支付功能,包括订单创建、支付状态查询及支付历史记录管理 2026-01-09 19:21:40 +08:00
wood911010 879aca449f feat: 添加授权码分享功能,支持用户将授权码分享给其他用户并生成新授权码 2026-01-07 00:55:12 +08:00
wood911010 2228beed51 feat: 添加设备管理模块,支持设备列表获取和设备解绑功能 2026-01-04 00:09:31 +08:00
wood911010 d7dc1e2dbe feat: 添加客户订单管理模块,支持套餐列表获取、订单创建及用户订单查询功能 2026-01-02 01:48:36 +08:00
wood911010 d76bff2142 feat: 添加客户用户管理模块,支持注册、登录、密码重置及个人资料管理功能 2025-12-31 00:40:37 +08:00
wood911010 18a1193618 chore: 更新后端和前端服务的镜像版本至 v1.0.5 2025-12-21 15:08:16 +08:00
wood911010 02fc6727f3 chore: 更新后端和前端服务的镜像版本至 v1.0.4 2025-12-21 14:59:07 +08:00
wood911010 146eb2c44d feat: 更新授权变更类型,添加起止时间支持,优化许可证更新表单和国际化文本 2025-12-21 13:31:00 +08:00
wood911010 53e3449849 chore: 修改开发环境的 API 基础 URL,恢复为本地地址 2025-12-09 20:39:22 +08:00
wood911010 b56766b123 chore: 更新后端服务的基础镜像版本至 alpine:3.18 2025-12-09 20:30:56 +08:00
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
357 changed files with 92504 additions and 2697 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
+55
View File
@@ -0,0 +1,55 @@
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.0.3
with:
host: ${{ secrets.HOST43 }}
username: root
password: ${{ secrets.PASS43 }}
port: 22
command_timeout: 10m
script_stop: true
envs: USER
script: |
# 确保使用正确的用户环境(云服务器 root)
export HOME=/root
export USER=root
# 加载 root 用户的环境配置(包括代理设置)
source /root/.bashrc || true
source /root/.profile || true
cd /root/license-manager
echo "Starting git pull with retry (max 5 times)..."
max_retries=5
count=0
until git pull origin main; do
count=$((count + 1))
if [ "$count" -ge "$max_retries" ]; then
echo "❌ git pull failed after $max_retries attempts."
exit 1
fi
echo "⚠️ git pull failed (attempt $count/$max_retries), retrying in 10 seconds..."
sleep 10
done
echo "✅ git pull succeeded."
pm2 restart license_manager
-120
View File
@@ -1,120 +0,0 @@
name: Deploy Backend
on:
push:
branches: [ main ]
paths:
- 'backend/**'
- 'Dockerfile'
- '.github/workflows/deploy_backend.yml'
workflow_dispatch:
env:
IMAGE_NAME: license-manager-backend
CONTAINER_NAME: license-manager
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.23'
- name: Build and test backend
run: |
cd backend
go mod tidy
go mod verify
go test ./...
go build -ldflags="-s -w" -o license-manager cmd/main.go
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
run: |
docker build -f Dockerfile.backend -t ${{ env.IMAGE_NAME }}:latest .
docker save ${{ env.IMAGE_NAME }}:latest > backend-image.tar
- name: Copy files to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.HOST_1 }}
username: root
password: ${{ secrets.PASS_1 }}
port: 22
source: "backend-image.tar,backend/configs/config.example.yaml,backend/configs/config.yaml"
target: "/opt/license-manager/"
- name: Deploy to server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST_1 }}
username: root
password: ${{ secrets.PASS_1 }}
port: 22
script: |
cd /opt/license-manager
# Stop and remove existing container
docker stop ${{ env.CONTAINER_NAME }} || true
docker rm ${{ env.CONTAINER_NAME }} || true
# Load new image
docker load < backend-image.tar
# Remove old image
docker rmi ${{ env.IMAGE_NAME }}:old || true
docker tag ${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:old || true
# 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
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 \
${{ env.IMAGE_NAME }}:latest
# Clean up
rm -f backend-image.tar
docker image prune -f
# Check deployment status
sleep 5
if docker ps | grep -q ${{ env.CONTAINER_NAME }}; then
echo "✅ Deployment successful! Container is running."
docker logs --tail 10 ${{ env.CONTAINER_NAME }}
else
echo "❌ Deployment failed! Container is not running."
docker logs ${{ env.CONTAINER_NAME }}
exit 1
fi
- name: Health check
run: |
sleep 10
response=$(curl -s -o /dev/null -w "%{http_code}" http://${{ secrets.HOST_1 }}:18888/health || echo "000")
if [ "$response" = "200" ]; then
echo "✅ Health check passed! Service is responding."
else
echo "⚠️ Health check returned status: $response"
echo "Service may still be starting up..."
fi
+194
View File
@@ -0,0 +1,194 @@
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.HOST43 }}
username: root
password: ${{ secrets.PASS43 }}
port: 22
script: |
sudo rm -rf /home/pi/lm-www/frontend_tmp
sudo mkdir -p /home/pi/lm-www/frontend_tmp/production
sudo chown -R root:root /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.PASS43 }}" rsync -avz --info=progress2 \
-e "ssh -o StrictHostKeyChecking=no" \
frontend/production/ \
root@${{ secrets.HOST43 }}:/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.PASS43 }}" ssh -o StrictHostKeyChecking=no \
root@${{ secrets.HOST43 }} \
"du -sb /home/pi/lm-www/frontend_tmp/production 2>/dev/null | cut -f1" || echo "0")
if [ "$TRANSFERRED" != "0" ] && [ "$TOTAL_SIZE" != "0" ]; then
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.HOST43 }}
username: root
password: ${{ secrets.PASS43 }}
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..."
# 将前端目录权限授予 nginx 运行用户(OpenCloudOS 上通常为 nginx)
sudo chown -R nginx:nginx /home/pi/lm-www/frontend/
sudo chmod -R 644 /home/pi/lm-www/frontend/
sudo find /home/pi/lm-www/frontend/ -type d -exec chmod 755 {} \;
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..."
# 与服务器手动操作保持一致:先测试配置,再重载
if command -v nginx >/dev/null 2>&1; then
echo "Testing nginx config (nginx -t)..."
sudo nginx -t
echo "Reloading nginx (nginx -s reload)..."
sudo nginx -s reload
else
echo "⚠️ 未找到 nginx 命令,请确认 nginx 是否已安装并在 PATH 中"
fi
echo "✅ Deployment completed!"
-96
View File
@@ -1,96 +0,0 @@
name: Deploy Frontend
on:
push:
branches: [ main ]
paths:
- 'frontend/**'
- 'Dockerfile.frontend'
- 'nginx.conf'
- '.github/workflows/deploy_frontend.yml'
workflow_dispatch:
env:
IMAGE_NAME: license-manager-frontend
CONTAINER_NAME: license-manager-frontend
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
run: |
docker build -f Dockerfile.frontend -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 }}
port: 22
source: "frontend-image.tar"
target: "/opt/license-manager/"
- name: Deploy to server
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HOST_1 }}
username: root
password: ${{ secrets.PASS_1 }}
port: 22
script: |
cd /opt/license-manager
# Stop and remove existing container
docker stop ${{ env.CONTAINER_NAME }} || true
docker rm ${{ env.CONTAINER_NAME }} || true
# Load new image
docker load < frontend-image.tar
# Remove old image
docker rmi ${{ env.IMAGE_NAME }}:old || true
docker tag ${{ env.IMAGE_NAME }}:latest ${{ env.IMAGE_NAME }}:old || true
# Run new container
docker run -d \
--name ${{ env.CONTAINER_NAME }} \
--restart unless-stopped \
-p 18080:80 \
--add-host host.docker.internal:host-gateway \
${{ env.IMAGE_NAME }}:latest
# Clean up
rm -f frontend-image.tar
docker image prune -f
# Check deployment status
sleep 5
if docker ps | grep -q ${{ env.CONTAINER_NAME }}; then
echo "✅ Frontend deployment successful! Container is running."
docker logs --tail 10 ${{ env.CONTAINER_NAME }}
else
echo "❌ Frontend deployment failed! Container is not running."
docker logs ${{ env.CONTAINER_NAME }}
exit 1
fi
- name: Health check
run: |
sleep 10
response=$(curl -s -o /dev/null -w "%{http_code}" http://${{ secrets.HOST_1 }}:18080/ || echo "000")
if [ "$response" = "200" ]; then
echo "✅ Frontend health check passed! Service is responding."
else
echo "⚠️ Frontend health check returned status: $response"
echo "Service may still be starting up..."
fi
+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
+93
View File
@@ -0,0 +1,93 @@
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: Build backend
run: |
cd backend
go mod tidy
go mod verify
# Skip tests in CI/CD to avoid external API dependencies
echo "Skipping tests in CI/CD environment..."
go build -ldflags="-s -w" -o license-manager cmd/main.go
- name: Set up Docker Buildx
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
+103
View File
@@ -0,0 +1,103 @@
# Branding & Trademark Policy
# 品牌与商标使用条款
本文件规定了本项目 Logo、图标、名称及其他品牌材料的使用规则。
The following terms govern the use of the project's logo, icons, name, and other branding materials.
源代码遵循 GNU General Public License v3(GPLv3)授权,但品牌材料不属于 GPLv3 授权范围。
The source code is licensed under the GNU General Public License v3 (GPLv3), but branding materials are **not** covered by the GPLv3 license.
---
## 1. Branding Materials Covered
## 1. 受保护的品牌材料
以下内容均属于“品牌材料”:
The following items are considered “Branding Materials”:
- 项目名称 / Project name
- Logo
- 图标 / Icons
- 品牌识别元素 / Brand identifiers
- 其他视觉或文字形式的品牌材料
Other visual or textual branding assets
---
## 2. No Modification
## 2. 禁止修改
未经版权所有者书面授权,禁止对品牌材料进行任何形式的修改:
You may not modify the Branding Materials in any way without explicit written permission:
- 更改颜色、比例、形状
Changing colors, proportions, or shapes
- 添加或删除元素
Adding or removing elements
- 制作衍生 Logo
Creating derivative logos
- 将 Logo 融入其他图形
Embedding the logo into other graphics
- 删除或替换软件中的原始 Logo
Removing or replacing the original logo in the software
---
## 3. No Replacement with Your Own Logo
## 3. 禁止替换为自有 Logo
未经书面授权,不得将软件中的 Logo 或品牌材料替换为任何个人或组织的 Logo、图标或品牌标识。
You may not replace the logo or branding materials within the software with your own or any third-party branding without written authorization.
---
## 4. No Commercial Use
## 4. 禁止商业使用
未经商业授权,禁止将品牌材料用于任何商业目的:
Commercial use of the Branding Materials is prohibited unless a commercial license is granted:
- 商业产品或服务
Commercial products or services
- 付费发行
Paid distribution
- 广告、营销、宣传
Advertising, marketing, or promotional use
- 商业网站或应用展示
Display on commercial websites or applications
---
## 5. No Misleading Use
## 5. 禁止误导性使用
不得以任何方式使用品牌材料暗示:
You may not use the Branding Materials in a way that implies:
- 官方认可 / Official endorsement
- 官方合作 / Official partnership
- 获得商业授权 / Commercial authorization
- 与版权所有者存在合作或从属关系
Any affiliation with the copyright holder
除非获得明确的书面许可。
Unless explicit written permission is provided.
---
## 6. Trademark Rights Reserved
## 6. 商标权保留
版权所有者保留对品牌材料的全部商标权。
All trademark rights to the Branding Materials are reserved by the copyright holder.
GPLv3 对源代码的授权 **不构成** 对商标或品牌材料的授权。
The GPLv3 license for the source code **does not** grant any rights to use the trademarks or branding materials.
---
## Contact for Permissions
## 授权联系
如需商业授权或品牌使用许可,请联系版权所有者。
For commercial licensing or branding permissions, please contact the copyright holder.
-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"]
+7 -5
View File
@@ -6,10 +6,12 @@ RUN go mod download
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
FROM alpine:3.18
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。
+181 -31
View File
@@ -1,4 +1,22 @@
# 软件授权管理平台
# 软件授权管理系统
<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)
@@ -6,7 +24,83 @@
## 项目概述
软件授权管理平台是一个独立的软件授权管理系统,为IoT平台等软件系统提供授权码生成、分发、验证和管理服务。支持在线和离线两种授权模式,基于硬件绑定的授权机制确保安全性。
软件授权管理系统是一个完整的软件商业化解决方案,集成了软件授权管理、用户自助服务和商业化运营能力。支持B2B企业客户和B2C个人用户的双轨运营模式,提供从注册购买到授权激活的全流程数字化服务。采用在线/离线混合授权模式,结合硬件绑定和RSA数字签名确保安全性。
✨同时支持**任何开发语言**、**任何类型的软件产品**通过标准化 **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://www.cedar-v.com](https://www.cedar-v.com)
官方文档请访问:[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)
客户管理模块提供完整的客户信息管理功能,包括客户信息的增删改查、状态管理、授权关联等核心功能。
## 用户端自助服务
平台提供完整的用户端自助服务,支持 **B2B+B2C双轨运营**:
### B2B企业客户服务
- **企业用户注册**:支持手机号注册,企业管理员可管理成员账户
- **产品套餐购买**:提供试用版、基础版、专业版三种套餐,支持批量许可购买
- **授权码管理**:在线购买授权码,支持离线激活和授权码分享
- **设备管理**:企业用户可查看和管理所有激活设备,支持设备解绑
- **订单管理**:完整的订单历史查询和状态跟踪
### B2C个人客户服务
- **个人用户注册**:手机号快速注册,简化的个人账户管理
- **灵活购买**:支持单个或少量许可购买,满足个人用户需求
- **便捷激活**:支持在线激活和离线激活两种模式
- **设备监控**:个人用户可实时查看设备状态和使用情况
### 统一支付体验
- **支付宝集成**:支持支付宝扫码支付,安全可靠的支付体验
- **支付状态跟踪**:实时支付状态查询和回调通知
- **多环境支持**:沙箱环境测试和生产环境切换
## 核心功能
@@ -17,63 +111,102 @@
- 🌐 **API服务**:提供验证、激活、心跳监控等RESTful API
- ⚙️ **系统管理**:管理员认证和监控仪表盘
- 🛠️ **跨平台工具**:多平台硬件信息获取工具
- 👥 **用户端自助服务**:B2B+B2C双轨运营,支持企业客户和个人用户自助操作
## 技术栈
- **前端**: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 +216,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群中反馈
- 发送邮件给项目维护者
+177 -20
View File
@@ -1,12 +1,97 @@
# 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)
---
## Overview
License Manager is an independent software licensing platform that provides license code generation, distribution, validation, and management services for IoT platforms and other software systems. It offers both online and offline licensing modes with hardware-based binding for enhanced security.
License Manager is a comprehensive software commercialization solution that integrates software licensing management, self-service user portals, and commercial operation capabilities. It supports dual-track operations for both B2B enterprise customers and B2C individual users, providing full-process digital services from registration and purchase to license activation. It employs a hybrid online/offline licensing model combined with hardware binding and RSA digital signatures to ensure security.
✨It enables **any programming language** and **any type of software product** to **quickly integrate licensing capabilities** via standardized **HTTP APIs** and **cross-platform client SDKs**.
## 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.
## Self-Service User Portal
The platform provides comprehensive self-service user portals supporting **dual-track operations for B2B+B2C**:
### B2B Enterprise Customer Services
- **Enterprise User Registration**: Mobile phone registration with enterprise admin managing member accounts
- **Product Package Purchase**: Three package types (Trial, Basic, Professional) with bulk license purchasing
- **License Code Management**: Online license purchase with offline activation and license sharing
- **Device Management**: Enterprise users can view and manage all activated devices with device unbinding
- **Order Management**: Complete order history queries and status tracking
### B2C Individual Customer Services
- **Individual User Registration**: Quick mobile phone registration with simplified personal account management
- **Flexible Purchasing**: Support for single or small quantity license purchases meeting individual user needs
- **Convenient Activation**: Support for both online activation and offline activation modes
- **Device Monitoring**: Individual users can monitor device status and usage in real-time
### Unified Payment Experience
- **Alipay Integration**: Support for Alipay QR code payment with secure and reliable payment experience
- **Payment Status Tracking**: Real-time payment status queries and callback notifications
- **Multi-environment Support**: Sandbox environment testing and production environment switching
## Key Features
@@ -17,12 +102,13 @@ License Manager is an independent software licensing platform that provides lice
- 🌐 **API Services**: RESTful APIs for validation, activation, and heartbeat monitoring
- ⚙️ **System Management**: Admin authentication and monitoring dashboard
- 🛠️ **Cross-platform Tools**: Hardware information extraction tools for multiple platforms
- 👥 **Self-Service User Portal**: Dual-track B2B+B2C operations supporting enterprise customers and individual users with self-service operations
## Technical Stack
- **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 +125,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 +223,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 .
+74 -7
View File
@@ -1,18 +1,24 @@
package main
import (
"flag"
"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
// @version 1.0
// @description 软件授权管理平台API文档
// @description 软件授权管理系统API文档
// @termsOfService http://swagger.io/terms/
// @contact.name API Support
@@ -22,8 +28,8 @@ import (
// @license.name MIT
// @license.url https://opensource.org/licenses/MIT
// @host localhost:18888
// @BasePath /
// @schemes http https
// @securityDefinitions.apikey BearerAuth
// @in header
@@ -31,8 +37,22 @@ import (
// @description Bearer token for authentication
func main() {
// 定义命令行参数
var customPath string
flag.StringVar(&customPath, "config", "", "path to configuration file")
flag.StringVar(&customPath, "c", "", "path to configuration file (shorthand)")
flag.Parse()
// 加载配置
if err := config.Load(""); err != nil {
configPath := customPath
if configPath == "" {
configPath = "config.yaml" // Docker 环境默认路径
if _, err := os.Stat(configPath); os.IsNotExist(err) {
configPath = "../configs/config.yaml" // 本地开发环境
}
}
if err := config.Load(configPath); err != nil {
log.Fatalf("Failed to load config: %v", err)
}
@@ -43,7 +63,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 +120,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)
}
}
}
+117
View File
@@ -0,0 +1,117 @@
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
cu_jwt:
secret: "license-manager-cu-dev-secret-key-32-bytes"
expire_hours: 24
refresh_threshold_minutes: 60
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天内到期算即将过期
payment:
# 默认支付方式
default_method: alipay
# 支付提供商配置 - 支付宝沙箱环境
providers:
alipay:
enabled: true
app_id: "9021000159611786"
private_key: "-----BEGIN PRIVATE KEY-----\nMIIEogIBAAKCAQEAhxBHcY3BLmA0glH1v1e9VnLFEc72ZyI5xNF77SMA5hGutz30\nSmzczjYvGJNYTeyWfIjIqtP0IA49XA61kpUPiTyBy2sxjDfu6ew+Yu2waynpI+Nbk\npIz8TWT1g7rqgZMsUexufmvFEpvn1AmboVKN8iZVflwc4lq5pQ/GS7uTlDK3fY32j\nEk6jxEHy8yZvkf467X0WK9AdrJ0IxUTOLBROdZySqg7mc2Iyo61xdDOuVCJjZSWFH\nt+s4UWx2VPFSbL2z4m82abGicAgj696J2PU9iLhQG2HA/EwrH8zsVyJPHTMgPFwng\nN9sxmT4U3OH8ugCnw/amEsD8VZR3PG3ypQIDAQABAoIBAFSs2Q2Q4H7xo5ncRmQ6Xq\nKW+4Ppq8MGyw7ggoAuBL84cL/C4KfUTi4d/DK9FYxs+f+DRcMLdK6wHrnbIuyN9Rq\nFy527S2nsGyWejZI6D/NvaBGu1Vczv8rGWzPFUOvcXTJ7jynYJnJjJE3N0vpRKDGG\n2tLyuhRFH7cFU/W23YJM7TflTUUh1SG0Ec+jUa+ek5nK/vsqGE7A5p/D+mW4WLfS4\nsGhfe1XzNKjuCznTYajG79YXHXBKljqq4SKoEdh0SQHoEy7H0BXqArjSbh4KdHYoW/\nKkvA/c5QpbaTuWdK3iuvq7bZ1I0SEckL/rzufj+OcgoKcnwxZG2/Eq1Nb8tUCgYEAw\ncuyQW1AZEVl4dbk9Y4JXFeWh82LZ9pmyvr9tmqwQP4Ywlb9zUlXy+Zbv1wa9acfRWu9\ndYGm3SByFk8c3UGb/s3+eY4MribqkNVkjdotC/+wfymyf3gdLypGEXjBGwNJregGFeF\nmUhWnS+YWOiCiH401uDTU1pRtW37DZgdD5ZMCgYEAsmqIRL4U+3bkIb8u26lgS3k2W\nImen8+i91jVc8wWD7/aWGNmjodimyOv9AJRt34y5apzzfcWuzr9qdNQrzGubtKjaVM6\ns6NNVGzUJkLjF4WpyFDmX/ZLZkTW0gQLMYAjmiEJkPlbpHQ81vSMCT7W76RB31vL8p\ndRJMGBz/dN6ecCgYATzaXsi6Z92J2do2EIOrh4KDcor3dkaGwCp/1PK73RFmYfTdZnq\nOwA2QxdFKNooOhQJr9Yh6Quioc+Al2XuoGfWxt6/moLTczAeh3pZPcfd8RoajFkVzjc\nPfylAmrxQLfBvqkIiVz0XGQZHGz/CaCnir2HklaBiu9V3zFGds2YYwKBgFuaNryUm1F\nAiYCKGBTPsZEOesiaX8bNZG56PZ1mey0642/eGRm0iPv+JqixSCNdt+QSz8I/ynv2naZ\nDyHkqmNRtcVjyUoFIprth5MqALPg2uCUQ5DcAzVWEoobRbK3NjHnl3HOCk3qr81Cp3f\nwhN1lyjB+RkfTLm+2zLGMKJgahAoGAGUigAVlCnUo6zj8PVOeUPJWV3tZf8ZkTtCXT\njdp9tPwZqf0ZlmVZCoQB3nclF/6PgxgH7W596DlUt8TIrdmgg28HM8C2rpY/AoCw1H3\ncMkha8gCUN308cx0I2duea3tvDUb8thfvR34d7bxCI2hTOsplfE/MVv+sjzprGwtH/84=\n-----END PRIVATE KEY-----"
public_key: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjkjXfe1fXjelyi6HIzFp\n5l9PGPxDBTi/TyIsRrF5NaYRCGqqSSJj1sc440eJ7InKRb4q/7cpdjlroC6DDO1O\nHKipld1pWSqoezc2lLVxvRWGt7Tl9mDfrul1/4ywx9OnIPBx3iJvzY15XDVWlZFY\nWVJFQv5EriLn+3xy+3gaazzJYCp/PumDqMTUgp0Kjk+HuHJ5/PKtFt3AtN2GB8gl\nQEybl2Wf0qNZU9SzmEm+9I7CNvB/zUC3csRxJHTvbOtkjhCC/TrS20Xm8IFjygu9\nlBf9qzacXyfHjG6fLMU8aDn4kPAXL+wbqE2UzFmVvMNCLpEaIS3r7I8S2TGQLPDD\n6QIDAQAB\n-----END PUBLIC KEY-----"
gateway_url: "https://openapi-sandbox.dl.alipaydev.com/gateway.do"
notify_url: "http://localhost:18888/api/payment/alipay/callback"
return_url: "http://localhost:18888/payment/success"
sign_type: "RSA2"
charset: "utf-8"
format: "json"
# 通用支付配置
expire_minutes: 30 # 支付过期时间(分钟)
retry_times: 3 # 支付重试次数
enable_log: true # 启用支付日志
sms:
enabled: false # 开发环境默认禁用,需要配置阿里云AccessKey
access_key_id: "" # 请配置阿里云AccessKey ID
access_key_secret: "" # 请配置阿里云AccessKey Secret
sign_name: "xxxx科技"
region_id: "cn-hangzhou"
endpoint: "dysmsapi.aliyuncs.com"
# 模板配置(可选,不配置则使用代码中的默认值)
templates:
register: "SMS_330275014" # 注册验证码模板
reset_pwd: "SMS_330275014" # 重置密码验证码模板
login: "SMS_330275014" # 登录验证码模板
current_phone: "SMS_330275014" # 当前手机号验证码模板
new_phone: "SMS_330275014" # 新手机号验证码模板
cache:
enabled: true # 是否启用缓存
type: memory # 缓存类型: memory, redis
ttl: 30m # 默认TTL
# Redis缓存配置(当type为redis时使用)
redis:
host: localhost
port: 6379
password: ""
db: 0
pool_size: 10
min_idle_conns: 5
conn_max_idle_time: 30m
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
# 内存缓存配置(当type为memory时使用)
memory:
max_size: 10000 # 最大缓存条目数
cleanup_interval: 10m # 清理间隔
+116 -21
View File
@@ -1,30 +1,125 @@
# 自动部署使用
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
cu_jwt:
secret: "license-manager-cu-dev-secret-key-32-bytes" # 客户用户JWT密钥
expire_hours: 24 # 24小时后过期
refresh_threshold_minutes: 60 # 60分钟后可刷新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天内到期算即将过期
payment:
# 默认支付方式
default_method: alipay
# 支付提供商配置
providers:
alipay:
enabled: true
app_id: "your_alipay_app_id"
private_key: "your_alipay_private_key"
public_key: "alipay_public_key"
gateway_url: "https://openapi.alipay.com/gateway.do"
notify_url: "https://your-domain.com/api/payment/alipay/callback"
return_url: "https://your-domain.com/payment/success"
sign_type: "RSA2"
charset: "utf-8"
format: "json"
wechat:
enabled: false # 可动态开启
app_id: "your_wechat_app_id"
mch_id: "your_wechat_mch_id"
private_key: "your_wechat_private_key"
gateway_url: "https://api.mch.weixin.qq.com/pay/unifiedorder"
notify_url: "https://your-domain.com/api/payment/wechat/callback"
# ... 其他微信支付配置
# 通用支付配置
expire_minutes: 30 # 支付过期时间(分钟)
retry_times: 3 # 支付重试次数
enable_log: true # 启用支付日志
sms:
enabled: false # 是否启用短信服务
access_key_id: "your-access-key-id" # 阿里云AccessKey ID
access_key_secret: "your-access-key-secret" # 阿里云AccessKey Secret
sign_name: "xxxx科技" # 短信签名
region_id: "cn-hangzhou" # 地域ID
endpoint: "dysmsapi.aliyuncs.com" # 服务端点
# 模板配置(可选,不配置则使用代码中的默认值)
templates:
register: "SMS_330275014" # 注册验证码模板
reset_pwd: "SMS_330275014" # 重置密码验证码模板
login: "SMS_330275014" # 登录验证码模板
current_phone: "SMS_330275014" # 当前手机号验证码模板
new_phone: "SMS_330275014" # 新手机号验证码模板
cache:
enabled: true # 是否启用缓存
type: memory # 缓存类型: memory, redis
ttl: 30m # 默认TTL
# Redis缓存配置(当type为redis时使用)
redis:
host: localhost
port: 6379
password: ""
db: 0
pool_size: 10
min_idle_conns: 5
conn_max_idle_time: 30m
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
# 内存缓存配置(当type为memory时使用)
memory:
max_size: 10000 # 最大缓存条目数
cleanup_interval: 10m # 清理间隔
+115
View File
@@ -0,0 +1,115 @@
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
cu_jwt:
secret: "please-change-cu-jwt-secret-in-prod"
expire_hours: 24
refresh_threshold_minutes: 60
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天内到期算即将过期
payment:
# 默认支付方式
default_method: alipay
# 支付提供商配置 - 生产环境支付宝配置
providers:
alipay:
enabled: true
app_id: "your-prod-app-id"
private_key: "your-prod-private-key"
public_key: "your-prod-public-key"
gateway_url: "https://openapi.alipay.com/gateway.do"
notify_url: "https://your-domain.com/api/payment/alipay/callback"
return_url: "https://your-domain.com/payment/success"
sign_type: "RSA2"
charset: "utf-8"
format: "json"
# 通用支付配置
expire_minutes: 30 # 支付过期时间(分钟)
retry_times: 3 # 支付重试次数
enable_log: true # 启用支付日志
sms:
enabled: false # 生产环境默认禁用,需要配置阿里云AccessKey
access_key_id: "" # 请配置生产环境阿里云AccessKey ID
access_key_secret: "" # 请配置生产环境阿里云AccessKey Secret
sign_name: "xxxx科技"
region_id: "cn-hangzhou"
endpoint: "dysmsapi.aliyuncs.com"
# 模板配置(可选,不配置则使用代码中的默认值)
templates:
register: "SMS_330275014" # 注册验证码模板
reset_pwd: "SMS_330275014" # 重置密码验证码模板
login: "SMS_330275014" # 登录验证码模板
current_phone: "SMS_330275014" # 当前手机号验证码模板
new_phone: "SMS_330275014" # 新手机号验证码模板
cache:
enabled: true # 是否启用缓存
type: memory # 缓存类型: memory, redis
ttl: 30m # 默认TTL
# Redis缓存配置(当type为redis时使用)
redis:
host: localhost
port: 6379
password: ""
db: 0
pool_size: 10
min_idle_conns: 5
conn_max_idle_time: 30m
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
# 内存缓存配置(当type为memory时使用)
memory:
max_size: 10000 # 最大缓存条目数
cleanup_interval: 10m # 清理间隔
+99 -4
View File
@@ -3,18 +3,113 @@ 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"
cu_jwt:
secret: "license-manager-cu-dev-secret-key-32-bytes" # 客户用户JWT密钥
expire_hours: 24 # 24小时后过期
refresh_threshold_minutes: 60 # 60分钟后可刷新token
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天内到期算即将过期
payment:
# 默认支付方式
default_method: alipay
# 支付提供商配置 - 支付宝沙箱环境
providers:
alipay:
enabled: true
app_id: "9021000159611786"
private_key: "-----BEGIN PRIVATE KEY-----\nMIIEogIBAAKCAQEAhxBHcY3BLmA0glH1v1e9VnLFEc72ZyI5xNF77SMA5hGutz30\nSmzczjYvGJNYTeyWfIjIqtP0IA49XA61kpUPiTyBy2sxjDfu6ew+Yu2waynpI+Nbk\npIz8TWT1g7rqgZMsUexufmvFEpvn1AmboVKN8iZVflwc4lq5pQ/GS7uTlDK3fY32j\nEk6jxEHy8yZvkf467X0WK9AdrJ0IxUTOLBROdZySqg7mc2Iyo61xdDOuVCJjZSWFH\nt+s4UWx2VPFSbL2z4m82abGicAgj696J2PU9iLhQG2HA/EwrH8zsVyJPHTMgPFwng\nN9sxmT4U3OH8ugCnw/amEsD8VZR3PG3ypQIDAQABAoIBAFSs2Q2Q4H7xo5ncRmQ6Xq\nKW+4Ppq8MGyw7ggoAuBL84cL/C4KfUTi4d/DK9FYxs+f+DRcMLdK6wHrnbIuyN9Rq\nFy527S2nsGyWejZI6D/NvaBGu1Vczv8rGWzPFUOvcXTJ7jynYJnJjJE3N0vpRKDGG\n2tLyuhRFH7cFU/W23YJM7TflTUUh1SG0Ec+jUa+ek5nK/vsqGE7A5p/D+mW4WLfS4\nsGhfe1XzNKjuCznTYajG79YXHXBKljqq4SKoEdh0SQHoEy7H0BXqArjSbh4KdHYoW/\nKkvA/c5QpbaTuWdK3iuvq7bZ1I0SEckL/rzufj+OcgoKcnwxZG2/Eq1Nb8tUCgYEAw\ncuyQW1AZEVl4dbk9Y4JXFeWh82LZ9pmyvr9tmqwQP4Ywlb9zUlXy+Zbv1wa9acfRWu9\ndYGm3SByFk8c3UGb/s3+eY4MribqkNVkjdotC/+wfymyf3gdLypGEXjBGwNJregGFeF\nmUhWnS+YWOiCiH401uDTU1pRtW37DZgdD5ZMCgYEAsmqIRL4U+3bkIb8u26lgS3k2W\nImen8+i91jVc8wWD7/aWGNmjodimyOv9AJRt34y5apzzfcWuzr9qdNQrzGubtKjaVM6\ns6NNVGzUJkLjF4WpyFDmX/ZLZkTW0gQLMYAjmiEJkPlbpHQ81vSMCT7W76RB31vL8p\ndRJMGBz/dN6ecCgYATzaXsi6Z92J2do2EIOrh4KDcor3dkaGwCp/1PK73RFmYfTdZnq\nOwA2QxdFKNooOhQJr9Yh6Quioc+Al2XuoGfWxt6/moLTczAeh3pZPcfd8RoajFkVzjc\nPfylAmrxQLfBvqkIiVz0XGQZHGz/CaCnir2HklaBiu9V3zFGds2YYwKBgFuaNryUm1F\nAiYCKGBTPsZEOesiaX8bNZG56PZ1mey0642/eGRm0iPv+JqixSCNdt+QSz8I/ynv2naZ\nDyHkqmNRtcVjyUoFIprth5MqALPg2uCUQ5DcAzVWEoobRbK3NjHnl3HOCk3qr81Cp3f\nwhN1lyjB+RkfTLm+2zLGMKJgahAoGAGUigAVlCnUo6zj8PVOeUPJWV3tZf8ZkTtCXT\njdp9tPwZqf0ZlmVZCoQB3nclF/6PgxgH7W596DlUt8TIrdmgg28HM8C2rpY/AoCw1H3\ncMkha8gCUN308cx0I2duea3tvDUb8thfvR34d7bxCI2hTOsplfE/MVv+sjzprGwtH/84=\n-----END PRIVATE KEY-----"
public_key: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjkjXfe1fXjelyi6HIzFp\n5l9PGPxDBTi/TyIsRrF5NaYRCGqqSSJj1sc440eJ7InKRb4q/7cpdjlroC6DDO1O\nHKipld1pWSqoezc2lLVxvRWGt7Tl9mDfrul1/4ywx9OnIPBx3iJvzY15XDVWlZFY\nWVJFQv5EriLn+3xy+3gaazzJYCp/PumDqMTUgp0Kjk+HuHJ5/PKtFt3AtN2GB8gl\nQEybl2Wf0qNZU9SzmEm+9I7CNvB/zUC3csRxJHTvbOtkjhCC/TrS20Xm8IFjygu9\nlBf9qzacXyfHjG6fLMU8aDn4kPAXL+wbqE2UzFmVvMNCLpEaIS3r7I8S2TGQLPDD\n6QIDAQAB\n-----END PUBLIC KEY-----"
gateway_url: "https://openapi-sandbox.dl.alipaydev.com/gateway.do"
notify_url: "http://localhost:18888/api/payment/alipay/callback"
return_url: "http://localhost:18888/payment/success"
sign_type: "RSA2"
charset: "utf-8"
format: "json"
# 通用支付配置
expire_minutes: 30 # 支付过期时间(分钟)
retry_times: 3 # 支付重试次数
enable_log: true # 启用支付日志
sms:
enabled: true # 是否启用短信服务(需要配置阿里云AccessKey)
access_key_id: "xxxxxxxxxxx" # 阿里云AccessKey ID
access_key_secret: "xxxxxxxxxxxxxxxxxxx" # 阿里云AccessKey Secret
sign_name: "xxxx科技" # 短信签名
region_id: "cn-hangzhou" # 地域ID
endpoint: "dysmsapi.aliyuncs.com" # 服务端点
# 模板配置(可选,不配置则使用代码中的默认值)
templates:
register: "SMS_330275014" # 注册验证码模板
reset_pwd: "SMS_330275014" # 重置密码验证码模板
login: "SMS_330275014" # 登录验证码模板
current_phone: "SMS_330275014" # 当前手机号验证码模板
new_phone: "SMS_330275014" # 新手机号验证码模板
cache:
enabled: true # 是否启用缓存
type: memory # 缓存类型: memory, redis
ttl: 30m # 默认TTL
# Redis缓存配置(当type为redis时使用)
redis:
host: localhost
port: 6379
password: ""
db: 0
pool_size: 10
min_idle_conns: 5
conn_max_idle_time: 30m
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
# 内存缓存配置(当type为memory时使用)
memory:
max_size: 10000 # 最大缓存条目数
cleanup_interval: 10m # 清理间隔
+184
View File
@@ -0,0 +1,184 @@
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": "Request too frequent, please try again later"
"200004": "SMS sending failed"
"200005": "Customer name is required"
"200006": "Invalid customer information format"
"200007": "Failed to query customer list"
"200008": "Customer has authorization codes, cannot delete. Please delete authorizations first"
"200009": "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"
"300101": "Authorization code not found"
"300102": "Authorization code is locked"
"300103": "Share count exceeds available activations"
"300104": "Target user not found"
"300105": "Cannot share with yourself"
"300106": "Database transaction failed"
# 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"
# Customer User module (50xxxx)
cu_user:
"500001": "Invalid phone number format"
"500002": "Phone number already registered"
"500003": "Invalid phone or password"
"500004": "Forgot password failed"
"500005": "Reset password failed"
"500006": "Profile update failed"
"500007": "Phone update failed"
"500008": "Password change failed"
"500009": "User not found"
"500010": "Authentication token required"
"500011": "Invalid authentication token"
"500012": "Authentication token expired"
"500013": "Failed to generate token"
"500014": "Verification code incorrect or expired"
"500015": "Verification code does not exist"
"500016": "SMS sending too frequent, please try again later"
"500017": "SMS sending failed"
"500018": "Account locked, please try again later"
# Package order module (60xxxx)
cu_package:
"600001": "Package does not exist"
"600002": "Package is discontinued"
"600003": "Trial version purchase time limit"
"600004": "Trial version does not support bulk purchase"
"600005": "Trial version already purchased this month"
# Order module (61xxxx)
cu_order:
"601001": "Order does not exist"
"601005": "License quantity exceeds limit"
"601006": "Order has been paid and cannot be cancelled"
"601007": "Order status does not allow continue payment"
# System general (90xxxx)
system:
"900001": "Invalid request parameters"
"900002": "Resource not found"
"900003": "Resource conflict"
"900004": "Internal server error"
# Invoice module (70xxxx)
invoice:
"700001": "Invoice not found"
"700002": "Order not found or access denied"
"700003": "Invoice already exists for this order"
"700004": "Invoice status does not allow this operation"
"700005": "Invoice file not found"
# Common text
common:
success: "Success"
# 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"
"feature_limit_change": "Feature Limit Change"
"lock": "Lock"
"unlock": "Unlock"
"other": "Other"
invoice_status:
"pending": "Pending"
"issued": "Issued"
"rejected": "Rejected"
invoice_type:
"personal": "Personal"
"enterprise": "Enterprise"
"vat_special": "VAT Special Invoice"
# Default error message
default_error: "Unknown error"
+166
View File
@@ -0,0 +1,166 @@
system:
name: "日本語"
locale: "ja-JP"
errors:
# 成功状態
"000000": "成功"
# 認証モジュール (10xxxx)
auth:
"100001": "認証が期限切れです"
"100002": "無効な認証"
"100003": "ユーザー名またはパスワードが無効です"
"100004": "認証が必要です"
"100005": "権限が不足しています"
# 顧客モジュール (20xxxx)
customer:
"200001": "顧客が見つかりません"
"200002": "顧客が既に存在します"
"200003": "リクエストが頻繁すぎます。しばらくしてからもう一度お試しください"
"200004": "SMS送信に失敗しました"
"200005": "顧客名は必須です"
"200006": "無効な顧客情報形式"
"200007": "顧客リストの照会に失敗しました"
"200008": "顧客にまだ認可コードが存在します。削除する前に認可を削除してください"
"200009": "顧客が無効になっています。認可を作成できません"
# 認可モジュール (30xxxx)
authorization:
"300001": "認可コードが見つかりません"
"300002": "認可コードが既に存在します"
"300003": "認可コードがロックされています"
"300004": "最大アクティベーション数に達しました"
"300005": "無効なハードウェア指紋形式"
"300006": "ライセンスが見つかりません"
"300007": "ライセンスが取り消されました"
"300008": "ハードウェア指紋が一致しません"
"300009": "ライセンスファイルの生成に失敗しました"
"300010": "無効な設定パラメータ"
"300101": "認可コードが見つかりません"
"300102": "認可コードがロックされています"
"300103": "共有数が利用可能なアクティベーション数を超えています"
"300104": "対象ユーザーが見つかりません"
"300105": "自分自身に共有できません"
"300106": "データベーストランザクションが失敗しました"
# ダッシュボードモジュール (40xxxx)
dashboard:
"400001": "無効な時間タイプパラメータ"
"400002": "無効な開始日付形式"
"400003": "無効な終了日付形式"
"400004": "時間範囲が制限を超えています(1年以上)"
"400005": "開始日は終了日より後にできません"
"400006": "無効な制限パラメータ"
# 顧客ユーザーモジュール (50xxxx)
cu_user:
"500001": "電話番号形式が無効です"
"500002": "電話番号が既に登録されています"
"500003": "電話番号またはパスワードが無効です"
"500004": "パスワードリセットに失敗しました"
"500005": "パスワードリセットに失敗しました"
"500006": "プロフィール更新に失敗しました"
"500007": "電話番号更新に失敗しました"
"500008": "パスワード変更に失敗しました"
"500009": "ユーザーが見つかりません"
"500010": "認証トークンが必要です"
"500011": "認証トークンが無効です"
"500012": "認証トークンが期限切れです"
"500013": "トークン生成に失敗しました"
"500014": "認証コードが正しくないか期限切れです"
"500015": "認証コードが存在しません"
"500016": "SMS送信が頻繁すぎます。しばらくしてからお試しください"
"500017": "SMS送信に失敗しました"
"500018": "アカウントがロックされています。しばらくしてからお試しください"
# セット料金モジュール (60xxxx)
cu_package:
"600001": "セット料金が存在しません"
"600002": "セット料金は販売終了しました"
"600003": "試用版購入時間制限"
"600004": "試用版は一括購入をサポートしていません"
"600005": "今月試用版を購入済み"
# 注文モジュール (61xxxx)
cu_order:
"601001": "注文が存在しません"
"601005": "ライセンス数量が制限を超えています"
"601006": "支払い済みの注文はキャンセルできません"
"601007": "注文の状態により、支払いを続けることができません"
# システム共通 (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": "更新"
"feature_limit_change": "機能制限変更"
"lock": "ロック"
"unlock": "アンロック"
"other": "その他"
# デフォルトエラーメッセージ
default_error: "不明なエラー"
+188
View File
@@ -0,0 +1,188 @@
system:
name: "中文"
locale: "zh-CN"
errors:
# 成功状态
"000000": "成功"
# 认证模块 (10xxxx)
auth:
"100001": "认证已过期"
"100002": "认证无效"
"100003": "用户名或密码错误"
"100004": "缺少认证信息"
"100005": "权限不足"
# 客户模块 (20xxxx)
customer:
"200001": "客户不存在"
"200002": "客户已存在"
"200003": "请求过于频繁,请稍后再试"
"200004": "短信发送失败"
"200005": "客户名称不能为空"
"200006": "客户信息格式无效"
"200007": "客户列表查询失败"
"200008": "客户仍有授权,无法删除,请先删除授权"
"200009": "客户已停用,无法创建授权"
# 授权模块 (30xxxx)
authorization:
"300001": "授权码不存在"
"300002": "授权码已存在"
"300003": "授权码已被锁定"
"300004": "激活数量已达上限"
"300005": "硬件指纹格式错误"
"300006": "许可证不存在"
"300007": "许可证已被撤销"
"300008": "硬件指纹不匹配"
"300009": "许可证文件生成失败"
"300010": "配置参数错误"
"300101": "授权码不存在"
"300102": "授权码已被锁定"
"300103": "分享数量超过可用激活数"
"300104": "目标用户不存在"
"300105": "不能分享给自己"
"300106": "数据库事务失败"
# 仪表盘模块 (40xxxx)
dashboard:
"400001": "时间类型参数错误"
"400002": "开始日期格式错误"
"400003": "结束日期格式错误"
"400004": "时间范围超过限制(超过一年)"
"400005": "开始日期不能晚于结束日期"
"400006": "数量限制参数错误"
# 客户用户模块 (50xxxx)
cu_user:
"500001": "手机号格式错误"
"500002": "手机号已被注册"
"500003": "手机号或密码错误"
"500004": "忘记密码失败"
"500005": "重置密码失败"
"500006": "个人资料更新失败"
"500007": "手机号更新失败"
"500008": "密码修改失败"
"500009": "用户不存在"
"500010": "认证令牌缺失"
"500011": "认证令牌无效"
"500012": "认证令牌已过期"
"500013": "令牌生成失败"
"500014": "验证码错误或已过期"
"500015": "验证码不存在"
"500016": "短信发送过于频繁,请稍后再试"
"500017": "短信发送失败"
"500018": "账号已被锁定,请稍后再试"
# 套餐订单模块 (60xxxx)
cu_package:
"600001": "套餐不存在"
"600002": "套餐已下架"
"600003": "试用版购买时间限制"
"600004": "试用版不支持批量购买"
"600005": "当月已购买试用版"
# 订单模块 (61xxxx)
cu_order:
"601001": "订单不存在"
"601005": "许可数量超出限制"
"601006": "订单已支付,无法取消"
"601007": "订单状态不允许继续支付"
# 设备管理模块 (62xxxx)
cu_device:
"620001": "设备不存在或无权限访问"
# 发票模块 (70xxxx)
invoice:
"700001": "发票不存在"
"700002": "订单不存在或无权限访问"
"700003": "该订单已存在发票申请"
"700004": "发票状态不允许操作"
"700005": "发票文件不存在"
# 系统通用 (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": "日期变更"
"feature_limit_change": "功能限制变更"
"lock": "锁定"
"unlock": "解锁"
"other": "其他"
invoice_status:
"pending": "待处理"
"issued": "已开票"
"rejected": "已驳回"
invoice_type:
"personal": "个人"
"enterprise": "企业普票"
"vat_special": "增值税专用发票"
# 默认错误信息
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注解是否完整
- 避免不必要的冗余,能写到工具函数的不要写在模块内
+145 -25
View File
@@ -3,12 +3,17 @@
## 技术栈选择
Web框架: Gin
ORM: GORM
ORM: GORM,开发环境使用Auto Migration
配置: Viper
日志: logrus
认证: JWT
缓存: Redis (可选)
数据库: PostgreSQL/MySQL
日志: logrus (封装为logger包)
认证: JWT (支持管理员和C端用户双JWT体系)
缓存: Redis/内存缓存 (支持切换)
数据库: MySQL (推荐)/PostgreSQL
支付集成: 支付宝
短信服务: 阿里云SMS
加密算法: RSA
国际化: 支持多语言错误信息和枚举显示
## 目录结构
@@ -18,57 +23,172 @@
```
backend/
├── cmd/
│ └── server/
│ └── main.go # 程序入口
│ ├── main.go # 程序入口
│ ├── license_manager.sh # 启动脚本
│ ├── client-demo/ # C端客户端演示程序
│ │ ├── main.go
│ │ ├── rsa.go
│ │ ├── client_config.json
│ │ ├── README.md
│ │ └── license_code/
│ └── gen-rsa-keys/ # RSA密钥生成工具
│ └── main.go
├── internal/
│ ├── api/
│ │ ├── handlers/ # HTTP处理器
│ │ │ ├── auth.go
│ │ │ ├── customer.go
│ │ │ ├── license.go
│ │ │ └── system.go
│ │ │ ├── auth_handler.go
│ │ │ ├── authorization_code_handler.go
│ │ │ ├── customer_handler.go
│ │ │ ├── dashboard_handler.go
│ │ │ ├── enum_handler.go
│ │ │ ├── license_handler.go
│ │ │ ├── payment_handler.go
│ │ │ ├── system_handler.go
│ │ │ ├── cu_auth_handler.go
│ │ │ ├── cu_authorization_handler.go
│ │ │ ├── cu_device_handler.go
│ │ │ ├── cu_invoice_handler.go # 客户端发票处理器
│ │ │ ├── cu_order_handler.go
│ │ │ ├── cu_profile_handler.go
│ │ │ └── invoice_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
│ │ ├── dashboard_service.go
│ │ ├── enum_service.go
│ │ ├── license_service.go
│ │ ├── payment_service.go
│ │ ├── system_service.go
│ │ ├── admin_invoice_service.go # 管理端发票服务
│ │ ├── cu_device_service.go
│ │ ├── cu_invoice_service.go # 客户端发票服务
│ │ ├── cu_order_filters.go # 订单过滤器
│ │ ├── cu_order_filters_test.go # 订单过滤器测试
│ │ ├── cu_order_service.go
│ │ ├── cu_user_service.go
│ │ └── interfaces.go # 接口定义
│ ├── repository/ # 数据访问层
│ │ ├── customer_repo.go
│ │ ├── license_repo.go
│ │ ├── admin_invoice_repository.go # 管理端发票仓储
│ │ ├── authorization_code_repository.go
│ │ ├── cu_invoice_repository.go # 客户端发票仓储
│ │ ├── cu_order_repository.go
│ │ ├── cu_user_repository.go
│ │ ├── customer_repository.go
│ │ ├── dashboard_repository.go
│ │ ├── license_repository.go
│ │ ├── payment_repository.go
│ │ ├── user_repository.go
│ │ ├── gorm/ # GORM相关扩展
│ │ ├── errors.go
│ │ └── interfaces.go
│ ├── models/ # 数据模型
│ │ ├── admin_invoice_models.go # 管理端发票模型
│ │ ├── auth.go
│ │ ├── common.go
│ │ ├── cu_authorization.go
│ │ ├── cu_invoice_models.go # 客户端发票模型
│ │ ├── cu_order.go
│ │ ├── cu_user.go
│ │ ├── customer.go
│ │ ├── dashboard.go
│ │ ├── license.go
│ │ └── common.go
│ │ ├── payment.go
│ │ ├── shared_invoice_models.go # 共享发票模型
│ │ ├── system.go
│ │ └── user.go
│ ├── config/ # 配置管理
│ │ └── config.go
│ └── cache/ # 缓存层
│ ├── memory.go
│ └── redis.go
│ └── database/ # 数据库连接和迁移
│ ├── connection.go
│ └── migration.go
├── pkg/
│ ├── cache/ # 缓存系统
│ │ ├── cache.go
│ │ ├── factory.go
│ │ ├── memory.go
│ │ ├── redis.go
│ │ └── key_builder.go
│ ├── context/ # 上下文封装
│ │ └── context.go
│ ├── i18n/ # 国际化
│ │ ├── errors.go
│ │ ├── manager.go
│ │ └── messages/
│ ├── utils/ # 工具函数
│ │ ├── crypto.go
│ │ ├── hardware.go
│ │ └── validator.go
│ │ ├── alipay.go # 支付宝支付集成
│ │ ├── authorization_code.go # 授权码生成
│ │ ├── crypto.go # 加密工具
│ │ ├── cu_jwt.go # C端用户JWT工具
│ │ ├── jwt.go # JWT工具
│ │ └── sms.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
│ ├── 007_create_cu_users_table.sql
│ ├── 008_create_cu_orders_table.sql
│ ├── 009_update_authorization_codes_code_length.sql
│ ├── 010_update_cu_orders_authorization_code_length.sql
│ ├── 011_create_payments_table.sql
│ ├── 012_create_invoices_table.sql
│ └── README.md
├── go.mod
└── go.sum
```
**核心设计原则:**
- **分层架构**:Handler → Service → Repository
- **依赖注入**:通过接口解耦各层
- **配置驱动**:Viper管理所有配置
- **缓存抽象**:支持内存/Redis切换
- **缓存抽象**:支持内存/Redis切换,带过期时间和键构建器
- **双JWT体系**:支持管理员和C端用户独立认证体系
- **客户端/管理端分离**:采用cu_*前缀严格分离不同用户角色的业务逻辑
- **模块化设计**:支持客户管理、订单管理、支付集成等独立模块
- **国际化支持**:完整的多语言错误信息和枚举显示系统
- **软删除支持**:所有数据表支持gorm.DeletedAt软删除
- **SQL迁移**:复杂的索引和约束通过SQL文件管理,保证生产环境一致性
@@ -0,0 +1,16 @@
# 令牌刷新
目前系统有一个超管端和一个用户端,需要对两个端进行令牌刷新机制的改造。
Token过期与刷新机制
Token刷新是为了在保证安全性的同时提升用户体验,避免频繁登录。核心是使用短期访问令牌(Access Token) + 长期刷新令牌(Refresh Token) 的双令牌策略。
基本流程
1. 用户登录
用户提交账号密码 → 后端验证 → 返回两个token:
- Access Token (短期,如15分钟-2小时)
- Refresh Token (长期,如7-30天)
2. 日常请求
前端携带Access Token → 后端验证 → 返回数据
3. Token过期处理
Access Token过期 → 请求失败(401) → 前端自动用Refresh Token换新的Access Token → 重试原请求
@@ -0,0 +1,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,33 @@
# 管理端企业线索汇总接口设计
## 接口路径
`GET /api/leads/summary`
## 请求
不需要任何参数。
## 鉴权
需要管理端权限(与线索列表/详情接口一致)。
## 响应示例
```json
{
"code": "000000",
"message": "success",
"data": {
"total_count": 1200,
"pending_count": 300,
"following_count": 520,
"converted_count": 260,
"lost_count": 120
},
"timestamp": 1738454400
}
```
## 字段说明
- total_count:线索总数
- pending_count:待跟进数量
- following_count:已跟进数量
- converted_count:已成交数量
- lost_count:已流失数量
@@ -0,0 +1,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` - 数量限制参数错误
@@ -0,0 +1,179 @@
# 用户端设备汇总接口设计文档
## 需求概述
提供用户端设备汇总统计接口,返回当前登录客户的设备总数、在线设备数、离线设备数。
## 接口路径
```
GET /api/cu/devices/summary
```
## 认证要求
- 需要 Bearer Token 认证(C端用户JWT)
- 基于当前登录用户的客户ID进行统计
## 请求参数
无查询参数
## 响应数据结构
### 成功响应
```json
{
"code": "000000",
"message": "成功",
"data": {
"total_devices": 100,
"online_devices": 85,
"offline_devices": 15
}
}
```
### 响应字段说明
| 字段 | 类型 | 必填 | 描述 |
|------|------|------|------|
| total_devices | int64 | 是 | 设备总数(活跃许可证数) |
| online_devices | int64 | 是 | 在线设备数 |
| offline_devices | int64 | 是 | 离线设备数 |
### 在线状态判断逻辑
参考现有代码实现:
- 通过 `last_heartbeat` 时间戳判断设备是否在线
- 心跳超时时间从配置获取,默认5分钟(300秒)
- 如果 `last_heartbeat + heartbeat_timeout > 当前时间`,则设备在线
### 业务规则
- 统计范围:当前登录用户的客户ID下的所有活跃设备(`status = 'active'` 且 `deleted_at IS NULL`)
- 权限控制:只能查看自己客户ID下的设备数据
- 数据一致性:`total_devices = online_devices + offline_devices`
## 错误响应
| 错误码 | HTTP状态码 | 描述 |
|--------|------------|------|
| 500011 | 401 | 认证令牌无效 |
| 500012 | 401 | 认证令牌已过期 |
| 500010 | 401 | 认证令牌缺失 |
| 900004 | 500 | 服务器内部错误 |
## Swagger 文档
```go
// GetDeviceSummary 获取设备汇总统计
// @Summary 获取设备汇总统计
// @Description 获取当前登录用户的设备汇总统计信息(总数、在线数、离线数)
// @Tags 客户设备管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.DeviceSummaryResponse} "获取成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/devices/summary [get]
```
## 技术实现方案
### 1. 新增响应模型(models/license.go)
```go
// DeviceSummaryResponse 设备汇总响应结构
type DeviceSummaryResponse struct {
TotalDevices int64 `json:"total_devices"` // 设备总数
OnlineDevices int64 `json:"online_devices"` // 在线设备数
OfflineDevices int64 `json:"offline_devices"` // 离线设备数
}
```
### 2. 服务层接口扩展(service/interfaces.go)
在 `CuDeviceService` 接口中添加:
```go
// 获取设备汇总统计
GetDeviceSummary(ctx context.Context, customerID string) (*models.DeviceSummaryResponse, error)
```
### 3. Repository层接口扩展(repository/interfaces.go)
在 `LicenseRepository` 接口中添加:
```go
// GetCustomerDeviceSummary 获取客户设备汇总统计
GetCustomerDeviceSummary(ctx context.Context, customerID string) (*models.DeviceSummaryResponse, error)
```
### 4. 处理器扩展(handlers/cu_device_handler.go)
添加新的处理器方法:
```go
func (h *CuDeviceHandler) GetDeviceSummary(c *gin.Context) {
// 从JWT Token中获取用户ID和客户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
// 调用服务层
result, err := h.cuDeviceService.GetDeviceSummary(c.Request.Context(), claims.CustomerID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: result,
})
}
```
### 5. 路由配置(routes/router.go)
在 cuAuth 路由组中添加:
```go
cuAuth.GET("/devices/summary", cuDeviceHandler.GetDeviceSummary)
```
## 数据库查询逻辑
基于现有 `licenses` 表结构,统计逻辑:
1. **设备总数**:查询 `customer_id = ? AND status = 'active' AND deleted_at IS NULL` 的记录数
2. **在线设备数**:在总数基础上,筛选 `last_heartbeat > (NOW() - INTERVAL heartbeat_timeout SECOND)` 的记录数
3. **离线设备数**:总数减去在线设备数
## 性能优化建议
- 建议对该接口使用缓存,避免频繁的数据库查询
- 可以使用 Redis 缓存,缓存时间建议为 1-5 分钟
- 缓存键设计:`device_summary:{customer_id}`
@@ -0,0 +1,167 @@
# 用户订单汇总接口设计文档
## 需求概述
提供用户订单汇总统计接口,返回当前登录客户的订单总数、待支付订单数、已支付订单数。
## 接口路径
```
GET /api/cu/orders/summary
```
## 认证要求
- 需要 Bearer Token 认证(C端用户JWT)
- 基于当前登录用户的客户ID进行统计
## 请求参数
无查询参数
## 响应数据结构
### 成功响应
```json
{
"code": "000000",
"message": "成功",
"data": {
"total_orders": 100,
"pending_orders": 15,
"paid_orders": 85
}
}
```
### 响应字段说明
| 字段 | 类型 | 必填 | 描述 |
|------|------|------|------|
| total_orders | int64 | 是 | 订单总数 |
| pending_orders | int64 | 是 | 待支付订单数 |
| paid_orders | int64 | 是 | 已支付订单数 |
## 业务规则
- 统计范围:当前登录用户的客户ID下的所有订单(包含已删除订单)
- 订单状态:
- `pending`: 待支付订单
- `paid`: 已支付订单
- 数据一致性:`total_orders = pending_orders + paid_orders`
## 错误响应
| 错误码 | HTTP状态码 | 描述 |
|--------|------------|------|
| 500011 | 401 | 认证令牌无效 |
| 500012 | 401 | 认证令牌已过期 |
| 500010 | 401 | 认证令牌缺失 |
| 900004 | 500 | 服务器内部错误 |
## Swagger 文档
```go
// GetOrderSummary 获取订单汇总统计
// @Summary 获取订单汇总统计
// @Description 获取当前登录用户的订单汇总统计信息(总数、待支付数、已支付数)
// @Tags 客户订单管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.OrderSummaryResponse} "获取成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/orders/summary [get]
```
## 技术实现方案
### 1. 新增响应模型(models/cu_order.go)
```go
// OrderSummaryResponse 订单汇总响应结构
type OrderSummaryResponse struct {
TotalOrders int64 `json:"total_orders"` // 订单总数
PendingOrders int64 `json:"pending_orders"` // 待支付订单数
PaidOrders int64 `json:"paid_orders"` // 已支付订单数
}
```
### 2. 服务层接口扩展(service/interfaces.go)
在 `CuOrderService` 接口中添加:
```go
// 获取订单汇总统计
GetOrderSummary(ctx context.Context, customerID string) (*models.OrderSummaryResponse, error)
```
### 3. Repository层接口扩展(repository/interfaces.go)
在 `CuOrderRepository` 接口中添加:
```go
// GetCustomerOrderSummary 获取客户订单汇总统计
GetCustomerOrderSummary(ctx context.Context, customerID string) (*models.OrderSummaryResponse, error)
```
### 4. 处理器扩展(handlers/cu_order_handler.go)
添加新的处理器方法:
```go
func (h *CuOrderHandler) GetOrderSummary(c *gin.Context) {
// 从JWT Token中获取用户ID和客户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
// 调用服务层
result, err := h.cuOrderService.GetOrderSummary(c.Request.Context(), claims.CustomerID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: result,
})
}
```
### 5. 路由配置(routes/router.go)
在 cuAuth 路由组中添加:
```go
cuAuth.GET("/orders/summary", cuOrderHandler.GetOrderSummary)
```
## 数据库查询逻辑
基于现有 `cu_orders` 表结构,统计逻辑:
1. **订单总数**:查询 `customer_id = ?` 的记录数(包含所有状态)
2. **待支付订单数**:查询 `customer_id = ? AND status = 'pending'` 的记录数
3. **已支付订单数**:查询 `customer_id = ? AND status = 'paid'` 的记录数
@@ -0,0 +1,45 @@
# 订单号查询增加筛选
改造接口:`GET /api/cu/orders`
## 背景
当前订单列表仅支持分页查询,需要增加筛选能力以提升定位订单效率。
## 目标
在现有订单列表接口基础上,增加 3 个可选筛选字段:`search`、`status`、`time`,并保持向后兼容(不传筛选参数时行为不变)。
## 接口定义
### 请求
`GET /api/cu/orders`
#### Query 参数
- `page`:页码,默认 `1`
- `page_size`:每页数量,默认 `10`,最大 `100`
- `search`:模糊匹配关键字(订单号 `order_no` 或订单授权码 `authorization_code`)
- `status`:订单状态筛选(等值匹配)
- 目前系统内常见值:`pending` / `paid` / `cancelled`
- `time`:创建时间筛选(按订单 `created_at`)
- 支持值(推荐):`today` / `week` / `month` / `three_months`
- 支持中文别名:`今天` / `本周` / `本月` / `近三个月`
#### 过滤规则
- `search`:`order_no LIKE %search% OR authorization_code LIKE %search%`
- `status`:`status = ?`
- `time`:对 `created_at` 增加范围过滤(左闭右开:`>= start AND < end`)
- `today/今天`:当天 00:00:00 ~ 次日 00:00:00
- `week/本周`:以周一为一周起始
- `month/本月`:当月 1 日 00:00:00 ~ 下月 1 日 00:00:00
- `three_months/近三个月`:从当前时间往前推 3 个月(按日期对齐到 00:00:00)~ 次日 00:00:00
### 响应
保持现有返回结构不变:`models.CuOrderListResponse`
## 示例
- 订单号/授权码模糊搜索:
- `GET /api/cu/orders?page=1&page_size=10&search=ORD20260118`
- 按状态筛选:
- `GET /api/cu/orders?status=pending`
- 按时间筛选(近三个月):
- `GET /api/cu/orders?time=three_months`
- 组合筛选:
- `GET /api/cu/orders?search=ORD&status=paid&time=month`
@@ -0,0 +1,95 @@
# 取消订单接口
## 背景
当前客户侧订单支持创建、查询与列表查看(`/api/cu/orders`),但缺少“取消未支付订单”的能力;用户误下单/重复下单时只能等待支付超时,体验较差。
## 目标
- 客户用户可取消“未支付(`pending`)”订单。
- 已支付(`paid`)订单不允许取消,返回明确的业务错误码与错误信息。
- 返回结构与现有接口保持一致:成功返回 `models.APIResponse`,失败返回 `models.ErrorResponse`。
## 参考接口(已实现)
为保持风格一致,本接口沿用以下已实现接口的路径风格、鉴权方式与返回结构:
- `POST /api/cu/orders`:创建订单
- `GET /api/cu/orders/{order_id}`:订单详情
- `GET /api/cu/orders`:订单列表(含 `status` 筛选)
## 业务规则(与当前项目对齐)
> 说明:当前 `cu_orders` 表包含 `deleted_at`(GORM 软删除),但订单列表接口按 `status` 做筛选;为了支持“已取消订单可追溯/可筛选”,本设计采用**状态取消**(`status=cancelled`)而不是直接删除记录。
- 允许取消:订单属于当前登录客户用户,且订单状态为 `pending`。
- 不允许取消:订单状态为 `paid`(包含试用/免费单,当前实现中免费单直接为 `paid`)。
- 幂等性:重复取消同一订单(已为 `cancelled`)应返回成功(不重复变更),避免前端重复点击导致报错。
## 接口定义
### 请求
```http
PUT /api/cu/orders/{order_id}/cancel
```
#### Headers
- `Authorization: Bearer <token>`(必填)
- `Accept-Language`(可选)或 query `?lang=zh-CN`(可选):与现有多语言中间件一致
#### Path 参数
- `order_id`:订单ID(必填,字符串,UUID)
#### Body
无
### 响应
#### 成功(200)
返回取消后的订单信息(与 `GET /api/cu/orders/{order_id}` 一致):
```json
{
"code": "000000",
"message": "成功",
"data": {
"id": "uuid-string",
"order_no": "ORD202601210001",
"customer_id": "customer-uuid",
"cu_user_id": "cu-user-uuid",
"package_id": "basic",
"package_name": "基础版",
"license_count": 10,
"unit_price": 300,
"discount_rate": 1,
"total_amount": 3000,
"status": "cancelled",
"authorization_code": null,
"expired_at": null,
"created_at": "2026-01-21T10:00:00Z",
"updated_at": "2026-01-21T10:05:00Z"
},
"timestamp": "2026-01-21T10:05:00Z"
}
```
#### 失败
失败时返回 `models.ErrorResponse`:
```json
{
"code": "601006",
"message": "订单已支付,无法取消",
"timestamp": "2026-01-21T10:05:00Z"
}
```
## 状态约定
- `pending`:待支付(允许取消)
- `paid`:已支付(不允许取消)
- `cancelled`:已取消(重复取消返回成功)
## 错误码(建议新增/复用)
- `100004`:缺少认证信息(未登录)
- `100005`:权限不足(只能取消自己的订单)
- `601001`:订单不存在
- `601006`:订单已支付,无法取消(建议新增到 `backend/configs/i18n/errors/*` 的 `cu_order` 下)
- `900001`:请求参数无效(如 `order_id` 为空)
- `900004`:服务器内部错误
## 示例
- 取消订单:
- `PUT /api/cu/orders/7b8c2c2e-9f2f-4f0b-9f31-0c8d3f6a9c11/cancel`
@@ -0,0 +1,123 @@
# 用户授权管理相关接口
> 分组建议:放到“用户端授权码管理”(Customer User / CU)分组,统一前缀:`/api/cu`
## 通用约定
- 鉴权:`Authorization: Bearer <token>`(必填)
- 多语言:`Accept-Language` header 或 `?lang=zh-CN`(可选)
- 成功返回:`models.APIResponse`
- 失败返回:`models.ErrorResponse`
## 参考接口(已实现)
为保持接口风格一致(路径、鉴权、返回结构),本设计参考并沿用以下已实现接口:
- `POST /api/cu/authorization-codes/{codeId}/share`:用户分享授权码
- `POST /api/cu/authorization-codes/product-activation-code`:获取产品激活码
- `GET /api/v1/authorization-codes`:授权码列表(管理端,分页结构可复用)
## 状态约定
授权码状态沿用系统枚举:
- `normal`:有效(当前时间未超过 `end_date` 且未锁定)
- `expired`:已过期(当前时间超过 `end_date`)
- `locked`:已锁定(`is_locked=true`;是否过期由 `end_date` 决定)
> 前端展示“有效/已过期”时,可将 `locked` 作为“有效但锁定”或单独状态展示(以 UI 为准)。
## 接口定义
### 1) 获取用户授权码列表(新增)
```http
GET /api/cu/authorization-codes
```
#### Query 参数
- `page`:页码,默认 `1`
- `page_size`:每页数量,默认 `10`,最大 `100`
- `status`:状态筛选(可选)`normal` / `expired` / `locked`
- `search`:授权码模糊匹配(可选,匹配 `code`)
#### 响应(200)
```json
{
"code": "000000",
"message": "成功",
"data": {
"list": [
{
"id": "uuid-string",
"code": "LIC-COMP001-A7B9X2-C8F4",
"status": "normal",
"status_display": "正常",
"max_activations": 10,
"current_activations": 7,
"remaining_activations": 3,
"created_at": "2026-01-21T10:00:00Z",
"end_date": "2026-12-31T23:59:59Z"
}
],
"total": 1,
"page": 1,
"page_size": 10,
"total_pages": 1
},
"timestamp": "2026-01-21T10:05:00Z"
}
```
#### 字段说明
- `max_activations`:设备总数(可绑定/可激活上限)
- `current_activations`:已绑定设备数(已激活许可证数量)
- `remaining_activations`:剩余可绑定设备数(建议字段;也可由前端计算 `max_activations - current_activations`)
- `end_date`:到期时间(授权结束时间)
#### 错误码
- `100004`:缺少认证信息
- `900001`:请求参数无效
- `900004`:服务器内部错误
---
### 2) 用户授权信息统计(新增)
```http
GET /api/cu/authorization-codes/summary
```
#### 响应(200)
```json
{
"code": "000000",
"message": "成功",
"data": {
"total_count": 12,
"expired_count": 3,
"valid_count": 9,
"valid_max_activations_sum": 150
},
"timestamp": "2026-01-21T10:05:00Z"
}
```
#### 字段说明
- `total_count`:总授权码数量
- `expired_count`:已过期授权码数量
- `valid_count`:有效授权码数量(建议口径:`status=normal`,是否包含 `locked` 由产品定义;若包含请在实现中统一)
- `valid_max_activations_sum`:有效授权码的 `max_activations` 之和
#### 错误码
- `100004`:缺少认证信息
- `900004`:服务器内部错误
---
### 3) 用户分享授权码(已实现)
```http
POST /api/cu/authorization-codes/{codeId}/share
```
> 说明:接口实现与 Swagger 注释已存在于 `backend/internal/api/handlers/cu_authorization_handler.go`。
---
### 4) 获取产品激活码(已实现)
```http
POST /api/cu/authorization-codes/product-activation-code
```
> 说明:接口实现与 Swagger 注释已存在于 `backend/internal/api/handlers/cu_authorization_handler.go`。
@@ -0,0 +1,12 @@
# 管理端用户管理相关接口设计
这两接口都属于管理端接口 不属于用户端接口
## 用户列表分页查询
系统管理员可以查看用户列表,并进行分页查询。
## 进入用户端
系统管理员可以以直接登录的方式进入用户端
接口和用户登录接口一样可以返回token
@@ -0,0 +1,145 @@
# 套餐管理需求
管理员端可以配置套餐,用户端从数据库查询套餐列表。
## 表设计
```sql
-- 套餐表
CREATE TABLE packages (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
name VARCHAR(100) NOT NULL COMMENT '套餐名称',
type VARCHAR(20) NOT NULL COMMENT '套餐类型: trial-试用版, basic-基础版, professional-专业版, custom-定制版',
price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '价格',
price_description VARCHAR(100) DEFAULT '' COMMENT '价格描述,如"定制报价"',
duration_description VARCHAR(200) DEFAULT '' COMMENT '期限描述,如"永久有效"、"当月25日到期"',
description VARCHAR(500) DEFAULT '' COMMENT '套餐说明',
features TEXT DEFAULT '' COMMENT '功能项,JSON格式: ["功能1", "功能2"]',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 1-启用, 0-禁用',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序,数字越大越靠前',
remark VARCHAR(500) DEFAULT '' COMMENT '备注',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME NULL,
INDEX idx_packages_type (type),
INDEX idx_packages_status (status),
INDEX idx_packages_sort_order (sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='产品套餐表';
```
## 接口设计
### 管理端接口
#### 1. 获取套餐列表
```
GET /api/packages
```
**请求参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| type | string | 否 | 套餐类型筛选 |
| status | int | 否 | 状态筛选,1-启用,0-禁用 |
**响应:**
```json
{
"code": "000000",
"data": [
{
"id": "uuid",
"name": "试用版",
"type": "trial",
"price": 0,
"price_description": "免费",
"duration_description": "当月25日到期",
"description": "免费体验全部功能",
"features": ["全部功能", "1个许可"],
"status": 1,
"sort_order": 1
}
]
}
```
#### 2. 创建套餐
```
POST /api/packages
```
**请求体:**
```json
{
"name": "试用版",
"type": "trial",
"price": 0,
"price_description": "免费",
"duration_description": "当月25日到期",
"description": "免费体验全部功能",
"features": ["全部功能", "1个许可"],
"status": 1,
"sort_order": 1,
"remark": ""
}
```
#### 3. 更新套餐
```
PUT /api/packages/{id}
```
**请求体:** 同创建接口
#### 4. 删除套餐
```
DELETE /api/packages/{id}
```
### 用户端接口
#### 获取套餐列表
```
GET /api/cu/packages
```
**说明:** 只返回启用状态的套餐,按 sort_order 排序,保持原有返回结构不变
**响应:** 与原有接口一致
```json
{
"code": "000000",
"data": [
{
"id": "trial",
"name": "试用版",
"type": "trial",
"price": 0,
"max_devices": 1,
"description": "免费体验全部功能",
"features": ["全部功能", "1个许可"],
"details": "包含全部功能|试用期限制|1个许可等"
}
]
}
```
## 套餐类型
| 类型 | 名称 | 说明 |
|------|------|------|
| trial | 试用版 | 免费,当月25日到期 |
| basic | 基础版 | 付费,永久有效 |
| professional | 专业版 | 付费,永久有效,全部功能 |
| custom | 定制版 | 定制报价,按需配置 |
## 实现文件
### 新建文件
- `backend/internal/models/package.go` - 套餐模型
- `backend/internal/repository/package_repository.go` - 套餐仓储
- `backend/internal/service/package_service.go` - 套餐服务
- `backend/internal/api/handlers/package_handler.go` - 管理端套餐处理器
- `backend/migrations/013_create_packages_table.sql` - 数据库迁移
### 修改文件
- `backend/internal/api/routes/router.go` - 添加路由
- `backend/internal/api/handlers/cu_order_handler.go` - 修改 GetPackages 从数据库查询
- `backend/internal/service/cu_order_service.go` - 修改 CalculatePrice 支持数据库套餐
@@ -0,0 +1,210 @@
# 企业线索收集
从各个渠道收集用户线索,并进行跟进和转化。
## 表设计
```sql
-- 企业线索表
CREATE TABLE leads (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
lead_no VARCHAR(50) NOT NULL COMMENT '线索编号,系统生成',
company_name VARCHAR(200) NOT NULL COMMENT '企业/机构名称',
contact_name VARCHAR(100) NOT NULL COMMENT '联系人',
contact_phone VARCHAR(20) NOT NULL COMMENT '联系电话',
contact_email VARCHAR(100) COMMENT '邮箱地址',
requirement TEXT NOT NULL COMMENT '需求描述',
extra_info TEXT COMMENT '补充信息',
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '状态: pending-待联系, contacted-已联系, converted-已成交, invalid-已失效',
follow_up_date DATETIME COMMENT '跟进日期',
follow_up_record TEXT COMMENT '跟进记录',
internal_note TEXT COMMENT '内部备注',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
INDEX idx_leads_status (status),
INDEX idx_leads_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='企业线索表';
```
## 字段说明
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| id | VARCHAR(36) | 是 | 主键,UUID |
| lead_no | VARCHAR(50) | 是 | 线索编号,系统生成,格式:LEAD + 时间戳 + 6位随机数 |
| company_name | VARCHAR(200) | 是 | 企业/机构名称 |
| contact_name | VARCHAR(100) | 是 | 联系人姓名 |
| contact_phone | VARCHAR(20) | 是 | 联系电话 |
| contact_email | VARCHAR(100) | 否 | 邮箱地址 |
| requirement | TEXT | 是 | 需求描述 |
| extra_info | TEXT | 否 | 补充信息 |
| status | VARCHAR(20) | 是 | 状态:pending-待联系,contacted-已联系,converted-已成交,invalid-已失效 |
| follow_up_date | DATETIME | 否 | 跟进日期 |
| follow_up_record | TEXT | 否 | 跟进记录 |
| internal_note | TEXT | 否 | 内部备注 |
| created_at | DATETIME | 是 | 创建时间 |
| updated_at | DATETIME | 是 | 更新时间 |
## 状态流转
```
pending (待联系) → contacted (已联系) → converted (已成交)
↓
invalid (已失效)
```
## 接口设计
### 1. 新增线索(公开接口)
```
POST /api/leads
```
**无需鉴权**
**请求体:**
```json
{
"company_name": "北京科技有限公司",
"contact_name": "张三",
"contact_phone": "13800138000",
"contact_email": "zhangsan@example.com",
"requirement": "需要采购50套软件授权",
"extra_info": "希望了解批量购买优惠"
}
```
**响应:**
```json
{
"code": "000000",
"data": {
"id": "uuid",
"lead_no": "LEAD20260201123456ABC123",
"company_name": "北京科技有限公司",
"contact_name": "张三",
"contact_phone": "13800138000",
"contact_email": "zhangsan@example.com",
"requirement": "需要采购50套软件授权",
"status": "pending",
"created_at": "2026-02-01T12:00:00Z"
}
}
```
### 2. 线索列表(管理端)
```
GET /api/leads
```
**需要管理员鉴权**
**请求参数:**
| 参数 | 类型 | 必填 | 说明 |
|------|------|------|------|
| page | int | 否 | 页码,默认1 |
| page_size | int | 否 | 每页数量,默认10,最大100 |
| search | string | 否 | 关键词检索(企业名称/联系人/联系电话) |
| status | string | 否 | 状态筛选:pending/contacted/converted/invalid |
**响应:**
```json
{
"code": "000000",
"data": {
"leads": [
{
"id": "uuid",
"lead_no": "LEAD20260201123456ABC123",
"company_name": "北京科技有限公司",
"contact_name": "张三",
"contact_phone": "13800138000",
"status": "pending",
"created_at": "2026-02-01T12:00:00Z"
}
],
"total_count": 100,
"page": 1,
"page_size": 10
}
}
```
### 3. 线索详情(管理端)
```
GET /api/leads/{id}
```
**需要管理员鉴权**
**响应:**
```json
{
"code": "000000",
"data": {
"id": "uuid",
"lead_no": "LEAD20260201123456ABC123",
"company_name": "北京科技有限公司",
"contact_name": "张三",
"contact_phone": "13800138000",
"contact_email": "zhangsan@example.com",
"requirement": "需要采购50套软件授权",
"extra_info": "希望了解批量购买优惠",
"status": "contacted",
"follow_up_date": "2026-02-05T10:00:00Z",
"follow_up_record": "已电话沟通,客户表示下周决策",
"internal_note": "重点跟进客户",
"created_at": "2026-02-01T12:00:00Z",
"updated_at": "2026-02-05T10:00:00Z"
}
}
```
### 4. 线索跟进(管理端)
```
PUT /api/leads/{id}
```
**需要管理员鉴权**
**请求体:**
```json
{
"status": "contacted",
"follow_up_date": "2026-02-05T10:00:00Z",
"follow_up_record": "已电话沟通,客户表示下周决策",
"internal_note": "重点跟进客户"
}
```
**响应:**
```json
{
"code": "000000",
"message": "成功"
}
```
### 5. 删除线索(管理端)
```
DELETE /api/leads/{id}
```
**需要管理员鉴权**
**响应:**
```json
{
"code": "000000",
"message": "成功"
}
```
## 实现文件
### 新建文件
- `backend/internal/models/lead.go` - 线索模型
- `backend/internal/repository/lead_repository.go` - 线索仓储
- `backend/internal/service/lead_service.go` - 线索服务
- `backend/internal/api/handlers/lead_handler.go` - 线索处理器
- `backend/migrations/014_create_leads_table.sql` - 数据库迁移
### 修改文件
- `backend/internal/api/routes/router.go` - 添加路由
- `backend/internal/database/migration.go` - 添加自动迁移和初始化
@@ -0,0 +1,26 @@
# 订单列表查询接口修改
## 接口说明
- **接口路径**:`GET /api/cu/orders`
- **修改内容**:在订单列表的每条订单数据中新增一个字段,表示该订单是否已经申请过发票。
## 新增字段
- **字段名**:`has_invoice_applied`
- **类型**:`boolean`
- **含义**:
- `true`:该订单在发票表中已经有发票申请记录
- `false`:该订单还没有发票申请记录
## 判断规则
- 相关表:`invoices`
- 判断条件:
- 在 `invoices` 表中存在 `order_id = cu_orders.id` 且 `deleted_at IS NULL` 的记录,则 `has_invoice_applied = true`
- 否则 `has_invoice_applied = false`
## 前端使用建议
- 当 `has_invoice_applied = true` 时,前端可以隐藏或禁用“申请开票”按钮。
- 当 `has_invoice_applied = false` 时,前端正常展示“申请开票”按钮。
@@ -0,0 +1,80 @@
# 2026.2.24-用户端发票申请修改
## 1. 需求背景
目前用户申请发票后,如果被管理员驳回(状态为 `rejected`),用户无法修改该申请信息并重新提交。由于每个订单只能关联一个发票记录,用户也无法为该订单重新发起新的申请。因此需要增加一个发票修改接口,允许用户在驳回状态下编辑发票信息并重新进入待处理状态。
## 2. 业务流程
1. **用户侧**:查看发票详情,发现状态为“已驳回”,查看“驳回原因”和“修改建议”。
2. **用户侧**:点击修改,编辑发票抬头、税号、邮箱等信息。
3. **提交修改**:调用修改接口。
- 系统校验发票当前状态必须为 `rejected`。
- 系统校验发票所属权。
- 系统更新发票信息,并将状态重置为 `pending`。
- 系统清空原有的驳回信息(可选,或保留历史但在界面不再作为当前驳回提示)。
4. **管理侧**:发票重新出现在待处理列表中。
## 3. 接口设计
### 3.1 修改发票申请 (客户端)
- **接口地址**:`PUT /api/cu/invoices/{id}`
- **鉴权**:需要客户端登录态 (`CustomerAuth`)
- **请求参数 (JSON Body)**:
| 字段名 | 类型 | 必填 | 说明 |
| :--- | :--- | :--- | :--- |
| invoice_type | string | 是 | 发票类型: personal/enterprise/vat_special |
| receiver_email| string | 是 | 收票邮箱 |
| title | string | 是 | 发票抬头 |
| taxpayer_id | string | 否 | 纳税人识别号 (企业类型必填) |
| content | string | 否 | 开票内容 |
| remark | string | 否 | 备注 |
> [!NOTE]
> `order_id` 不允许修改,因为发票记录与订单是一一绑定的。
- **逻辑校验**:
1. **存在性校验**:发票 ID 必须存在。
2. **权限校验**:发票必须属于当前登录的客户用户 (`cu_user_id`)。
3. **状态校验**:只有 `status = 'rejected'` 的发票允许修改。若状态为 `pending` 或 `issued`,则返回错误。
4. **字段校验**:与创建发票时的校验逻辑一致(如企业类型必须有税号)。
- **数据更新**:
- 更新传入的业务字段。
- `status` 更新为 `pending`。
- `updated_at` 更新为当前时间。
- `reject_reason`、`suggestion`、`rejected_at`、`rejected_by` 建议清空,以表示这是一次全新的提交。
### 3.2 响应示例
**成功响应**:
```json
{
"code": "000000",
"message": "成功",
"data": {
"id": "invoice-uuid",
"invoice_no": "INV20260224001",
"status": "pending"
},
"timestamp": "2026-02-24T18:00:00Z"
}
```
**失败响应 (状态错误)**:
```json
{
"code": "700004",
"message": "发票当前状态不允许该操作",
"timestamp": "2026-02-24T18:00:00Z"
}
```
## 4. 影响范围
- **后端模型**:需新增 `InvoiceUpdateRequest` 结构体。
- **后端服务**:`CuInvoiceService` 新增 `UpdateInvoice` 方法。
- **后端接口**:`router.go` 注册新路由。
- **前端**:发票详情页增加修改按钮(仅在驳回时显示),跳转至编辑页。
## 5. 错误码补充
- `700006`: 发票当前状态不允许修改 (如果 700004 语义不够具体可以新增,或沿用 700004)。
+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. **密钥分离**:公钥可公开,私钥仅服务器持有
+391
View File
@@ -0,0 +1,391 @@
# 错误信息多语言设计方案
## 设计理念
基于系统设计理念,构建简洁、易用、可维护的多语言错误信息响应系统:
- **配置驱动**:语言包通过配置文件管理,支持热更新
- **代码即文档**:通过结构化的语言文件体现错误信息组织
- **渐进式复杂度**:从单语言平滑升级到多语言支持
- **失败快速原则**:语言缺失时优雅降级到默认语言
## 整体架构设计
### 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": "客户已存在"
"200003": "请求过于频繁,请稍后再试"
"200004": "短信发送失败"
# 授权模块 (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"
"200003": "Request too frequent, please try again later"
"200004": "SMS sending failed"
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. 错误消息规范
- **错误消息**: 使用业务错误码,通过 `i18n.NewI18nErrorResponse()` 获取本地化错误信息
- **成功消息**: 统一使用 `i18n.GetErrorMessage("000000", lang)` 获取本地化的"成功"消息
- **自定义消息**: 支持通过第三个参数传入自定义错误消息,优先级高于本地化消息
### 2. 响应格式统一
```go
// 错误响应 - 使用本地化消息
status, errCode, message := i18n.NewI18nErrorResponse("200001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message, // 显示本地化的错误消息
Timestamp: getCurrentTimestamp(),
})
// 错误响应 - 使用自定义消息(显示具体错误信息)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message, // 显示具体的错误信息,如"RSA私钥文件不存在..."
Timestamp: getCurrentTimestamp(),
})
// 错误响应 - 结合本地化消息和具体错误
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(), // 本地化消息 + 具体错误信息
Timestamp: getCurrentTimestamp(),
})
// 成功响应
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: result,
})
```
## 核心组件设计
### 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
```
+11
View File
@@ -0,0 +1,11 @@
## 本地开发环境
```bash
# 使用默认配置
cd cmd
go run .
# 指定配置文件
cd cmd
go run . -config configs/config.dev.yaml
```
@@ -38,4 +38,4 @@ swag fmt
# 验证swagger注释
swag init --parseVendor -g cmd/main.go
```
```
+309
View File
@@ -0,0 +1,309 @@
# 日志系统设计方案
## 设计理念
基于系统设计理念,构建轻量级、高性能、可观测的日志系统:
- **结构化日志**:使用 Logrus 提供结构化日志输出,便于日志分析和监控
- **分层记录**:HTTP 请求日志、业务逻辑日志、错误日志分层记录
- **配置驱动**:通过配置文件控制日志级别和格式
- **性能友好**:日志输出异步化,不影响业务处理性能
- **可观测性**:支持日志聚合和监控集成
## 整体架构设计
### 1. 目录结构
```
backend/
├── configs/ # 配置目录
│ └── config.yaml # 日志配置
├── pkg/logger/ # 日志封装包
│ └── logger.go # 日志管理器和自定义格式器
├── internal/
│ ├── api/
│ │ └── middleware/
│ │ └── logging.go # HTTP请求日志中间件
│ └── service/ # 业务服务层(日志输出点)
└── docs/
└── log_design.md # 日志设计文档
```
### 2. 核心设计原则
- **单一职责**:日志包专门处理日志格式化和输出逻辑
- **配置解耦**:日志配置独立管理,支持运行时调整
- **性能优先**:日志输出异步,不阻塞业务逻辑
- **扩展性**:支持多种输出目标(文件、Elasticsearch等)
## 核心组件设计
### 1. 日志管理器
```go
// pkg/logger/logger.go
type CustomFormatter struct {
*logrus.TextFormatter
}
// Init() 初始化日志系统
// GetLogger() 获取全局日志实例
```
**核心特性:**
- 基于 Logrus 的封装
- 自定义格式器,支持调用者信息显示
- 配置文件驱动的日志级别设置
### 2. HTTP 请求日志中间件
```go
// internal/api/middleware/logging.go
func CustomLoggerMiddleware() gin.HandlerFunc
func LoggingMiddleware(logger *logrus.Logger) gin.HandlerFunc
```
**记录内容:**
- 客户端IP
- 请求方法和路径
- 响应状态码
- 请求延迟时间
- User-Agent信息
### 3. 业务日志输出
```go
// 业务服务中的日志输出示例
s.logger.Infof("[GenerateLicenseFile] 开始生成许可证文件,license_id: %s", id)
s.logger.Errorf("[GenerateLicenseFile] 查询许可证失败,license_id: %s, error: %v", id, err)
```
**日志分类:**
- **Info级别**:正常业务流程记录
- **Warn级别**:异常情况但不影响业务
- **Error级别**:业务错误和系统异常
## 日志格式和输出
### 1. 日志格式
**标准格式:**
```
2024-12-25 14:30:15 INFO API Request status=200 method=GET path=/api/customers ip=127.0.0.1 latency=15.2ms
```
**带调用者信息:**
```
2024-12-25 14:30:15 INFO [GenerateLicenseFile] 开始生成许可证文件,license_id: 12345 ./backend/internal/service/license_service.go:290
```
### 2. 日志级别
| 级别 | 描述 | 使用场景 |
|------|------|----------|
| DEBUG | 调试信息 | 开发环境,详细的执行流程 |
| INFO | 一般信息 | 正常业务流程,重要状态变更 |
| WARN | 警告信息 | 异常情况但不影响业务继续 |
| ERROR | 错误信息 | 业务错误,系统异常 |
### 3. 结构化字段
**HTTP请求日志字段:**
- `status`: HTTP状态码
- `method`: 请求方法
- `path`: 请求路径
- `ip`: 客户端IP
- `latency`: 请求处理时间
- `user_agent`: 用户代理
**业务日志字段:**
- `[ModuleName]`: 模块标识
- 业务相关参数(如 license_id, customer_id 等)
## 配置系统集成
### 1. 日志配置结构
```yaml
# configs/config.yaml
log:
level: "info" # 日志级别: debug/info/warn/error
format: "text" # 日志格式: text/json
```
### 2. 配置加载逻辑
```go
// internal/config/config.go
type LogConfig struct {
Level string `mapstructure:"level"`
Format string `mapstructure:"format"`
}
// 默认配置
viper.SetDefault("log.level", "info")
viper.SetDefault("log.format", "json")
```
## 集成实现方案
### 1. 应用启动集成
```go
// cmd/main.go
func main() {
// 初始化日志系统
logger.Init()
log := logger.GetLogger()
log.Info("启动 License Manager 服务器...")
}
```
### 2. Gin 路由集成
```go
// internal/api/routes/router.go
func SetupRouter() *gin.Engine {
router := gin.New()
// 添加日志中间件
router.Use(middleware.CustomLoggerMiddleware())
return router
}
```
### 3. 服务层集成
```go
// internal/service/license_service.go
type LicenseService struct {
logger *logrus.Logger
}
func NewLicenseService(repo repository.LicenseRepository, logger *logrus.Logger) LicenseService {
return LicenseService{
logger: logger,
}
}
```
## 日志输出示例
### 1. HTTP 请求日志
```
2024-12-25 14:30:15 INFO API Request status=200 method=GET path=/api/customers ip=127.0.0.1 latency=15.2ms user_agent=Mozilla/5.0...
```
### 2. 业务操作日志
```
2024-12-25 14:30:16 INFO [GenerateLicenseFile] 开始生成许可证文件,license_id: 12345 ./backend/internal/service/license_service.go:290
2024-12-25 14:30:16 INFO [GenerateLicenseFile] 许可证信息查询成功,license_key: LK-12345, status: active ./backend/internal/service/license_service.go:309
2024-12-25 14:30:16 WARN [GenerateLicenseFile] 许可证已被撤销,license_id: 12345 ./backend/internal/service/license_service.go:313
```
### 3. 错误日志
```
2024-12-25 14:30:17 ERROR [GenerateLicenseFile] 查询许可证失败,license_id: 12345, error: license not found ./backend/internal/service/license_service.go:302
```
## 性能优化策略
### 1. 异步日志输出
- 日志输出默认异步,不阻塞业务逻辑
- 使用缓冲区批量写入,提升I/O性能
### 2. 日志级别控制
- 生产环境使用 INFO 级别及以上
- 开发环境可启用 DEBUG 级别
- 根据需要动态调整日志级别
### 3. 结构化日志优化
- 使用预定义的字段名称
- 避免大对象序列化
- 敏感信息脱敏处理
## 前端日志处理
### 1. 开发环境日志
```typescript
// 前端调试日志
console.log('[Licenses] openChangeValidityDialog called')
console.log('[Licenses] changeDialogVisible ->', val)
```
### 2. 生产环境优化
- 开发环境的 console.log 在生产环境自动移除
- 错误日志通过统一的错误处理中间件记录
- 用户行为日志通过专门的埋点系统处理
## 监控和告警集成
### 1. 日志聚合
- 使用 ELK Stack 或 Loki 进行日志聚合
- 支持日志查询和可视化分析
### 2. 告警规则
- ERROR 级别日志自动告警
- 关键业务异常监控
- 性能指标监控(如请求延迟)
### 3. 审计日志
- 敏感操作记录(如用户登录、授权变更)
- 支持审计追溯和合规要求
## 实施策略
### 第一阶段:基础架构
1. 实现日志管理器核心功能
2. 配置基础日志格式和级别
3. 集成 HTTP 请求日志中间件
4. 在关键业务节点添加日志输出
### 第二阶段:功能完善
1. 优化日志格式和字段
2. 添加业务日志分类
3. 实现日志轮转和归档
4. 集成日志监控系统
### 第三阶段:运维优化
1. 实现分布式日志追踪
2. 添加性能监控指标
3. 建立日志分析和告警机制
4. 完善日志安全和隐私保护
## 配置示例
### 开发环境配置
```yaml
log:
level: "debug" # 开发环境启用debug级别
format: "text" # 文本格式便于阅读
```
### 生产环境配置
```yaml
log:
level: "info" # 生产环境使用info级别
format: "json" # JSON格式便于日志聚合
```
这个日志设计方案遵循系统的设计理念,提供了结构化、可观测、性能优异的日志系统,为系统的监控、调试和运维提供了有力支持。
-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"
}
```
@@ -0,0 +1,407 @@
# 客户管理结构改造 - 客户用户管理模块
## 需求概述
为支持客户自主注册和使用客户端,需要构建独立的客户用户管理体系。
**核心要求:**
- 只支持手机号注册,通过短信验证码验证
- 用户体系完全独立,与原有系统管理员用户体系分离
- 鉴权和路由模块与原有系统完全分开,实现安全隔离和独立演进
## 原customers表结构
-- license_manager.customers definition
CREATE TABLE `customers` (
`id` varchar(36) NOT NULL DEFAULT (uuid()),
`customer_code` varchar(20) NOT NULL,
`customer_name` varchar(200) NOT NULL,
`customer_type` varchar(20) NOT NULL DEFAULT 'enterprise',
`contact_person` varchar(100) NOT NULL,
`contact_title` varchar(100) DEFAULT NULL,
`email` varchar(255) DEFAULT NULL,
`phone` varchar(20) DEFAULT NULL,
`address` text,
`company_size` varchar(20) DEFAULT NULL,
`customer_level` varchar(20) NOT NULL DEFAULT 'basic',
`status` varchar(20) NOT NULL DEFAULT 'active',
`description` text,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`created_by` varchar(36) NOT NULL,
`updated_by` varchar(36) DEFAULT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_customers_customer_code` (`customer_code`),
KEY `idx_customers_customer_name` (`customer_name`),
KEY `idx_customers_customer_type` (`customer_type`),
KEY `idx_customers_customer_level` (`customer_level`),
KEY `idx_customers_status` (`status`),
KEY `idx_customers_created_at` (`created_at`),
KEY `idx_customers_created_by` (`created_by`),
KEY `idx_customers_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
## 需求背景
当与客户签约后,原授权管理平台的客户是系统管理员通过后管手动添加的。现要支持客户自动注册,给客户专门开发了一个客户端,客户可以自己注册账号并登录使用。
**核心变更:**
- 用户体系完全独立:客户用户与系统管理员用户完全分离
- 注册方式:仅支持手机号注册,通过短信验证码验证
- 安全隔离:鉴权和路由模块与原有系统完全分开,独立演进
## 新增表结构
-- mysql客户用户表(新增)
CREATE TABLE `cu_users` (
`id` VARCHAR(36) NOT NULL DEFAULT (uuid()),
`customer_id` VARCHAR(36) NOT NULL COMMENT '所属客户ID',
`phone` VARCHAR(20) NOT NULL COMMENT '手机号(作为登录账号)',
`phone_country_code` VARCHAR(10) DEFAULT '+86' COMMENT '国家代码',
`password` VARCHAR(255) NULL COMMENT '密码(可选,用于增强安全)',
`salt` VARCHAR(32) NULL COMMENT '密码盐值',
`user_role` VARCHAR(20) NOT NULL DEFAULT 'member' COMMENT '角色: admin-企业管理员, member-普通成员',
`real_name` VARCHAR(100) NULL COMMENT '真实姓名',
`email` VARCHAR(255) NULL COMMENT '邮箱(可选)',
`status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active-正常, disabled-禁用, pending-待激活',
`phone_verified` TINYINT(1) DEFAULT 1 COMMENT '手机是否验证(注册时已验证)',
`email_verified` TINYINT(1) DEFAULT 0 COMMENT '邮箱是否验证',
`last_login_at` DATETIME(3) DEFAULT NULL COMMENT '最后登录时间',
`last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP',
`login_fail_count` INT DEFAULT 0 COMMENT '登录失败次数',
`locked_until` DATETIME(3) DEFAULT NULL COMMENT '锁定截止时间',
`created_at` DATETIME(3) NOT NULL,
`updated_at` DATETIME(3) NOT NULL,
`updated_by` VARCHAR(36) DEFAULT NULL,
`deleted_at` DATETIME(3) DEFAULT NULL,
-- ========== 用户偏好设置 ==========
`avatar_url` VARCHAR(500) DEFAULT NULL COMMENT '头像URL',
`language` VARCHAR(10) DEFAULT 'zh-CN',
`timezone` VARCHAR(50) DEFAULT 'Asia/Shanghai',
`additional_info` JSON DEFAULT NULL COMMENT '附加信息',
`remark` TEXT DEFAULT NULL COMMENT '备注',
PRIMARY KEY (`id`),
UNIQUE KEY `idx_cu_users_phone` (`phone`, `phone_country_code`),
UNIQUE KEY `idx_cu_users_email` (`email`),
KEY `idx_cu_users_customer_id` (`customer_id`),
KEY `idx_cu_users_status` (`status`),
KEY `idx_cu_users_created_at` (`created_at`),
KEY `idx_cu_users_deleted_at` (`deleted_at`),
CONSTRAINT `fk_cu_users_customer_id` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
## 字段说明
### 核心身份字段
- `customer_id`: 关联客户ID,建立用户与客户的关系
- `phone`: 手机号,作为主要登录账号(手机号+国家代码唯一)
- `phone_country_code`: 国家代码,默认+86
### 安全字段
- `password`: 密码(可选,用于增强安全)
- `salt`: 密码盐值
- `user_role`: 用户角色
- `admin`: 企业管理员(可管理企业成员)
- `member`: 普通成员
### 基础信息字段
- `real_name`: 用户真实姓名
- `email`: 邮箱地址(可选,全局唯一)
### 状态管理字段
- `status`: 用户状态
- `active`: 正常状态
- `disabled`: 禁用状态
- `pending`: 待激活状态
- `phone_verified`: 手机验证状态(注册时已验证)
- `email_verified`: 邮箱验证状态
### 安全监控字段
- `last_login_at`: 最后登录时间
- `last_login_ip`: 最后登录IP
- `login_fail_count`: 登录失败次数
- `locked_until`: 账户锁定截止时间
### 偏好设置字段
- `avatar_url`: 用户头像URL
- `language`: 界面语言,默认zh-CN
- `timezone`: 时区设置,默认Asia/Shanghai
- `additional_info`: 附加信息,JSON格式存储
- `remark`: 备注信息
## 核心功能
### 手机号注册
- 仅支持手机号注册方式
- 通过短信验证码验证手机号真实性
- 手机号+国家代码全局唯一
- 注册成功后自动创建用户记录
### 短信验证码机制
- 发送短信验证码API
- 验证码存储在Redis中,有效期控制(通常5分钟)
- 验证码使用后立即删除,防止重复使用
- 验证码错误次数限制
- 防短信轰炸机制(频率限制)
### 用户登录
- 手机号+密码登录(主要方式)
- 手机号+短信验证码登录(备用方式)
- 登录失败次数限制和账户锁定
- 登录历史记录和IP追踪
### 用户信息管理
- 用户信息查看和编辑
- 邮箱绑定和验证(可选)
- 密码设置和管理
- 用户偏好设置
- 手机号修改(验证新旧手机号)
- 角色权限管理(管理员功能)
### 密码管理
- 忘记密码流程(短信验证码重置)
- 修改密码(验证当前密码)
- 密码强度要求和安全策略
### 安全特性
- 手机号实名验证
- 短信验证码二次验证
- 登录失败自动锁定
- 敏感操作日志记录
- IP访问记录追踪
## 认证和路由隔离设计
**采用共享架构,分组隔离的设计方案**
### 为什么需要隔离?
1. **用户体系不同**:SAAS用户是客户员工,原有用户是系统管理员
2. **安全隔离要求**:防止SAAS用户访问系统管理功能
3. **独立演进**:允许SAAS用户系统独立升级和维护
### 共享架构优势
- 避免代码重复,保持架构一致性
- 通过路由组实现逻辑隔离
- 共享核心组件,降低维护成本
- 符合现有Clean Architecture设计原则
### 认证隔离策略
#### 双认证体系
- **管理员认证**:原有JWT认证体系,针对系统管理员
- **客户认证**:新增的客户用户认证体系,针对客户用户
#### 认证中间件扩展
```go
// internal/api/middleware/auth.go
// AdminAuth 原有管理员认证中间件
func AdminAuth() gin.HandlerFunc {
// 使用管理员JWT密钥验证
// 检查管理员权限
}
// CustomerAuth 新增客户用户认证中间件
func CustomerAuth() gin.HandlerFunc {
// 使用客户JWT密钥验证
// 检查客户用户身份和状态
// 从cu_users表验证用户
}
```
### 路由分组隔离
#### 路由前缀分离
- **管理员API**:`/api/*` - 系统管理功能
- **客户API**:`/api/cu/*` - 客户用户功能
#### 功能权限隔离
- 管理员可以访问所有系统功能
- 客户用户只能访问自己的数据和授权信息
- 企业管理员可以管理本企业的普通成员(不实现)
- 严格的数据隔离和权限控制
### Token机制分离
#### 独立的JWT配置
```yaml
# configs/config.yaml
jwt:
admin:
secret: "admin-jwt-secret-key"
expire: 24h
cu: # 客户用户
secret: "cu-jwt-secret-key"
expire: 24h
```
#### Token格式差异
- 管理员Token:包含用户ID和角色信息
- 客户Token:包含用户ID和客户ID,确保数据隔离
## 数据库迁移
需要创建新的数据库迁移文件:
```sql
-- migrations/007_create_cu_users_table.sql
-- 创建customer_users表及相关索引和外键约束
-- 同时需要考虑短信验证码相关的配置表(如有需要)
-- migrations/008_add_sms_config.sql (可选)
```
## 架构设计方案
基于现有Clean Architecture,采用**共享架构,分组隔离**的设计方案:
### 总体原则
- 保持现有架构不变,避免过度分离
- 通过路由组实现逻辑隔离
- 共享核心组件,减少重复代码
- 认证逻辑独立,Token机制分离
### 目录结构规划
```
backend/
├── internal/
│ ├── api/
│ │ ├── handlers/
│ │ │ ├── auth_handler.go # 保持(管理员登录)
│ │ │ ├── cu_auth_handler.go # 新增(客户用户注册/登录)
│ │ │ ├── cu_profile_handler.go # 新增(客户用户个人信息)
│ │ │ ├── customer_handler.go # 保持
│ │ │ ├── license_handler.go # 保持
│ │ │ └── ...
│ │ ├── middleware/
│ │ │ ├── auth.go # 扩展:添加CustomerAuth函数
│ │ │ ├── cors.go
│ │ │ └── ...
│ │ └── routes/
│ │ └── router.go # 扩展:区分admin和customer路由组
│ ├── service/
│ │ ├── cu_user_service.go # 新增(客户用户业务逻辑)
│ │ └── ...
│ ├── repository/
│ │ ├── cu_user_repository.go # 新增(客户用户数据访问)
│ │ └── ...
│ └── models/
│ ├── cu_user.go # 新增(客户用户数据模型)
│ └── ...
├── pkg/
│ └── sms/ # 新增(短信服务工具包)
└── configs/
└── config.yaml # 扩展:添加客户用户相关配置项
```
### 路由分组设计
```go
// internal/api/routes/router.go
func SetupRouter() *gin.Engine {
router := gin.New()
// 管理员API组 - /api/*
adminGroup := router.Group("/api")
adminGroup.Use(middleware.AdminAuth()) // 原有管理员认证
{
adminGroup.POST("/auth/login", handlers.AdminLogin)
adminGroup.GET("/customers", handlers.GetCustomers)
// ... 其他管理员API
}
// 客户用户API组 - /api/cu/*
customerGroup := router.Group("/api/cu")
customerGroup.Use(middleware.CustomerAuth()) // 新增客户认证
{
customerGroup.POST("/auth/register", handlers.CuUserRegister)
customerGroup.POST("/auth/login", handlers.CuUserLogin)
customerGroup.POST("/auth/forgot-password", handlers.CuUserForgotPassword)
customerGroup.POST("/auth/reset-password", handlers.CuUserResetPassword)
customerGroup.GET("/profile", handlers.GetCuUserProfile)
customerGroup.PUT("/profile", handlers.UpdateCuUserProfile)
customerGroup.PUT("/profile/phone", handlers.UpdateCuUserPhone)
// ... 其他客户用户API
}
return router
}
```
## 实现计划
### 第一阶段:基础设施搭建
1. 创建cu_前缀的文件结构
2. 实现短信验证码服务
3. 创建cu_users数据模型和迁移
4. 配置独立的JWT密钥和Token机制
### 第二阶段:核心认证功能
1. 实现手机号注册API(短信验证码验证)
2. 实现手机号登录API(短信验证码登录)
3. 构建独立的认证中间件
4. 实现客户用户路由隔离机制
### 第三阶段:用户管理功能
1. 实现用户信息管理API
2. 添加用户偏好设置功能
3. 实现密码管理功能(忘记密码、重置密码)
4. 实现手机号修改功能(新旧手机号验证)
5. 完善安全监控机制
6. 集成登录历史和IP追踪
## 技术要点
### 短信服务集成
- 支持主流短信服务商(阿里云、腾讯云等)
- 验证码存储在Redis中,确保高性能和自动过期
- 验证码生成和验证逻辑
- 频率限制和防刷机制
### Redis缓存策略
- 短信验证码:`sms:{type}:{phone}`格式存储,5分钟过期
- 使用后立即删除,防止重复使用
- 支持分布式部署的一致性
### 安全考虑
- 手机号数据加密存储
- 独立的Token密钥管理
- 请求频率限制
- 异常登录检测和告警
- 密码安全策略(强度要求、定期更新)
### 配置管理
- 客户用户模块配置文件
- 环境区分(开发/测试/生产)
- 敏感信息加密存储
### 文件命名规范
- 客户用户相关文件统一使用`cu_`前缀
- 清晰区分客户用户逻辑与管理员逻辑
- 便于代码维护和功能扩展
## 验收标准
### 功能验收
- [ ] 客户自主注册流程完整(短信验证码)
- [ ] 手机号+密码登录功能正常
- [ ] 忘记密码重置流程完整
- [ ] 用户角色权限控制正确(admin/member)
- [ ] 手机号修改流程安全(新旧手机号验证)
- [ ] Redis验证码存储和验证正常
- [ ] 路由隔离正确(/api/ vs /api/cu/)
### 性能验收
- [ ] API响应时间符合要求
- [ ] Redis操作性能良好
- [ ] 并发访问稳定
### 安全验收
- [ ] 密码加密存储安全
- [ ] JWT Token隔离正确
- [ ] 短信验证码防刷机制有效
- [ ] 数据隔离完整
Whitespace-only changes.
@@ -0,0 +1,541 @@
# 产品套餐体系设计
## 需求概述
构建套餐购买和授权管理系统,支持客户自主选择套餐、生成授权码的业务流程。
**核心要求:**
- 支持三种套餐类型:试用版、基础版、专业版
- 实现阶梯定价折扣机制(基于许可数量,通过配置化JSON实现)
- 自动生成授权码(一个订单对应一个授权码,激活次数由许可数量决定)
- 支付流程暂时用伪代码实现
- 保证授权码的安全性和唯一性
**重要概念说明:**
- **许可数量**:等于设备数量,决定授权码可以激活多少次
- **试用版**:1个授权码,许可数量为1,用户每月限购1个
- **基础版/专业版**:1个授权码,许可数量等于购买数量,支持50-99、100-499、500+许可的阶梯折扣
## 业务流程
### 整体购买流程
1. 用户登录客户用户系统
2. 浏览套餐列表和价格信息
3. 选择套餐类型和许可数量
4. 系统计算折扣和总价
5. 用户确认并支付(支付成功后直接创建订单)
6. 系统生成授权码并返回给用户
7. 支付失败或取消:不创建订单,用户可重新下单
### 简化流程说明
- **试用版**:直接生成授权码,无需支付
- **基础版/专业版**:使用伪代码模拟支付成功,然后生成授权码
- **一个订单**:对应一个授权码,数量字段仅用于价格计算
## 套餐类型详细说明
| 套餐类型 | 授权期限 | 单价(元)/许可 | 许可数量 | 功能限制 | 适用场景 |
|---------|---------|----------|---------|---------|---------|
| 试用版 | 当天至本月25日 | 0 | 1 | 全部功能 | 新用户试用体验 |
| 基础版 | 永久授权 | 300 | 按购买数量 | 部分功能限制 | 小型企业入门 |
| 专业版 | 永久授权 | 2000 | 按购买数量 | 全部功能 | 中大型企业使用 |
### 功能限制说明
**试用版:**
- 全部功能开放,无任何限制
- 时效性:每月1-25日可购买,当月25日到期
- 不可批量购买,数量固定为1
**基础版:**
- 限制同时在线设备数量(具体数量待定)
- 限制某些高级功能的使用
- 支持批量购买,享受阶梯折扣
**专业版:**
- 全部功能开放,无任何限制
- 支持批量购买,企业级折扣
- 包含优先技术支持服务
## 购买规则和限制
### 折扣规则
- **默认价格**:单个许可不享受折扣
- **9折优惠**:购买50-99个许可
- **8折优惠**:购买100-499个许可
- **7折优惠**:购买500个及以上许可
### 试用版特殊规则
- **购买时间限制**:每月1-25日可购买,26-31日不可购买
- **授权期限**:从购买当天到当月25日23:59:59
- **许可数量**:固定为1(只能激活1次)
- **用户限制**:每个用户每月只能购买1个试用版授权码
- **重复购买**:次月可重新购买,不限制购买次数
### 基础版/专业版规则
- **许可数量选择**:支持选择1-1000个许可数量
- **激活次数限制**:授权码的激活次数等于购买的许可数量
- **折扣计算**:基于许可总数量计算折扣,按单价折扣
- **授权期限**:购买后永久有效,无到期时间限制
### 通用限制
- **用户限制**:每个客户用户账号可独立购买,不受其他账号影响
- **支付限制**:必须完成支付后才能生成授权码
- **退款政策**:购买后不支持退款,建议谨慎选择
## 套餐配置设计
### 写死JSON配置结构
套餐信息通过代码中的JSON配置实现,便于后续修改:
```go
// pkg/config/packages.go
type PackageConfig struct {
Packages []Package `json:"packages"`
Discounts []DiscountRule `json:"discounts"`
}
type Package struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // trial, basic, professional
Price float64 `json:"price"`
DurationDays *int `json:"duration_days"` // null表示永久
MaxDevices *int `json:"max_devices"` // null表示无限制
Features map[string]interface{} `json:"features"`
Status string `json:"status"` // active, disabled
SortOrder int `json:"sort_order"`
}
type DiscountRule struct {
MinQuantity int `json:"min_quantity"`
MaxQuantity *int `json:"max_quantity"` // null表示无上限
Rate float64 `json:"rate"`
Description string `json:"description"`
}
// 写死配置数据
var DefaultPackageConfig = PackageConfig{
Packages: []Package{
{
ID: "trial",
Name: "试用版",
Type: "trial",
Price: 0, // 免费
DurationDays: nil, // 动态计算到当月25日
MaxDevices: nil, // 无设备限制
Features: map[string]interface{}{"full_access": true},
Status: "active",
SortOrder: 1,
},
{
ID: "basic",
Name: "基础版",
Type: "basic",
Price: 300, // 每许可300元
DurationDays: nil, // 永久
MaxDevices: nil, // 激活次数由购买数量决定
Features: map[string]interface{}{"basic_features": true},
Status: "active",
SortOrder: 2,
},
{
ID: "professional",
Name: "专业版",
Type: "professional",
Price: 2000, // 每许可2000元
DurationDays: nil, // 永久
MaxDevices: nil, // 激活次数由购买数量决定
Features: map[string]interface{}{"full_access": true, "priority_support": true},
Status: "active",
SortOrder: 3,
},
},
// 折扣规则:基于许可数量
Discounts: []DiscountRule{
{MinQuantity: 50, MaxQuantity: &[]int{99}[0], Rate: 0.9, Description: "50-99许可9折优惠"},
{MinQuantity: 100, MaxQuantity: &[]int{499}[0], Rate: 0.8, Description: "100-499许可8折优惠"},
{MinQuantity: 500, MaxQuantity: nil, Rate: 0.7, Description: "500+许可7折优惠"},
},
}
```
## 数据模型设计
### 订单表 (orders)
```sql
CREATE TABLE orders (
id VARCHAR(36) PRIMARY KEY DEFAULT (UUID()),
order_no VARCHAR(50) NOT NULL COMMENT '订单号',
customer_id VARCHAR(36) NOT NULL COMMENT '客户ID',
cu_user_id VARCHAR(36) NOT NULL COMMENT '客户用户ID',
package_id VARCHAR(50) NOT NULL COMMENT '套餐ID (对应配置中的ID)',
package_name VARCHAR(100) NOT NULL COMMENT '套餐名称(快照)',
license_count INT NOT NULL COMMENT '许可数量(可激活次数)',
unit_price DECIMAL(10,2) NOT NULL COMMENT '单价(每许可价格,快照)',
discount_rate DECIMAL(3,2) NOT NULL DEFAULT 1.0 COMMENT '折扣率',
total_amount DECIMAL(10,2) NOT NULL COMMENT '订单总金额',
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '订单状态: pending-待支付, paid-已支付, cancelled-已取消, expired-已过期',
authorization_code VARCHAR(50) NULL COMMENT '生成的授权码',
expired_at DATETIME(3) NULL COMMENT '订单过期时间',
created_at DATETIME(3) NOT NULL,
updated_at DATETIME(3) NOT NULL,
deleted_at DATETIME(3) NULL,
FOREIGN KEY (customer_id) REFERENCES customers(id),
FOREIGN KEY (cu_user_id) REFERENCES cu_users(id)
);
```
**说明:**
- 一个订单对应一个授权码,`license_count`字段表示许可数量
- `authorization_code`字段存储生成的授权码
- 授权码创建时会设置许可数量限制
## API接口设计
### 1. 获取套餐列表
```
GET /api/cu/packages
```
**响应:**
```json
{
"code": "000000",
"message": "成功",
"data": [
{
"id": "trial",
"name": "试用版",
"type": "trial",
"price": 0,
"max_devices": 1,
"description": "免费试用,有效期至本月25日"
},
{
"id": "basic",
"name": "基础版",
"type": "basic",
"price": 300,
"max_devices": 1000,
"description": "基础功能,支持批量许可购买"
},
{
"id": "professional",
"name": "专业版",
"type": "professional",
"price": 2000,
"max_devices": 1000,
"description": "全部功能,支持批量许可购买"
}
]
}
```
### 2. 计算订单价格
```
POST /api/cu/orders/calculate
```
**请求:**
```json
{
"package_id": "basic",
"license_count": 100
}
```
**响应:**
```json
{
"code": "000000",
"message": "成功",
"data": {
"unit_price": 300.00,
"license_count": 100,
"discount_rate": 0.8,
"total_amount": 24000.00,
"discount_description": "100-499许可8折优惠"
}
}
```
### 3. 创建订单
```
POST /api/cu/orders
```
**请求:**
```json
{
"package_id": "basic",
"license_count": 100
}
```
**响应:**
```json
{
"code": "000000",
"message": "成功",
"data": {
"order_id": "order_id_123",
"order_no": "ORD202401010001",
"total_amount": 24000.00,
"status": "paid",
"authorization_code": "AC-240101-A1B2C3D4",
"expired_at": null
}
}
```
### 4. 获取订单详情
```
GET /api/cu/orders/{order_id}
```
返回订单状态和授权码信息。
### 5. 获取用户订单历史
```
GET /api/cu/orders
```
返回用户的订单历史记录。
## 前端交互需求
### 套餐选择页面
- 展示三种套餐类型的详细信息
- 支持数量选择(试用版除外)
- 实时计算折扣和总价
- 显示购买限制和规则说明
### 订单确认页面
- 显示订单详情和价格明细
- 提供支付方式选择
- 显示订单过期时间提醒
### 支付结果页面
- 显示支付状态
- 支付成功后展示生成的授权码
- 提供授权码下载/复制功能
### 订单历史页面
- 显示用户的所有订单记录
- 支持按状态筛选
- 显示授权码详情和使用情况
## 支付集成 (暂时用伪代码实现)
### 支付流程说明
由于支付功能暂时不实现,使用伪代码直接模拟支付成功:
```go
// 伪代码:模拟支付流程
func simulatePayment(order *Order) error {
// TODO: 替换为真实的支付接口
// 1. 调用第三方支付接口
// 2. 返回支付链接或二维码
// 3. 等待支付回调
// 暂时直接标记为支付成功
order.Status = "paid"
order.AuthorizationCode = generateAuthorizationCode()
return nil
}
```
### 支付状态流转
1. **所有订单**:创建时即为已支付状态
2. **试用版**:免费,无需支付流程
3. **基础版/专业版**:支付成功后创建订单,失败则不创建
4. **无中间状态**:要么创建成功(已支付),要么失败不创建
### 未来扩展点
- 集成支付宝/微信支付SDK
- 实现支付回调接口
- 添加支付超时和失败处理
- 实现退款功能
## 授权码生成逻辑
### 授权码生成流程
1. 根据套餐类型从配置中获取授权参数
2. 生成唯一的授权码(格式:AC-年月日-8位随机数)
3. 设置授权期限和许可数量限制
4. 创建订单记录和授权码记录
### 授权码格式规范
- **前缀**:AC (Authorization Code)
- **日期**:YYMMDD格式(如240101)
- **随机数**:8位数字字母组合(排除容易混淆的字符如0、O、I、1)
- **示例**:AC-240101-A1B2C3D4
### 试用版特殊处理
- **购买限制**:每个用户每月只能有一个试用版授权码
- **时间限制**:每月1-25日可购买,26-31日不可购买
- **到期时间**:购买当月25日23:59:59
- **许可数量**:固定为1(只能激活1次)
- **重复购买**:次月可重新购买,不限制购买次数
**说明:** 由于一个订单对应一个授权码,不再需要批量生成逻辑。
## 数据库设计
### 新增表
1. **orders** - 订单表
### 索引设计
```sql
-- orders表索引
CREATE INDEX idx_orders_customer_id ON orders(customer_id);
CREATE INDEX idx_orders_cu_user_id ON orders(cu_user_id);
CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_created_at ON orders(created_at);
CREATE INDEX idx_orders_package_id ON orders(package_id);
```
## 错误处理
### 业务错误码 (60xxxx)
```yaml
# 套餐相关错误
cu_package:
"600001": "套餐不存在"
"600002": "套餐已下架"
"600003": "试用版购买时间限制"
"600004": "试用版不支持批量购买"
"600005": "当月已购买试用版"
# 订单相关错误
cu_order:
"601001": "订单不存在"
"601005": "许可数量超出限制"
# 支付相关错误
cu_payment:
"602001": "支付失败"
"602002": "支付金额不匹配"
"602003": "重复支付"
```
## 多语言支持
### 套餐名称国际化
```yaml
packages:
trial: "试用版"
basic: "基础版"
professional: "专业版"
```
### 折扣说明国际化
```yaml
discounts:
no_discount: "不享受折扣"
discount_9: "9折优惠 (50-99个)"
discount_8: "8折优惠 (100-499个)"
discount_7: "7折优惠 (500个以上)"
```
## 技术实现要点
### 订单号生成
- 格式:ORD + 年月日 + 6位序列号
- 保证全局唯一性
- 支持高并发生成
### 折扣计算逻辑
基于许可数量的配置化折扣规则:
```go
func calculateDiscount(config PackageConfig, licenseCount int) (rate float64, description string) {
for _, rule := range config.Discounts {
if licenseCount >= rule.MinQuantity {
if rule.MaxQuantity == nil || licenseCount <= *rule.MaxQuantity {
return rule.Rate, rule.Description
}
}
}
return 1.0, "不享受折扣"
}
```
### 试用版时间限制检查
```go
func canPurchaseTrial() bool {
now := time.Now()
// 每月1-25日可购买
return now.Day >= 1 && now.Day <= 25
}
func getTrialExpiryTime() time.Time {
now := time.Now()
// 当月25日23:59:59
return time.Date(now.Year(), now.Month(), 25, 23, 59, 59, 0, now.Location())
}
```
### 授权码生成
```go
func generateAuthorizationCode() string {
now := time.Now()
dateStr := now.Format("060102") // YYMMDD格式
// 生成8位随机字符串(数字+字母,排除易混淆字符)
const charset = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"
code := make([]byte, 8)
for i := range code {
code[i] = charset[rand.Intn(len(charset))]
}
return fmt.Sprintf("AC-%s-%s", dateStr, string(code))
}
```
### 订单号生成
```go
func generateOrderNo() string {
now := time.Now()
dateStr := now.Format("20060102") // YYYYMMDD格式
// 使用原子计数器生成6位序列号
sequence := atomic.AddUint64(&orderSequence, 1) % 1000000
return fmt.Sprintf("ORD%s%06d", dateStr, sequence)
}
```
## 验收标准
### 功能验收
- [x] 套餐列表接口正确返回三种套餐信息(公开接口,无需认证)
- [x] 价格计算接口正确处理折扣逻辑
- [x] 订单创建接口完整实现业务流程
- [x] 获取订单详情接口支持权限验证
- [x] 获取用户订单历史接口支持分页
- [ ] 试用版购买时间限制生效
- [ ] 试用版每月限购一个的检查逻辑正确
- [ ] 授权码生成正确(包含激活次数限制)
- [x] 订单创建即为已支付状态,无需状态更新
- [ ] 授权码格式规范统一
### 性能验收
- [ ] 订单创建响应时间 < 500ms
- [ ] 授权码生成 < 100ms
- [ ] 高并发订单处理稳定
- [ ] 数据库查询性能良好
### 安全验收
- [ ] 订单金额验证严格
- [ ] 防止重复生成授权码
- [ ] 授权码唯一性保证
- [ ] 敏感信息处理安全
### 业务验收
- [ ] 折扣规则计算准确(基于许可数量和配置JSON)
- [ ] 试用版期限设置正确(当月25日)
- [ ] 试用版用户每月限购一个
- [ ] 永久版无到期时间限制
- [ ] 许可数量设置正确(试用版1,其他版等于购买数量)
- [ ] 一个订单对应一个授权码
@@ -0,0 +1,232 @@
# 授权码生成规范变更
## 📋 需求背景
### 现状分析
- 当前授权码格式:`AC-260101-PQRSTUVW` (示例格式)
- 用户购买授权码后,需要在软件端激活使用
- 软件使用环境可能不联网,无法实时验证授权码有效性
- 当前授权码仅包含基本标识信息,缺少配置详情
### 业务痛点
1. **离线激活困难**:无网络环境无法验证授权码
2. **配置信息缺失**:授权码不包含功能配置、使用限制等详细信息
3. **用户体验不佳**:激活流程依赖网络连接
4. **格式不够友好**:授权码可读性差,不易识别
## 🎯 需求目标
### 核心目标
实现**离线激活授权码**,支持无网络环境下的软件激活和配置获取。
### 功能目标
1. **完整配置嵌入**:授权码包含所有必要配置信息
2. **加密签名保护**:通过数字签名确保授权码安全性
3. **离线验证支持**:软件端可独立验证授权码有效性
4. **友好格式设计**:提高授权码的可读性和识别性
## 🏗️ 技术方案设计
### 1. 授权码结构设计
#### 自包含式授权码
授权码本身包含完整的配置信息,通过编码和加密实现:
**授权码格式**:
```
LIC-{压缩编码的配置数据}-{校验码}
```
**生成步骤**:
1. 构建核心配置JSON
2. 进行数据压缩和编码
3. 添加RSA签名
4. 生成校验码
5. 格式化为用户友好的字符串
### 2. 数据编码方案
#### 核心配置数据
```json
{
"ver": "1.0",
"exp": "2026-12-31T23:59:59Z",
"features": {
"max_users": 100,
"storage": "100GB"
},
"limits": {
"type": "standard"
},
"params": {
"company": "示例公司"
}
}
```
#### 编码过程
1. **JSON序列化**:将配置转换为紧凑JSON格式
2. **数据压缩**:使用高效压缩算法减小体积
3. **Base64编码**:转换为安全字符集
4. **RSA签名**:对编码数据进行签名
5. **格式封装**:添加前缀和校验码
#### 解码过程
1. **格式解析**:提取编码数据部分
2. **签名验证**:验证RSA签名有效性
3. **数据解码**:Base64解码并解压缩
4. **配置提取**:解析JSON获取配置信息
### 3. 加密与签名
#### 密钥管理
- **私钥**: 与生成许可证使用相同的RSA私钥
- **公钥**: 软件客户端内置对应的RSA公钥
- **自动生成**: 如果密钥文件不存在,系统会自动生成RSA密钥对
- **数据签名**: 对编码后的配置数据进行RSA签名
#### 签名算法
- **算法**: RS256 (RSA + SHA256)
- **签名位置**: 签名数据嵌入到授权码中
- **验证方式**: 软件端提取并验证签名确保数据完整性
### 4. 激活流程设计
#### 完全离线激活流程
```mermaid
sequenceDiagram
participant U as 用户
participant S as 软件客户端
U->>S: 输入授权码
S->>S: 解析授权码格式
S->>S: 验证RSA签名
S->>S: 解码配置数据
S->>S: 验证到期时间
S->>S: 激活软件功能
Note over S: 完全离线,授权码自包含所有信息
```
## 🔒 安全性考虑
### 加密保护
- **RSA签名**: 使用与许可证相同的私钥进行签名
- **数据完整性**: 防止配置信息被篡改
- **到期验证**: 确保授权码在有效期内使用
### 验证机制
- **格式校验**: 验证授权码格式规范
- **签名验证**: 强制验证RSA签名有效性
- **时间检查**: 验证到期时间未过期
## 📊 验收标准
### 功能验收
- [ ] 授权码自包含配置信息(到期时间、功能配置、使用限制、自定义参数)
- [ ] 授权码通过RSA签名确保数据完整性
- [ ] 软件端可完全离线解析和验证授权码
- [ ] 激活流程无需网络连接
### 安全性验收
- [ ] RSA签名验证通过率 100%
- [ ] 配置数据防篡改保护
- [ ] 到期时间验证准确
- [ ] 编码/解码过程可靠
## 🔧 简化实施计划
### 📋 当前实现分析
#### 现有架构保持不变
- **数据库结构**: 完全保持现有设计
- **业务逻辑**: 不改变现有流程
- **API接口**: 现有接口继续可用
#### 核心变化
- **授权码生成规则**: 从简单字符串改为自包含配置的编码字符串
- **新增工具包**: `pkg/license/codec.go` 提供编解码功能
- **字段长度调整**: `code` 字段长度从 100 调整到 1000 以容纳编码数据
### 🎯 简化的修改计划
#### 第一阶段:开发核心算法包
1. **扩展 `pkg/utils/license_codec.go`**
- `EncodeLicenseData()`: 配置数据编码为授权码
- `DecodeLicenseData()`: 从授权码解析配置数据
- `SignLicenseData()`: RSA签名生成
- `VerifyLicenseSignature()`: RSA签名验证
#### 第二阶段:修改授权码生成
1. **更新 `generateAuthorizationCode()` 方法**
- 集成配置数据编码逻辑
- 生成自包含的授权码字符串
- 保持相同的调用接口
2. **调整数据库字段长度**
- `code VARCHAR(100)` → `code VARCHAR(1000)`
- 确保向后兼容现有短授权码
#### 第三阶段:完成授权码生成规则
1. **集成到现有流程**
- 修改授权码生成服务
- 保持现有API接口不变
- 授权码自包含配置信息
### 📊 验收标准
#### 功能验收
- [ ] 授权码包含完整配置信息(到期时间、功能配置、使用限制、自定义参数)
- [ ] 授权码通过RSA签名确保数据完整性
- [ ] 授权码可被软件端离线解析和验证
- [ ] 保持与现有系统的完全兼容性
#### 性能验收
- [ ] 授权码生成时间 < 200ms (增加约50ms用于编码)
- [ ] 离线验证时间 < 100ms
#### 安全性验收
- [ ] RSA签名验证功能正常
- [ ] 配置数据防篡改保护
### 🔄 兼容性策略
#### 渐进式启用
- 默认保持原有生成方式(可配置开关)
- 新授权码采用自包含格式
- 现有授权码继续有效
### 📝 具体代码修改点
#### 1. 新增文件:`pkg/utils/license_codec.go`
```go
package utils
// 核心编解码和签名验证功能
func EncodeLicenseData(config interface{}) (string, error)
func DecodeLicenseData(code string) (interface{}, error)
func SignLicenseData(data []byte) (string, error)
func VerifyLicenseSignature(data []byte, signature string) error
```
#### 2. 修改文件:`authorization_code_service.go`
- 修改 `CreateAuthorizationCode()` 方法支持预生成授权码
- 添加 `PreGeneratedCode` 参数到请求结构
- 保持向后兼容性
#### 3. 修改文件:`cu_order_service.go`
- 修改订单创建流程生成自包含授权码
- 使用 `utils.EncodeLicenseData()` 生成授权码
- 传递预生成的授权码给授权码服务
#### 4. 修改文件:`003_create_authorization_codes_table.sql`
- 调整 `code VARCHAR(100)` → `code VARCHAR(1000)`
## 📝 附录
### 配置参数说明
- **end_date**: 到期时间
- **feature_config**: 功能特性配置
- **usage_limits**: 使用限制配置
- **custom_parameters**: 自定义业务参数
@@ -0,0 +1,135 @@
# 注册发送验证码接口
## 概述
为注册流程提供手机号验证功能,用户在注册前需要先获取短信验证码进行手机号验证。
## 接口信息
- **接口路径**: `POST /api/cu/send-register-sms`
- **认证方式**: 无需认证(公开接口)
- **请求格式**: `application/json`
- **响应格式**: `application/json`
## 请求参数
```json
{
"phone": "13800138000", // 必填,手机号
"phone_country_code": "+86" // 可选,国家代码,默认+86
}
```
### 参数说明
| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| phone | string | 是 | 手机号,长度7-20位 |
| phone_country_code | string | 否 | 国家代码,默认"+86" |
## 响应格式
### 成功响应 (200)
```json
{
"code": 200,
"message": "验证码发送成功",
"data": null
}
```
### 错误响应
#### 手机号已存在 (200002)
```json
{
"code": "200002",
"message": "客户已存在",
"data": null
}
```
#### 请求过于频繁 (200003)
```json
{
"code": "200003",
"message": "请求过于频繁,请稍后再试",
"data": null
}
```
#### 参数无效 (900001)
```json
{
"code": "900001",
"message": "请求参数无效",
"data": null
}
```
#### 短信发送失败 (200004)
```json
{
"code": "200004",
"message": "短信发送失败",
"data": null
}
```
## 业务逻辑
1. **参数验证**
- 验证手机号格式
- 验证国家代码格式(如提供)
- 参数不符合要求时,返回错误码 `900001`
2. **用户检查**
- 检查手机号是否已被注册
- 如已注册,返回错误码 `200002`
3. **频率限制**
- 限制同一手机号发送频率(建议:1分钟内最多1次,1小时内最多5次)
- 使用Redis缓存记录发送时间和次数
- 超过频率限制时,返回错误码 `200003`
4. **验证码生成**
- 生成6位数字验证码
- 将验证码存储到Redis(设置过期时间,如5分钟)
5. **短信发送**
- 调用短信服务发送验证码
- 发送失败时,返回错误码 `200004`
- 记录发送日志
## 与忘记密码接口对比
| 对比项 | 注册验证码接口 | 忘记密码验证码接口 |
|--------|----------------|-------------------|
| 接口路径 | `/api/cu/send-register-sms` | `/api/cu/forgot-password` |
| 用户存在检查 | 检查用户**不存在** | 检查用户**存在** |
| 验证码用途 | 手机号验证 | 密码重置 |
| 频率限制 | 较宽松 | 较严格 |
| 缓存键前缀 | `register_sms:` | `forgot_sms:` |
## 缓存设计
- **验证码存储**: `register_sms:{phone_country_code}:{phone}` (过期时间: 5分钟)
- **频率限制**: `register_sms_limit:{phone_country_code}:{phone}` (过期时间: 1小时)
## 安全考虑
1. 验证码只能使用一次,验证后立即删除
2. 限制发送频率,防止短信轰炸
3. 记录发送日志,便于审计
4. 支持图形验证码作为二次验证(可选)
## 后续流程
1. 用户收到验证码后,填写注册表单
2. 调用注册接口 `/api/cu/register`,提交手机号、验证码、密码等信息
3. 后端验证验证码正确性,完成注册流程
@@ -0,0 +1,350 @@
# 短信发送模块设计
## 概述
基于阿里云短信服务,实现验证码发送功能,支持注册、登录、密码重置等场景。
## 技术栈
- **短信服务商**: 阿里云短信服务
- **SDK**: github.com/alibabacloud-go/dysmsapi-20170525/v5
- **缓存**: Redis (存储验证码)
- **频率限制**: Redis (限制发送频率)
## 核心功能
1. **验证码发送**: 支持手机号验证
2. **频率控制**: 防止短信轰炸
3. **验证码缓存**: Redis存储和管理
4. **错误处理**: 完善的错误响应
## 架构设计
### 1. 配置结构
```go
// internal/config/config.go
type SMSConfig struct {
Enabled bool `mapstructure:"enabled"` // 是否启用短信服务
AccessKeyID string `mapstructure:"access_key_id"` // 阿里云AccessKey ID
AccessKeySecret string `mapstructure:"access_key_secret"`// 阿里云AccessKey Secret
SignName string `mapstructure:"sign_name"` // 短信签名
RegionID string `mapstructure:"region_id"` // 地域ID,默认cn-hangzhou
Endpoint string `mapstructure:"endpoint"` // 服务端点
}
// 添加到主配置
type Config struct {
// ... 其他配置
SMS SMSConfig `mapstructure:"sms"`
}
```
### 2. 服务接口
```go
// pkg/utils/sms.go
type SMSService interface {
SendVerificationCode(phone, phoneCountryCode, templateCode string) error
VerifyCode(phone, phoneCountryCode, code string) (bool, error)
IsRateLimited(phone, phoneCountryCode string) bool
}
type smsService struct {
client *dysmsapi20170525.Client
redis *redis.Client // 验证码缓存和频率限制
config *SMSConfig
logger *log.Logger
}
```
### 3. 配置与常量设计
#### 为什么不完全使用配置文件?
**不推荐完全配置化的原因:**
1. **模板代码相对稳定**:阿里云模板一旦创建,不会频繁变化
2. **类型安全**:代码常量有编译时检查,避免运行时错误
3. **重构友好**:IDE可以自动重构所有引用
4. **性能优化**:常量在编译时确定,无运行时查找开销
#### 推荐的混合方案
```go
// pkg/utils/sms.go
// 默认短信模板常量(生产环境使用)
const (
DefaultTemplateRegister = "SMS_330275014"
DefaultTemplateResetPwd = "SMS_330275015"
DefaultTemplateLogin = "SMS_330275016"
)
// 缓存键常量
const (
SMSCodeKeyPrefix = "sms:code"
SMSLimitKeyPrefix = "sms:limit"
SMSCodeExpireTime = 5 * time.Minute
SMSLimitTimeWindow = time.Hour
SMSLimitMaxCount = 5
)
// SMS模板配置(支持运行时覆盖)
type SMSTemplates struct {
Register string `mapstructure:"register"`
ResetPwd string `mapstructure:"reset_pwd"`
Login string `mapstructure:"login"`
}
// 使用方法:优先使用配置,否则使用默认常量
func (s *smsService) getTemplateCode(templateType string) string {
var templateCode string
switch templateType {
case "register":
templateCode = s.templates.Register
if templateCode == "" {
templateCode = DefaultTemplateRegister
}
case "reset_pwd":
templateCode = s.templates.ResetPwd
if templateCode == "" {
templateCode = DefaultTemplateResetPwd
}
case "login":
templateCode = s.templates.Login
if templateCode == "" {
templateCode = DefaultTemplateLogin
}
}
return templateCode
}
```
#### 配置文件示例
```yaml
# configs/config.yaml
sms:
enabled: true
access_key_id: "your-access-key-id"
access_key_secret: "your-access-key-secret"
sign_name: "惠州顺视智能科技"
region_id: "cn-hangzhou"
endpoint: "dysmsapi.aliyuncs.com"
# 模板配置(可选,不配置则使用代码中的默认值)
templates:
register: "SMS_330275014" # 注册模板
reset_pwd: "SMS_330275015" # 重置密码模板
login: "SMS_330275016" # 登录模板
current_phone: "SMS_330275017" # 当前手机号验证模板
new_phone: "SMS_330275018" # 新手机号验证模板
注意:所有模板ID都必须在阿里云SMS控制台中存在且审核通过,否则会报错"isv.SMS_TEMPLATE_ILLEGAL"
```
### 4. 缓存设计
#### 验证码存储
- **Key**: `sms:code:{country_code}:{phone}`
- **Value**: 6位数字验证码
- **TTL**: 5分钟
#### 频率限制
- **Key**: `sms:limit:{country_code}:{phone}`
- **Value**: 发送次数计数器
- **TTL**: 1小时
- **限制**: 每小时最多5次
### 5. 阿里云SMS API集成
#### 已实现功能 ✅
- ✅ AccessKey认证方式
- ✅ 短信发送API调用
- ✅ 错误处理和响应解析
- ✅ 缓存集成(验证码存储和频率限制)
- ✅ 多环境配置支持
#### API调用流程
1. **客户端创建**: 使用AccessKeyID和AccessKeySecret创建阿里云SMS客户端
2. **请求构建**: 构建SendSmsRequest,包含签名、模板、手机号和参数
3. **API调用**: 调用阿里云SMS API发送短信
4. **响应处理**: 解析响应,检查发送结果
5. **缓存存储**: 成功发送后将验证码存储到缓存
#### 错误处理
- **认证错误**: InvalidAccessKeyId.NotFound 等
- **参数错误**: 手机号格式、模板不存在等
- **网络错误**: 超时、重试等
- **配额错误**: 发送频率限制等
#### 测试验证
```bash
# 运行SMS服务测试
go test ./pkg/utils/ -v -run TestSMS
# 验证阿里云API调用
# 使用有效AccessKey时会成功发送短信
# 使用无效AccessKey时会返回认证错误
```
## 接口设计
### 发送验证码
```go
// 发送注册验证码
func (s *smsService) SendRegisterCode(phone, phoneCountryCode string) error {
return s.SendVerificationCode(phone, phoneCountryCode, TemplateRegister)
}
// 发送重置密码验证码
func (s *smsService) SendResetPwdCode(phone, phoneCountryCode string) error {
return s.SendVerificationCode(phone, phoneCountryCode, TemplateResetPwd)
}
```
### 验证码验证
```go
// 验证验证码
func (s *smsService) VerifyCode(phone, phoneCountryCode, code string) (bool, error) {
key := fmt.Sprintf("sms:code:%s:%s", phoneCountryCode, phone)
storedCode, err := s.redis.Get(key).Result()
if err == redis.Nil {
return false, errors.New("验证码不存在或已过期")
}
if err != nil {
return false, err
}
// 验证成功后删除验证码(一次性使用)
if storedCode == code {
s.redis.Del(key)
return true, nil
}
return false, nil
}
```
### 频率限制
```go
// 检查频率限制
func (s *smsService) IsRateLimited(phone, phoneCountryCode string) bool {
key := fmt.Sprintf("sms:limit:%s:%s", phoneCountryCode, phone)
count, err := s.redis.Incr(key).Result()
if err != nil {
return true // Redis错误时限制发送
}
// 首次发送,设置过期时间
if count == 1 {
s.redis.Expire(key, time.Hour)
}
// 每小时最多5次
return count > 5
}
```
## 集成方式
### 1. Service层集成
```go
// internal/service/cu_user_service.go
func (s *cuUserService) SendRegisterSms(ctx context.Context, req *models.CuUserSendRegisterSmsRequest) error {
// 1. 参数验证
if req == nil {
return i18n.NewI18nError("900001", lang)
}
// 2. 检查手机号是否已注册
exists, err := s.repo.CheckPhoneExists(req.Phone, req.PhoneCountryCode, "")
if err != nil {
return i18n.NewI18nError("900004", lang, err.Error())
}
if exists {
return i18n.NewI18nError("200002", lang)
}
// 3. 调用短信服务
err = s.smsService.SendRegisterCode(req.Phone, req.PhoneCountryCode)
if err != nil {
return i18n.NewI18nError("200004", lang) // 短信发送失败
}
return nil
}
```
### 2. 依赖注入
```go
// internal/api/routes/router.go
func SetupRouter() *gin.Engine {
// ... 其他代码
// 初始化短信服务
smsService := utils.NewSMSService(&cfg.SMS, redisClient, logger)
// 初始化用户服务时注入短信服务
cuUserService := service.NewCuUserService(cuUserRepo, customerRepo, db, smsService)
// ... 其他代码
}
```
## 错误码扩展
在语言包中添加短信相关错误码:
```yaml
# customer模块扩展
customer:
"200003": "请求过于频繁,请稍后再试"
"200004": "短信发送失败"
```
## 安全考虑
1. **验证码保护**
- 验证码一次性使用
- 过期时间控制(5分钟)
- Redis存储,防止内存泄露
2. **频率控制**
- 基于手机号的发送频率限制
- 防止短信轰炸攻击
- 支持国际号码
3. **日志记录**
- 发送成功/失败日志
- 敏感信息脱敏
- 便于问题排查
## 实施计划
### 第一阶段:基础功能
1. 实现SMS服务基础类
2. 集成阿里云短信SDK
3. 实现验证码发送和验证
4. 添加频率限制
### 第二阶段:完善功能
1. 添加多种验证码模板
2. 完善错误处理和日志
3. 添加监控指标
4. 性能优化
### 第三阶段:高级功能
1. 支持国际短信
2. 模板动态配置
3. 批量发送功能
4. 失败重试机制
@@ -0,0 +1,470 @@
# 缓存模块设计
基于Clean Architecture设计,支持内存缓存和Redis缓存两种实现方式,提供统一的缓存接口,便于在不同环境下切换。
## 设计理念
- **抽象接口**:通过接口抽象不同缓存实现
- **配置驱动**:支持运行时配置缓存类型和参数
- **优雅降级**:缓存异常时不影响业务逻辑
- **性能监控**:提供缓存命中率等指标
- **序列化支持**:自动处理常见数据类型的序列化
## 核心架构
### 1. 缓存接口设计
```go
// Cache 缓存接口
type Cache interface {
// 基础操作
Get(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key string, value string, ttl time.Duration) error
Del(ctx context.Context, keys ...string) error
Exists(ctx context.Context, key string) (bool, error)
// TTL操作
TTL(ctx context.Context, key string) (time.Duration, error)
Expire(ctx context.Context, key string, ttl time.Duration) error
// 数值操作
Incr(ctx context.Context, key string) (int64, error)
Decr(ctx context.Context, key string) (int64, error)
// 批量操作
MGet(ctx context.Context, keys ...string) ([]string, error)
MSet(ctx context.Context, pairs map[string]string, ttl time.Duration) error
// 工具方法
Ping(ctx context.Context) error
Close() error
}
// CacheWithTTL 带TTL的缓存接口
type CacheWithTTL interface {
Cache
GetWithTTL(ctx context.Context, key string) (string, time.Duration, error)
}
// Serializer 序列化接口
type Serializer interface {
Marshal(v interface{}) ([]byte, error)
Unmarshal(data []byte, v interface{}) error
}
```
### 2. 缓存配置
```go
// CacheConfig 缓存配置
type CacheConfig struct {
Type string `mapstructure:"type"` // 缓存类型: memory, redis
TTL time.Duration `mapstructure:"ttl"` // 默认TTL
Enabled bool `mapstructure:"enabled"` // 是否启用缓存
Redis RedisConfig `mapstructure:"redis"` // Redis配置
Memory MemoryConfig `mapstructure:"memory"` // 内存配置
}
// RedisConfig Redis配置
type RedisConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
Password string `mapstructure:"password"`
DB int `mapstructure:"db"`
PoolSize int `mapstructure:"pool_size"`
MinIdleConns int `mapstructure:"min_idle_conns"`
ConnMaxIdleTime time.Duration `mapstructure:"conn_max_idle_time"`
DialTimeout time.Duration `mapstructure:"dial_timeout"`
ReadTimeout time.Duration `mapstructure:"read_timeout"`
WriteTimeout time.Duration `mapstructure:"write_timeout"`
}
// MemoryConfig 内存配置
type MemoryConfig struct {
MaxSize int `mapstructure:"max_size"` // 最大缓存条目数
CleanupInterval time.Duration `mapstructure:"cleanup_interval"` // 清理间隔
}
```
## 实现方案
### 1. 内存缓存实现
```go
// memoryCache 内存缓存实现
type memoryCache struct {
data sync.Map
ttl sync.Map
maxSize int
mu sync.RWMutex
}
// NewMemoryCache 创建内存缓存
func NewMemoryCache(config MemoryConfig) Cache {
cache := &memoryCache{
maxSize: config.MaxSize,
data: sync.Map{},
ttl: sync.Map{},
}
// 启动清理协程
if config.CleanupInterval > 0 {
go cache.cleanup(config.CleanupInterval)
}
return cache
}
func (m *memoryCache) Get(ctx context.Context, key string) (string, error) {
value, ok := m.data.Load(key)
if !ok {
return "", ErrCacheMiss
}
// 检查TTL
if ttl, exists := m.ttl.Load(key); exists {
if time.Now().After(ttl.(time.Time)) {
m.data.Delete(key)
m.ttl.Delete(key)
return "", ErrCacheMiss
}
}
return value.(string), nil
}
func (m *memoryCache) Set(ctx context.Context, key string, value string, ttl time.Duration) error {
// 简单的容量检查(生产环境建议使用更完善的LRU缓存库)
if m.maxSize > 0 {
// 这里可以实现简单的容量控制逻辑
// 生产环境建议使用 github.com/hashicorp/golang-lru 等专业库
}
m.data.Store(key, value)
if ttl > 0 {
m.ttl.Store(key, time.Now().Add(ttl))
}
return nil
}
```
### 2. Redis缓存实现
```go
// redisCache Redis缓存实现
type redisCache struct {
client *redis.Client
ttl time.Duration
}
// NewRedisCache 创建Redis缓存
func NewRedisCache(config RedisConfig, defaultTTL time.Duration) (Cache, error) {
client := redis.NewClient(&redis.Options{
Addr: fmt.Sprintf("%s:%d", config.Host, config.Port),
Password: config.Password,
DB: config.DB,
PoolSize: config.PoolSize,
})
// 测试连接
if err := client.Ping(context.Background()).Err(); err != nil {
return nil, fmt.Errorf("failed to connect to Redis: %w", err)
}
return &redisCache{
client: client,
ttl: defaultTTL,
}, nil
}
func (r *redisCache) Get(ctx context.Context, key string) (string, error) {
value, err := r.client.Get(ctx, key).Result()
if err == redis.Nil {
return "", ErrCacheMiss
}
if err != nil {
return "", fmt.Errorf("cache get error: %w", err)
}
return value, nil
}
func (r *redisCache) Set(ctx context.Context, key string, value string, ttl time.Duration) error {
if ttl == 0 {
ttl = r.ttl
}
return r.client.Set(ctx, key, value, ttl).Err()
}
```
### 3. 缓存工厂
```go
// NewCache 创建缓存实例
func NewCache(config CacheConfig) (Cache, error) {
if !config.Enabled {
return &noOpCache{}, nil
}
switch config.Type {
case "memory":
return NewMemoryCache(config.Memory)
case "redis":
return NewRedisCache(config.Redis, config.TTL)
default:
return nil, fmt.Errorf("unsupported cache type: %s", config.Type)
}
}
// noOpCache 空操作缓存(当缓存被禁用时使用)
type noOpCache struct{}
func (n *noOpCache) Get(ctx context.Context, key string) (string, error) {
return "", ErrCacheMiss
}
func (n *noOpCache) Set(ctx context.Context, key string, value string, ttl time.Duration) error {
return nil
}
// ... 其他方法都返回nil或默认值
```
## 高级功能
### 1. 序列化支持
```go
// JSONSerializer JSON序列化器
type JSONSerializer struct{}
func (j *JSONSerializer) Marshal(v interface{}) ([]byte, error) {
return json.Marshal(v)
}
func (j *JSONSerializer) Unmarshal(data []byte, v interface{}) error {
return json.Unmarshal(data, v)
}
// Cached 带序列化的缓存包装器
type Cached struct {
Cache
Serializer Serializer
}
func (c *Cached) SetObject(ctx context.Context, key string, value interface{}, ttl time.Duration) error {
data, err := c.Serializer.Marshal(value)
if err != nil {
return err
}
return c.Cache.Set(ctx, key, string(data), ttl)
}
func (c *Cached) GetObject(ctx context.Context, key string, dest interface{}) error {
value, err := c.Cache.Get(ctx, key)
if err != nil {
return err
}
return c.Serializer.Unmarshal([]byte(value), dest)
}
```
### 2. 缓存装饰器
```go
// CacheDecorator 缓存装饰器
type CacheDecorator struct {
Cache
prefix string // 键前缀
}
func (c *CacheDecorator) buildKey(key string) string {
if c.prefix != "" {
return c.prefix + ":" + key
}
return key
}
func (c *CacheDecorator) Get(ctx context.Context, key string) (string, error) {
return c.Cache.Get(ctx, c.buildKey(key))
}
func (c *CacheDecorator) Set(ctx context.Context, key string, value string, ttl time.Duration) error {
return c.Cache.Set(ctx, c.buildKey(key), value, ttl)
}
```
### 3. 缓存键管理
```go
// KeyBuilder 缓存键构建器
type KeyBuilder struct {
prefix string
separator string
}
func NewKeyBuilder(prefix string) *KeyBuilder {
return &KeyBuilder{
prefix: prefix,
separator: ":",
}
}
func (k *KeyBuilder) Build(parts ...string) string {
if k.prefix != "" {
parts = append([]string{k.prefix}, parts...)
}
return strings.Join(parts, k.separator)
}
// 预定义键模式
const (
UserKeyPrefix = "user"
SessionKeyPrefix = "session"
SMSCodePrefix = "sms:code"
SMSLimitPrefix = "sms:limit"
)
// 使用示例
keyBuilder := NewKeyBuilder("app")
userKey := keyBuilder.Build(UserKeyPrefix, "123") // "app:user:123"
```
## 配置示例
### 内存缓存配置
```yaml
cache:
enabled: true
type: memory
ttl: 30m
memory:
max_size: 10000
cleanup_interval: 10m
```
### Redis缓存配置
```yaml
cache:
enabled: true
type: redis
ttl: 30m
redis:
host: localhost
port: 6379
password: ""
db: 0
pool_size: 10
min_idle_conns: 5
conn_max_idle_time: 30m
dial_timeout: 5s
read_timeout: 3s
write_timeout: 3s
```
## 错误处理
```go
var (
ErrCacheMiss = errors.New("cache miss")
ErrCacheClosed = errors.New("cache closed")
)
// 优雅降级:缓存错误不影响业务
func safeCacheOperation(cache Cache, operation func() error) error {
defer func() {
if r := recover(); r != nil {
// 记录错误日志,但不影响业务
}
}()
if cache == nil {
return nil // 缓存未启用,直接跳过
}
return operation()
}
```
## 监控指标
```go
// CacheStats 缓存统计信息
type CacheStats struct {
Hits int64 // 命中次数
Misses int64 // 未命中次数
Errors int64 // 错误次数
HitRate float64 // 命中率
TotalOps int64 // 总操作数
AvgRespTime int64 // 平均响应时间(纳秒)
}
func (s *CacheStats) RecordHit() {
s.Hits++
s.updateHitRate()
}
func (s *CacheStats) RecordMiss() {
s.Misses++
s.updateHitRate()
}
func (s *CacheStats) RecordError() {
s.Errors++
}
func (s *CacheStats) updateHitRate() {
total := s.Hits + s.Misses
if total > 0 {
s.HitRate = float64(s.Hits) / float64(total)
}
}
```
## 使用示例
```go
// 初始化缓存
cache, err := NewCache(config.Cache)
if err != nil {
log.Fatal(err)
}
// 基础使用
cache.Set(ctx, "user:123", "user_data", time.Hour)
data, err := cache.Get(ctx, "user:123")
// 带序列化的使用
cached := &Cached{
Cache: cache,
Serializer: &JSONSerializer{},
}
user := User{ID: 123, Name: "John"}
cached.SetObject(ctx, "user:123", user, time.Hour)
var userData User
cached.GetObject(ctx, "user:123", &userData)
```
## 实施计划
### 第一阶段:核心功能 (P0)
1. 实现缓存接口和基础实现类
2. 支持内存缓存 (sync.Map)
3. 支持Redis缓存 (go-redis/v9)
4. 实现缓存工厂和配置加载
5. 集成到现有项目中替换现有缓存逻辑
### 第二阶段:增强功能 (P1)
1. 添加序列化支持 (JSONSerializer)
2. 实现缓存键构建器 (KeyBuilder)
3. 添加TTL查询和过期时间管理
4. 完善批量操作支持
### 第三阶段:监控运维 (P2)
1. 实现缓存统计和监控指标
2. 添加性能监控和告警
3. 优化连接池和错误重试
4. 支持缓存预热和配置热更新
@@ -0,0 +1,191 @@
# 授权码生成规范再变更(2026-01-16)
## 1. 背景与目标
授权码(Authorization Code)当前用于“在线激活”:客户端携带授权码请求服务端,服务端签发许可证文件。
当客户环境无网络时,在线激活不可用,因此需要一段可由客户端内置 RSA 公钥解析/验签的字符串,携带授权的配置(到期时间、功能配置、使用限制等),客户端在离线场景可据此完成自校验与功能开关。
本次“再变更”的核心原则:
- 不改动现有授权码本体格式与核心激活流程
- 仅在“复制/导出”时提供“产品激活码(Product Activation Code)”扩展串
## 2. 当前代码实现(现状梳理)
### 2.1 授权码基础格式(旧规则,核心流程依赖)
当前核心流程依赖的授权码格式为 4 段:
```
LIC-{客户ID前4位}-{12位随机}-{4位校验码}
```
示例:`LIC-F44D-GvBzMfEGbxMP-LVAA`
对应实现:
- 生成:`backend/pkg/utils/authorization_code.go` 的 `GenerateLegacyAuthorizationCode()`(由 `backend/internal/service/authorization_code_service.go` 的 `generateAuthorizationCode()` 调用)
- 使用:核心流程以数据库匹配为准(`licenseRepo.GetAuthorizationCodeByCode`),不做额外格式校验
### 2.2 历史“自包含授权码”方案(已移除)
历史上曾存在一套“自包含授权码”的生成/解析方案(已删除,不再使用):
- 曾实现:`backend/pkg/utils/license_codec.go`(已删除,原 `EncodeLicenseData()/DecodeLicenseData()`)
- 格式:`LIC-{2位随机}-{Base64数据}`(3 段示例:`LIC-B5-xxxx...`)
- 特征:将到期时间/配置等编码进 `{Base64数据}`,并做签名校验
该格式与 2.1 的旧规则(4 段)以及现有激活流程不一致,已按本次“再变更”回退/统一到 2.1。
受影响的核心流程(已改回旧规则生成):
- `POST /api/v1/authorization-codes`(管理端创建授权码)
- `POST /api/cu/orders`(C 端下单创建授权码)
### 2.3 授权码下载包(保持不变)
现有下载接口会导出一个 zip,包含:
- `authorization_code.txt`:仅包含授权码字符串(`authCode.Code`)
- `rsa_public_key.pem`:RSA 公钥(从配置 `cfg.License.RSA.PublicKeyPath` 读取)
对应实现:
- 路由:`GET /api/v1/authorization-codes/:id/download`
- Service:`backend/internal/service/authorization_code_service.go` 的 `GenerateAuthorizationFile()`
- Handler:`backend/internal/api/handlers/authorization_code_handler.go` 的 `DownloadAuthorizationFile()`
### 2.4 “公钥可解析”的签名封装(可复用)
代码中已存在一套「RSA 私钥签名 + Base64 封装」实现,客户端可用公钥验签后得到原始 JSON:
- 服务端签名封装:`backend/internal/service/license_service.go` 的 `signLicenseFile()`
- 签名算法:`RSA-PSS-SHA256`(`backend/pkg/utils/crypto.go` 的 `RSAPrivateKey.SignData()` / `RSAPublicKey.VerifySignature()`)
- 客户端验签示例:`backend/cmd/client-demo/main.go` 的 `decryptLicenseFile()`
说明:这里的“加密字符串”在现有体系中更贴近“签名封装字符串”(提供防篡改与来源可信验证,不提供保密性)。
## 3. 本次再变更:产品激活码(Product Activation Code)
### 3.1 输出格式
在需要离线激活/离线解析配置的场景,对外展示/复制的字符串为“产品激活码”:
```
{授权码}&{配置签名封装串}
```
示例:`LIC-F44D-GvBzMfEGbxMP-LVAA&{payload}`
其中:
- `{授权码}`:保持现有 4 段格式不变(用于在线激活、数据库查找、订单绑定等核心流程)
- `{payload}`:可由客户端内置 RSA 公钥验签并解析出 JSON 的字符串
### 3.2 payload(配置签名封装串)业务 JSON
payload 解析后的业务 JSON(即“配置 JSON”)建议至少包含:
```json
{
"authorization_code": "LIC-F44D-GvBzMfEGbxMP-LVAA",
"start_date": "2026-01-16T00:00:00+08:00",
"end_date": "2026-12-31T23:59:59+08:00",
"deployment_type": "standalone",
"max_activations": 100,
"feature_config": {},
"usage_limits": {},
"custom_parameters": {},
"generated_at": "2026-01-16T12:00:00+08:00",
"ver": 1
}
```
字段来源:
- `authorization_code/start_date/end_date/deployment_type/max_activations/feature_config/usage_limits/custom_parameters`:来自 `authorization_codes` 表记录
- `generated_at`:服务端生成时间
### 3.3 payload 生成规则(服务端)
复用许可证文件的签名封装方式(`license_service.signLicenseFile()` 的数据结构与算法):
1. 构造“配置 JSON”(见 3.2),序列化为 `data`(JSON 字符串)
2. 使用 RSA 私钥对 `data` 进行 PSS+SHA256 签名得到 `signature`(Base64)
3. 组装签名封装对象:
```json
{
"data": "{...配置JSON字符串...}",
"signature": "{base64_signature}",
"algorithm": "RSA-PSS-SHA256"
}
```
4. 将封装对象再序列化为 JSON,并 Base64 编码得到 `{payload}`
说明:
- 私钥路径来自 `cfg.License.RSA.PrivateKeyPath`
- 公钥路径来自 `cfg.License.RSA.PublicKeyPath`(客户端内置同一对公钥)
- Base64 编码沿用 `base64.StdEncoding`(与现有 license file 一致)
### 3.4 客户端解析规则(产品端)
1. 读取输入字符串,按 `&` 分割:
- 只有 1 段:视为仅“在线激活授权码”
- 至少 2 段:`base_code` 与 `payload`(取第 1 段为授权码本体)
2. 对 `payload` Base64 解码得到封装 JSON
3. 解析封装 JSON,校验 `algorithm == "RSA-PSS-SHA256"`
4. 用内置 RSA 公钥对 `data` 与 `signature` 验签
5. 验签通过后解析 `data` 得到配置 JSON,并自行校验:
- 到期时间、功能配置、使用限制等
补充(在线激活):
- `/api/v1/activate` 的 `authorization_code` 参数只使用 `{授权码}` 本体,不依赖 `{payload}`
- 客户端若传入完整 `product_activation_code`(`{授权码}&{payload}`),服务端会自动截取 `&` 前的 `{授权码}` 完成激活
## 4. 接口变更清单(需求说明)
### 4.1 现有接口需要调整的内容
#### 4.1.1 管理端:创建授权码
- 接口:`POST /api/v1/authorization-codes`
- 变更点:
- `code` 生成规则统一为 2.1 的 4 段旧规则:`LIC-{客户ID前4位}-{12位随机}-{4位校验码}`
- `feature_config/usage_limits/custom_parameters/start_date/end_date/...` 等仍按现有结构字段写入数据库(供 license file 与 payload 复用)
- 返回:响应中的 `code` 仅返回“旧规则授权码本体”(不包含 `&payload`)
#### 4.1.2 C 端:下单创建授权码
- 接口:`POST /api/cu/orders`
- 变更点:
- 订单关联的授权码字符串(`order.authorization_code`)生成规则统一为 2.1 的旧规则
- 同步把订单对应配置写入 `authorization_codes` 表的结构化字段(不再依赖“授权码本体自包含”)
- 返回:仍返回 `authorization_code`(旧规则授权码本体,不包含 `&payload`)
- 备注:`POST /api/cu/authorization-codes/:codeId/share` 仍可沿用旧规则生成(如后续需要统一,可复用同一生成器)
#### 4.1.3 客户端:激活
- 接口:`POST /api/v1/activate`
- 变更点:
- 激活不需要配置 payload;仅以 `{授权码}` 做数据库匹配,不做额外格式校验
- 兼容传入完整 `product_activation_code`:服务端自动截取 `&` 前的 `{授权码}`
### 4.2 新增接口(仅客户端使用):获取产品激活码
为客户端提供一个“获取产品激活码”的接口,返回 `{授权码}&{payload}`。管理端不提供该接口(当前仅 C 端“复制/导出”场景需要)。
#### 4.2.1 建议接口形态(C 端)
```
POST /api/cu/authorization-codes/product-activation-code
```
请求:
```json
{
"authorization_code": "LIC-F44D-GvBzMfEGbxMP-LVAA"
}
```
返回:
```json
{
"code": "000000",
"message": "success",
"data": {
"product_activation_code": "LIC-F44D-GvBzMfEGbxMP-LVAA&{payload}"
}
}
```
约束与校验(建议):
- 授权码存在,且归属当前登录客户(CustomerAuth)
- 授权码未锁定,且在有效期内
## 5. 兼容性与验收标准
### 5.1 兼容性
- 原授权码本体格式不变,在线激活核心流程不受影响
- 旧授权码仍可用;仅新增“产品激活码”扩展串用于离线解析
### 5.2 验收标准
- `product_activation_code` 中的 `{payload}` 能被客户端仅凭公钥验签并解析出配置 JSON
- 客户端可在离线场景下依据配置 JSON 完成自校验与功能开关
- 不影响 `/api/v1/activate`、授权码管理、订单等核心流程
@@ -0,0 +1,414 @@
# 发票模块设计
## 1. 背景与目标
客户购买套餐后(订单已支付),需要在用户端发起开票申请;管理员在管理端处理开票、上传发票 PDF,或驳回并给出修改建议;用户可在列表/详情查看处理进度并下载发票文件。
## 2. 实体关系
- `cu_orders`(订单) : `invoices`(发票) = 1 : 1(一个订单最多一张发票)
约束:
- `invoices.order_id` 唯一,确保 1:1
## 3. 状态与枚举
### 3.1 开票状态(invoice_status)
- `pending`:待处理
- `issued`:已开票
- `rejected`:已驳回
状态流转:
- `pending -> issued`
- `pending -> rejected`
- (可选)`rejected -> issued`(管理员确认无误后直接开票)
### 3.2 发票类型(invoice_type)
- `personal`:个人
- `enterprise`:企业普票
- `vat_special`:增值税专用发票
字段约束建议:
- `invoice_type=personal`:`taxpayer_id` 可为空
- `invoice_type=enterprise/vat_special`:`taxpayer_id` 必填
## 4. 编号与金额口径
### 4.1 发票申请号(invoice_no)
建议生成规则与订单号风格一致:
- `INV{YYYYMMDD}{9位序列}`(序列使用纳秒时间戳后 9 位,确保全局唯一)
### 4.2 发票金额(amount)
强制以订单 `cu_orders.total_amount` 为准(下单时已确定),避免用户端传入金额造成口径不一致。
## 5. 表结构设计(新增 invoices 表)
> 说明:当前项目大量使用软删除(`deleted_at`),发票也建议沿用该模式。
### 5.1 invoices 表(建议)
字段设计(按业务分组):
- 主键:`id`
- 关联:`order_id`(唯一)、`order_no`(冗余快照,便于搜索)、`customer_id`、`cu_user_id`
- 金额:`amount`
- 业务:`status`、`invoice_type`、`title`、`taxpayer_id`、`content`、`receiver_email`、`remark`
- 开票处理:`issued_at`、`invoice_file_url`、`uploaded_at`、`uploaded_by`
- 驳回处理:`rejected_at`、`rejected_by`、`reject_reason`、`suggestion`
- 下载(可选):`download_token`(用于邮件链接下载的随机 token)
- 时间:`created_at`、`updated_at`、`deleted_at`
推荐 SQL(MySQL):
```sql
CREATE TABLE `invoices` (
`id` varchar(36) NOT NULL,
`invoice_no` varchar(50) NOT NULL COMMENT '发票申请号',
`order_id` varchar(36) NOT NULL COMMENT '订单ID(唯一)',
`order_no` varchar(50) NOT NULL COMMENT '订单号(冗余快照,便于检索)',
`customer_id` varchar(36) NOT NULL COMMENT '客户ID',
`cu_user_id` varchar(36) NOT NULL COMMENT '申请人(客户用户)ID',
`amount` decimal(10,2) NOT NULL COMMENT '发票金额(取订单total_amount)',
`status` varchar(20) NOT NULL DEFAULT 'pending' COMMENT 'pending/issued/rejected',
`invoice_type` varchar(20) NOT NULL COMMENT 'personal/enterprise/vat_special',
`title` varchar(200) NOT NULL COMMENT '发票抬头',
`taxpayer_id` varchar(50) DEFAULT NULL COMMENT '纳税人识别号',
`content` varchar(200) NOT NULL COMMENT '开票内容',
`receiver_email` varchar(255) NOT NULL COMMENT '收票邮箱',
`remark` varchar(1000) DEFAULT NULL COMMENT '备注',
`invoice_file_url` varchar(500) DEFAULT NULL COMMENT '发票文件URL(PDF)',
`uploaded_at` datetime(3) DEFAULT NULL COMMENT '上传时间',
`uploaded_by` varchar(36) DEFAULT NULL COMMENT '上传人(管理员)ID',
`issued_at` datetime(3) DEFAULT NULL COMMENT '开票完成时间',
`reject_reason` varchar(500) DEFAULT NULL COMMENT '驳回原因',
`suggestion` varchar(500) DEFAULT NULL COMMENT '建议修改内容',
`rejected_at` datetime(3) DEFAULT NULL COMMENT '驳回时间',
`rejected_by` varchar(36) DEFAULT NULL COMMENT '驳回人(管理员)ID',
`download_token` varchar(64) DEFAULT NULL COMMENT '下载token(可选)',
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_invoices_invoice_no` (`invoice_no`),
UNIQUE KEY `idx_invoices_order_id` (`order_id`),
KEY `idx_invoices_customer_id` (`customer_id`),
KEY `idx_invoices_cu_user_id` (`cu_user_id`),
KEY `idx_invoices_status` (`status`),
KEY `idx_invoices_created_at` (`created_at`),
KEY `idx_invoices_deleted_at` (`deleted_at`),
KEY `idx_invoices_order_no` (`order_no`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
## 6. 接口设计
### 6.1 通用约定(与项目一致)
- 鉴权:客户端 `CustomerAuth()`;管理端 `AuthMiddleware()`(是否叠加 `AdminOnlyMiddleware()` 视权限策略)
- 返回:成功 `models.APIResponse`,失败 `models.ErrorResponse`
- 多语言:`Accept-Language` 或 `?lang=...`
### 6.2 客户端(/api/cu)
#### 6.2.1 客户申请发票
```http
POST /api/cu/invoices
```
请求 Body:
```json
{
"order_id": "order-uuid",
"invoice_type": "enterprise",
"receiver_email": "finance@example.com",
"title": "某某公司",
"taxpayer_id": "9133xxxxxxxxxxxxxx",
"content": "软件服务费",
"remark": "请开电子发票"
}
```
规则(建议):
- `order_id` 必填,且订单必须属于当前用户(`cu_orders.cu_user_id`)
- 仅允许已支付订单开票:`cu_orders.status=paid`
- 一个订单仅允许一张发票:若已存在发票则返回冲突错误
- `invoice_type` 影响 `taxpayer_id` 是否必填(见 3.2)
成功响应:
```json
{
"code": "000000",
"message": "成功",
"data": { "id": "invoice-uuid", "invoice_no": "INV202601210123456789", "status": "pending" },
"timestamp": "2026-01-21T10:05:00Z"
}
```
#### 6.2.2 客户发票列表
```http
GET /api/cu/invoices
```
Query:
- `page`(默认 1)
- `page_size`(默认 10,最大 100)
- `status`(可选):`pending/issued/rejected`
- `search`(可选):`invoice_no` 或 `order_no` 模糊匹配
- `apply_date`(可选):申请日期(`YYYY-MM-DD`,按 `created_at` 过滤)
#### 6.2.3 客户发票详情
```http
GET /api/cu/invoices/{id}
```
返回:发票表全部字段 + 关联订单号/套餐类型(可 join `cu_orders` 返回 `order_no/package_name`)。
#### 6.2.4 客户发票汇总信息
```http
GET /api/cu/invoices/summary
```
返回:
- `total_count`
- `pending_count`
- `issued_count`
- `rejected_count`
#### 6.2.5 发票下载(推荐:鉴权下载)
```http
GET /api/cu/invoices/{id}/download
```
仅允许下载自己的发票;返回 PDF 文件流。
### 6.3 管理端(/api/v1)
#### 6.3.1 管理员发票列表
```http
GET /api/v1/invoices
```
Query 与客户端一致,额外可支持 `customer_id` 筛选。
#### 6.3.2 管理员发票详情
```http
GET /api/v1/invoices/{id}
```
返回:
- 发票表所有字段
- 关联订单号、套餐类型(`cu_orders.package_name`)
- 申请人姓名/电话(`cu_users.real_name/phone`)
- 开票人 `full_name`(通过 `users.full_name`,关联 `uploaded_by`)
#### 6.3.3 管理员发票汇总信息
```http
GET /api/v1/invoices/summary
```
#### 6.3.4 管理员发票驳回
```http
POST /api/v1/invoices/{id}/reject
```
Body:
```json
{ "reject_reason": "抬头信息不完整", "suggestion": "请补充纳税人识别号" }
```
规则:仅允许对 `pending` 发票操作,写入驳回字段并更新 `status=rejected`。
#### 6.3.5 管理员发票开票(支持覆盖重开)
```http
POST /api/v1/invoices/{id}/issue
```
Body:
```json
{ "invoice_file_url": "/files/invoices/INV202601210123456789_20260121103000.pdf", "issued_at": "2026-01-21T10:30:00Z" }
```
规则:
- 允许从 `pending/rejected` 置为 `issued`
- 允许在 `issued` 状态下覆盖更新 `invoice_file_url/issued_at`(“重开覆盖”)
#### 6.3.6 管理端发票文件上传
```http
POST /api/v1/invoices/upload
```
说明:
- 存储目录:程序运行目录相对路径 `../files/invoices/`
- 文件名:`{invoice_no}_{yyyyMMddHHmmss}.pdf`
- 返回相对 URL:`/files/invoices/...pdf`
FormData:
- `invoice_no`
- `file`(pdf)
### 6.4 公共下载接口(可选)
若需要邮件直达下载(不登录),建议采用 token 方式(不要直接暴露任意 `file_url`):
```http
GET /api/public/invoices/download?token=xxxxx
```
规则:
- `token` 对应 `invoices.download_token`(随机生成)
- 仅当 `status=issued` 且存在 `invoice_file_url` 才允许下载
## 7. 错误码规划(建议)
建议新增发票模块错误码段:`70xxxx`
- `700001` 发票不存在
- `700002` 订单不允许开票(非 `paid`)
- `700003` 该订单已存在发票
- `700004` 发票状态不允许操作(如非 `pending` 不能驳回)
- `700005` 发票文件缺失/不可下载
## 8. 相关表结构(现有)
### 附录:现有表结构(参考)
-- license_manager.users definition
CREATE TABLE `users` (
`id` varchar(36) NOT NULL,
`username` varchar(50) NOT NULL,
`email` varchar(255) NOT NULL,
`password_hash` varchar(255) NOT NULL,
`full_name` varchar(100) NOT NULL,
`phone` varchar(20) DEFAULT NULL,
`role` varchar(20) NOT NULL DEFAULT 'viewer',
`status` varchar(20) NOT NULL DEFAULT 'active',
`last_login_at` datetime(3) DEFAULT NULL,
`last_login_ip` varchar(45) DEFAULT NULL,
`login_attempts` bigint NOT NULL DEFAULT '0',
`locked_until` datetime(3) DEFAULT NULL,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_users_username` (`username`),
UNIQUE KEY `idx_users_email` (`email`),
KEY `idx_users_role` (`role`),
KEY `idx_users_status` (`status`),
KEY `idx_users_last_login_at` (`last_login_at`),
KEY `idx_users_locked_until` (`locked_until`),
KEY `idx_users_created_at` (`created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- license_manager.cu_users definition
CREATE TABLE `cu_users` (
`id` varchar(36) NOT NULL,
`customer_id` varchar(36) NOT NULL,
`phone` varchar(20) NOT NULL,
`phone_country_code` varchar(10) NOT NULL DEFAULT '+86',
`password` varchar(255) DEFAULT NULL,
`salt` varchar(32) DEFAULT NULL,
`user_role` varchar(20) NOT NULL DEFAULT 'member',
`real_name` varchar(100) DEFAULT NULL,
`email` varchar(255) DEFAULT NULL,
`status` varchar(20) NOT NULL DEFAULT 'active',
`phone_verified` tinyint(1) NOT NULL DEFAULT '1',
`email_verified` tinyint(1) NOT NULL DEFAULT '0',
`last_login_at` datetime(3) DEFAULT NULL,
`last_login_ip` varchar(50) DEFAULT NULL,
`login_attempts` bigint NOT NULL DEFAULT '0',
`locked_until` datetime(3) DEFAULT NULL,
`avatar_url` varchar(500) DEFAULT NULL,
`language` varchar(10) NOT NULL DEFAULT 'zh-CN',
`timezone` varchar(50) NOT NULL DEFAULT 'Asia/Shanghai',
`additional_info` json DEFAULT NULL,
`remark` text,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_cu_users_customer_id` (`customer_id`),
KEY `idx_cu_users_email` (`email`),
KEY `idx_cu_users_status` (`status`),
KEY `idx_cu_users_last_login_at` (`last_login_at`),
KEY `idx_cu_users_locked_until` (`locked_until`),
KEY `idx_cu_users_created_at` (`created_at`),
KEY `idx_cu_users_deleted_at` (`deleted_at`),
KEY `idx_cu_users_phone` (`phone`,`phone_country_code`),
KEY `idx_cu_users_customer_status` (`customer_id`,`status`),
KEY `idx_cu_users_status_locked` (`status`,`locked_until`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- license_manager.cu_orders definition
CREATE TABLE `cu_orders` (
`id` varchar(36) NOT NULL,
`order_no` varchar(50) NOT NULL,
`customer_id` varchar(36) NOT NULL,
`cu_user_id` varchar(36) NOT NULL,
`package_id` varchar(50) NOT NULL,
`package_name` varchar(100) NOT NULL,
`license_count` bigint NOT NULL,
`unit_price` decimal(10,2) NOT NULL,
`discount_rate` decimal(3,2) NOT NULL DEFAULT '1.00',
`total_amount` decimal(10,2) NOT NULL,
`status` varchar(20) NOT NULL DEFAULT 'paid',
`authorization_code` varchar(500) DEFAULT NULL,
`expired_at` datetime(3) DEFAULT NULL,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_cu_orders_order_no` (`order_no`),
KEY `idx_cu_orders_customer_id` (`customer_id`),
KEY `idx_cu_orders_cu_user_id` (`cu_user_id`),
KEY `idx_cu_orders_package_id` (`package_id`),
KEY `idx_cu_orders_status` (`status`),
KEY `idx_cu_orders_expired_at` (`expired_at`),
KEY `idx_cu_orders_created_at` (`created_at`),
KEY `idx_cu_orders_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- license_manager.customers definition
CREATE TABLE `customers` (
`id` varchar(36) NOT NULL DEFAULT (uuid()),
`customer_code` varchar(20) NOT NULL,
`customer_name` varchar(200) NOT NULL,
`customer_type` varchar(20) NOT NULL DEFAULT 'enterprise',
`contact_person` varchar(100) NOT NULL,
`contact_title` varchar(100) DEFAULT NULL,
`email` varchar(255) DEFAULT NULL,
`phone` varchar(20) DEFAULT NULL,
`address` text,
`company_size` varchar(20) DEFAULT NULL,
`customer_level` varchar(20) NOT NULL DEFAULT 'basic',
`status` varchar(20) NOT NULL DEFAULT 'active',
`description` text,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`created_by` varchar(36) NOT NULL,
`updated_by` varchar(36) DEFAULT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_customers_customer_code` (`customer_code`),
KEY `idx_customers_customer_name` (`customer_name`),
KEY `idx_customers_customer_type` (`customer_type`),
KEY `idx_customers_customer_level` (`customer_level`),
KEY `idx_customers_status` (`status`),
KEY `idx_customers_created_at` (`created_at`),
KEY `idx_customers_created_by` (`created_by`),
KEY `idx_customers_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- license_manager.authorization_codes definition
CREATE TABLE `authorization_codes` (
`id` varchar(36) NOT NULL,
`code` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`customer_id` varchar(36) NOT NULL,
`created_by` varchar(36) NOT NULL,
`software_id` varchar(50) DEFAULT NULL,
`description` text,
`start_date` datetime(3) NOT NULL,
`end_date` datetime(3) NOT NULL,
`deployment_type` varchar(20) NOT NULL DEFAULT 'standalone',
`encryption_type` varchar(20) DEFAULT 'standard',
`software_version` varchar(50) DEFAULT NULL,
`max_activations` bigint NOT NULL DEFAULT '1',
`is_locked` tinyint(1) NOT NULL DEFAULT '0',
`lock_reason` text,
`locked_at` datetime(3) DEFAULT NULL,
`locked_by` varchar(36) DEFAULT NULL,
`feature_config` json DEFAULT NULL,
`usage_limits` json DEFAULT NULL,
`custom_parameters` json DEFAULT NULL,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_authorization_codes_customer_id` (`customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
@@ -0,0 +1,165 @@
# 设备管理
## 概述
设备管理是为客户用户提供的功能,用户可以在客户端查看和管理自己激活授权码的设备信息。
**核心业务逻辑:**
- 用户激活授权码后,设备会在平台生成许可证记录
- 一个客户可以有多个设备(受每个授权码的`max_activations`限制)
- 设备支持解绑操作,解绑后可以用授权码在其他设备重新激活
## 相关表结构
### licenses 表(许可证表)
```sql
CREATE TABLE `licenses` (
`id` varchar(36) NOT NULL,
`license_key` varchar(200) NOT NULL,
`authorization_code_id` varchar(36) NOT NULL,
`customer_id` varchar(36) NOT NULL,
`hardware_fingerprint` varchar(200) NOT NULL,
`device_info` json DEFAULT NULL,
`activation_ip` varchar(45) DEFAULT NULL,
`status` varchar(20) NOT NULL DEFAULT 'inactive',
`activated_at` datetime(3) DEFAULT NULL,
`last_heartbeat` datetime(3) DEFAULT NULL,
`last_online_ip` varchar(45) DEFAULT NULL,
`config_updated_at` datetime(3) DEFAULT NULL,
`usage_data` json DEFAULT NULL,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_licenses_license_key` (`license_key`),
KEY `idx_licenses_authorization_code_id` (`authorization_code_id`),
KEY `idx_licenses_customer_id` (`customer_id`),
KEY `idx_licenses_hardware_fingerprint` (`hardware_fingerprint`),
KEY `idx_licenses_status` (`status`),
KEY `idx_licenses_activated_at` (`activated_at`),
KEY `idx_licenses_last_heartbeat` (`last_heartbeat`),
KEY `idx_licenses_config_updated_at` (`config_updated_at`),
KEY `idx_licenses_created_at` (`created_at`),
KEY `idx_licenses_deleted_at` (`deleted_at`),
CONSTRAINT `fk_licenses_authorization_code` FOREIGN KEY (`authorization_code_id`) REFERENCES `authorization_codes` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT,
CONSTRAINT `fk_licenses_customer` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
### authorization_codes 表(授权码表)
```sql
CREATE TABLE `authorization_codes` (
`id` varchar(36) NOT NULL,
`code` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
`customer_id` varchar(36) NOT NULL,
`created_by` varchar(36) NOT NULL,
`software_id` varchar(50) DEFAULT NULL,
`description` text,
`start_date` datetime(3) NOT NULL,
`end_date` datetime(3) NOT NULL,
`deployment_type` varchar(20) NOT NULL DEFAULT 'standalone',
`encryption_type` varchar(20) DEFAULT 'standard',
`software_version` varchar(50) DEFAULT NULL,
`max_activations` bigint NOT NULL DEFAULT '1',
`is_locked` tinyint(1) NOT NULL DEFAULT '0',
`lock_reason` text,
`locked_at` datetime(3) DEFAULT NULL,
`locked_by` varchar(36) DEFAULT NULL,
`feature_config` json DEFAULT NULL,
`usage_limits` json DEFAULT NULL,
`custom_parameters` json DEFAULT NULL,
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_authorization_codes_customer_id` (`customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
## 配置要求
使用现有的心跳超时配置来判断设备在线状态:
```yaml
license:
heartbeat_timeout: 300 # 心跳超时时间(秒),超过此时间未上报心跳视为离线
```
## API 接口
### 1. 获取设备列表
**接口路径:** `GET /api/cu/devices`
**功能:** 根据用户token获取该客户的所有设备列表
**查询参数:**
- `page` (integer, optional): 页码,默认1
- `page_size` (integer, optional): 每页数量,默认20,最大100
- `device_name` (string, optional): 设备名称模糊搜索(匹配device_info中的name字段)
- `authorization_code_id` (string, optional): 按授权码ID筛选设备
**返回数据:**
```json
{
"code": "200",
"message": "success",
"data": {
"total": 25,
"page": 1,
"page_size": 20,
"list": [
{
"id": "license-uuid",
"device_info": {
"name": "我的电脑",
"os": "Windows 11",
"cpu": "Intel i7",
"memory": "16GB"
},
"is_online": true,
"last_online_ip": "192.168.1.100",
"last_heartbeat": "2026-01-03T10:30:00Z",
"activated_at": "2026-01-01T09:00:00Z",
"authorization_info": {
"authorization_code": "LIC-ABC123-XXXX...",
"authorization_code_id": "auth-code-uuid",
"end_date": "2026-12-31T23:59:59Z",
"description": "基础版许可"
}
}
]
}
}
```
**业务逻辑:**
1. 从用户token解析得到`customer_id`
2. 查询该客户的所有有效许可证(status='active'且未删除)
3. 支持按授权码ID筛选(可选)
4. 支持分页和设备名称模糊搜索
5. 在线状态计算:当前时间 - last_heartbeat < heartbeat_timeout(配置为300秒)
6. 授权信息通过关联`authorization_codes`表获取
### 2. 解绑设备
**接口路径:** `DELETE /api/cu/devices/{id}`
**功能:** 解绑指定设备,物理删除许可证记录
**路径参数:**
- `id` (string, required): 许可证ID
**返回数据:**
```json
{
"code": "200",
"message": "设备解绑成功"
}
```
**业务逻辑:**
1. 验证许可证ID存在且属于当前客户
2. 物理删除许可证记录(DELETE操作)
3. 删除后用户可以用该授权码在其他设备重新激活
4. `max_activations`限制检查在激活时处理,不在此接口处理
@@ -0,0 +1,229 @@
# 用户端授权码分享
## 📋 功能概述
用户可以将自己的授权码分享给其他用户,受赠用户可以激活使用该授权码。
## 🎯 核心需求
- **分享操作**:A用户可以将其授权码的部分激活次数分享给B用户
- **新码生成**:系统为B用户生成新的授权码,时间范围从分享时刻到原授权码结束时间
- **事务保证**:整个分享过程必须支持数据库事务,确保数据一致性
## 🔄 业务流程
### 分享操作流程:
1. A用户在后台选择要分享的授权码
2. 输入B用户的用户ID
3. 选择分享数量(最大分享数量 = 授权码最大激活数 - 已激活数)
4. 确认分享操作
### 系统处理逻辑:
1. **参数校验**:验证分享数量不超过可用激活数,验证目标用户存在
2. **扣减操作**:A用户的授权码 `max_activations` 减少相应数量
3. **生成新码**:为B用户重新生成授权码
- 时间范围:从当前时间到原授权码结束时间
- 激活数量:分享的数量
- 其他参数:与原授权码完全相同
4. **事务保证**:整个过程在数据库事务中执行,失败时完全回滚
## 🛠️ API接口设计
### 分享授权码
**接口**: `POST /api/v1/user/authorization-codes/{codeId}/share`
**请求参数**:
```json
{
"target_user_id": "string", // 受赠用户ID
"share_count": 5 // 分享激活次数
}
```
**响应格式**:
```json
{
"code": "000000",
"message": "分享成功",
"data": {
"new_authorization_code": {
"id": "string",
"code": "string",
"start_date": "2024-01-01T00:00:00Z",
"end_date": "2024-12-31T23:59:59Z",
"max_activations": 5
}
}
}
```
## ⚠️ 错误处理
| 错误码 | 错误场景 | HTTP状态码 |
|--------|----------|------------|
| 300101 | 授权码不存在 | 404 |
| 300102 | 授权码已被锁定 | 403 |
| 300103 | 分享数量超过可用激活数 | 400 |
| 300104 | 目标用户不存在 | 404 |
| 300105 | 不能分享给自己 | 400 |
| 300106 | 数据库事务失败 | 500 |
## 🔒 安全考虑
- **权限验证**:用户只能分享自己拥有的授权码
- **参数校验**:分享数量必须为正整数,不能超过可用激活数
- **用户验证**:目标用户必须存在且状态正常
- **防止自赠**:不能分享给自己
## 🏗️ 实现要点
### 技术架构要求:
- **分层架构**:Handler → Service → Repository
- **事务支持**:必须使用数据库事务保证数据一致性
- **多语言支持**:错误信息支持国际化
- **日志记录**:记录所有分享操作的详细日志
### 数据库事务:
```sql
BEGIN TRANSACTION;
-- 1. 减少原授权码的max_activations
UPDATE authorization_codes SET max_activations = max_activations - ? WHERE id = ?;
-- 2. 为目标用户创建新的授权码
INSERT INTO authorization_codes (...) VALUES (...);
COMMIT;
```
## 📊 数据表结构
### authorization_codes - 授权码表
```sql
CREATE TABLE `authorization_codes` (
`id` varchar(36) NOT NULL,
`code` varchar(1000) NOT NULL, -- 加密后的授权码
`customer_id` varchar(36) NOT NULL, -- 客户ID
`created_by` varchar(36) NOT NULL, -- 创建者用户ID
`software_id` varchar(50) DEFAULT NULL, -- 软件ID
`description` text, -- 描述
`start_date` datetime(3) NOT NULL, -- 开始时间
`end_date` datetime(3) NOT NULL, -- 结束时间
`deployment_type` varchar(20) NOT NULL DEFAULT 'standalone', -- 部署类型
`encryption_type` varchar(20) DEFAULT 'standard', -- 加密类型
`software_version` varchar(50) DEFAULT NULL, -- 软件版本
`max_activations` bigint NOT NULL DEFAULT '1', -- 最大激活次数
`is_locked` tinyint(1) NOT NULL DEFAULT '0', -- 是否锁定
`lock_reason` text, -- 锁定原因
`locked_at` datetime(3) DEFAULT NULL, -- 锁定时间
`locked_by` varchar(36) DEFAULT NULL, -- 锁定者
`feature_config` json DEFAULT NULL, -- 功能配置
`usage_limits` json DEFAULT NULL, -- 使用限制
`custom_parameters` json DEFAULT NULL, -- 自定义参数
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_authorization_codes_customer_id` (`customer_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
### licenses - 许可证表
```sql
CREATE TABLE `licenses` (
`id` varchar(36) NOT NULL,
`license_key` varchar(200) NOT NULL, -- 许可证密钥
`authorization_code_id` varchar(36) NOT NULL, -- 授权码ID
`customer_id` varchar(36) NOT NULL, -- 客户ID
`hardware_fingerprint` varchar(200) NOT NULL, -- 硬件指纹
`device_info` json DEFAULT NULL, -- 设备信息
`activation_ip` varchar(45) DEFAULT NULL, -- 激活IP
`status` varchar(20) NOT NULL DEFAULT 'inactive', -- 状态
`activated_at` datetime(3) DEFAULT NULL, -- 激活时间
`last_heartbeat` datetime(3) DEFAULT NULL,-- 最后心跳
`last_online_ip` varchar(45) DEFAULT NULL, -- 最后在线IP
`config_updated_at` datetime(3) DEFAULT NULL, -- 配置更新时间
`usage_data` json DEFAULT NULL, -- 使用数据
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_licenses_license_key` (`license_key`),
KEY `idx_licenses_authorization_code_id` (`authorization_code_id`),
KEY `idx_licenses_customer_id` (`customer_id`),
KEY `idx_licenses_hardware_fingerprint` (`hardware_fingerprint`),
KEY `idx_licenses_status` (`status`),
KEY `idx_licenses_activated_at` (`activated_at`),
KEY `idx_licenses_last_heartbeat` (`last_heartbeat`),
KEY `idx_licenses_config_updated_at` (`config_updated_at`),
KEY `idx_licenses_created_at` (`created_at`),
KEY `idx_licenses_deleted_at` (`deleted_at`),
CONSTRAINT `fk_licenses_authorization_code` FOREIGN KEY (`authorization_code_id`) REFERENCES `authorization_codes` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT,
CONSTRAINT `fk_licenses_customer` FOREIGN KEY (`customer_id`) REFERENCES `customers` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
### cu_users - 客户用户表
```sql
CREATE TABLE `cu_users` (
`id` varchar(36) NOT NULL,
`customer_id` varchar(36) NOT NULL, -- 客户ID
`phone` varchar(20) NOT NULL, -- 手机号
`phone_country_code` varchar(10) NOT NULL DEFAULT '+86', -- 国家代码
`password` varchar(255) DEFAULT NULL, -- 密码
`salt` varchar(32) DEFAULT NULL, -- 密码盐
`user_role` varchar(20) NOT NULL DEFAULT 'member', -- 用户角色
`real_name` varchar(100) DEFAULT NULL, -- 真实姓名
`email` varchar(255) DEFAULT NULL, -- 邮箱
`status` varchar(20) NOT NULL DEFAULT 'active', -- 状态
`phone_verified` tinyint(1) NOT NULL DEFAULT '1', -- 手机号验证
`email_verified` tinyint(1) NOT NULL DEFAULT '0', -- 邮箱验证
`last_login_at` datetime(3) DEFAULT NULL, -- 最后登录时间
`last_login_ip` varchar(50) DEFAULT NULL, -- 最后登录IP
`login_attempts` bigint NOT NULL DEFAULT '0', -- 登录尝试次数
`locked_until` datetime(3) DEFAULT NULL, -- 锁定到期时间
`avatar_url` varchar(500) DEFAULT NULL, -- 头像URL
`language` varchar(10) NOT NULL DEFAULT 'zh-CN', -- 语言
`timezone` varchar(50) NOT NULL DEFAULT 'Asia/Shanghai', -- 时区
`additional_info` json DEFAULT NULL, -- 附加信息
`remark` text, -- 备注
`created_at` datetime(3) NOT NULL,
`updated_at` datetime(3) NOT NULL,
`deleted_at` datetime(3) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_cu_users_customer_id` (`customer_id`),
KEY `idx_cu_users_email` (`email`),
KEY `idx_cu_users_status` (`status`),
KEY `idx_cu_users_last_login_at` (`last_login_at`),
KEY `idx_cu_users_locked_until` (`locked_until`),
KEY `idx_cu_users_created_at` (`created_at`),
KEY `idx_cu_users_deleted_at` (`deleted_at`),
KEY `idx_cu_users_phone` (`phone`,`phone_country_code`),
KEY `idx_cu_users_customer_status` (`customer_id`,`status`),
KEY `idx_cu_users_status_locked` (`status`,`locked_until`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
## 📋 开发规范
### 遵循原则:
- **分层架构**:Handler → Service → Repository
- **依赖注入**:通过接口解耦各层依赖
- **多语言支持**:错误信息支持国际化
- **统一响应**:遵循项目API响应格式规范
### 代码结构:
```
internal/
├── api/handlers/
│ └── user_authorization_handler.go # 用户授权处理器
├── service/
│ └── user_authorization_service.go # 用户授权业务逻辑
└── repository/
└── user_authorization_repository.go # 用户授权数据访问
```
### 质量检查:
- [ ] 分层职责是否清晰
- [ ] 多语言字段是否完整
- [ ] 错误处理是否统一
- [ ] 事务边界是否正确
- [ ] 参数校验是否完整
- [ ] 日志记录是否完善
@@ -0,0 +1,492 @@
# 支付宝支付对接设计文档
## 概述
本系统需要对接支付宝支付功能,主要用于用户购买产品套餐的支付场景。系统采用模块化设计,支持后续扩展其他支付方式(如微信支付等)。
## 业务需求
- 用户购买产品套餐时支持支付宝支付
- 支持支付状态查询和回调处理
- 支付成功后自动激活用户套餐
- 支持支付记录查询和管理
## 技术架构
基于现有的Clean Architecture设计,使用**支付宝官方Go SDK**实现支付功能:
### 核心组件
- **支付SDK**: `github.com/smartwalle/alipay/v3` - 官方支付宝Go SDK
- **支付服务**: 封装支付业务逻辑,集成官方SDK
- **支付仓储**: 支付数据持久化
- **支付处理器**: RESTful API接口
### 架构优势
- **官方维护**: 使用官方SDK,确保兼容性和安全性
- **简化开发**: 无需手动实现签名、验证等复杂逻辑
- **标准化**: 遵循支付宝官方API规范
- **易扩展**: 支持支付宝、微信等多支付方式
## 数据模型设计
### 通用支付设计:支付抽象与业务分离
采用最佳实践的支付抽象设计,支持多种支付方式和业务场景:
#### 设计原则
- **支付抽象**:payments表只包含支付相关的通用字段
- **业务关联**:通过`business_type`和`business_id`关联具体业务
- **扩展性**:支持支付宝、微信等多种支付方式
- **业务隔离**:支付失败不影响业务数据
#### 支付订单表 (payments)
```sql
CREATE TABLE payments (
id SERIAL PRIMARY KEY,
payment_no VARCHAR(64) UNIQUE NOT NULL COMMENT '支付单号',
business_type VARCHAR(50) NOT NULL COMMENT '业务类型 (package_order-套餐购买)',
business_id VARCHAR(36) NULL COMMENT '业务ID (关联cu_orders.id)',
user_id INTEGER NOT NULL COMMENT '用户ID',
amount DECIMAL(10,2) NOT NULL COMMENT '支付金额',
currency VARCHAR(3) DEFAULT 'CNY' COMMENT '货币类型',
payment_method VARCHAR(20) DEFAULT 'alipay' COMMENT '支付方式',
payment_provider VARCHAR(20) DEFAULT 'alipay' COMMENT '支付提供商',
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '支付状态',
trade_no VARCHAR(64) NULL COMMENT '第三方交易号',
payment_time TIMESTAMP NULL COMMENT '支付完成时间',
expire_time TIMESTAMP NOT NULL COMMENT '支付过期时间',
payment_url TEXT NULL COMMENT '支付链接',
notify_data JSONB NULL COMMENT '回调数据',
extra_data JSONB NULL COMMENT '扩展数据',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
);
-- 索引
CREATE INDEX idx_payments_payment_no ON payments(payment_no);
CREATE INDEX idx_payments_business_type_id ON payments(business_type, business_id);
CREATE INDEX idx_payments_user_id ON payments(user_id);
CREATE INDEX idx_payments_status ON payments(status);
CREATE INDEX idx_payments_created_at ON payments(created_at);
```
#### 业务类型枚举
- `package_order`: 套餐购买订单
- `license_extend`: 许可延期(预留)
- `service_upgrade`: 服务升级(预留)
#### 支付状态枚举
- `pending`: 待支付
- `paid`: 已支付
- `cancelled`: 已取消
- `expired`: 已过期
- `failed`: 支付失败
- `refunded`: 已退款
#### 支付方式枚举
- `alipay`: 支付宝
- `wechat`: 微信支付(预留)
- `bank`: 银行卡(预留)
## 官方SDK集成
### SDK选择
使用 `github.com/smartwalle/alipay/v3` 官方Go SDK,原因:
- **官方维护**: 支付宝官方团队维护,确保最新API支持
- **功能完整**: 支持支付、退款、查询等所有功能
- **签名处理**: 自动处理RSA签名和验证
- **类型安全**: 强类型接口,避免手动构造参数
- **社区活跃**: 有完整的文档和示例
### 核心用法
```go
// 1. 创建客户端
client, err := alipay.New(appID, privateKey, false)
// 2. 创建支付请求
payRequest := alipay.TradePagePay{
Trade: alipay.Trade{
OutTradeNo: "PAY20240101001",
TotalAmount: "100.00",
Subject: "产品购买",
ProductCode: "FAST_INSTANT_TRADE_PAY",
},
}
// 3. 生成支付URL
payURL, err := client.TradePagePay(payRequest)
// 4. 处理回调
notification, err := client.GetTradeNotification(request)
```
### 集成优势
- **零配置**: SDK自动处理签名和验证
- **错误处理**: 完善的错误类型和处理机制
- **性能优化**: 内部实现连接池和缓存
- **安全可靠**: 官方审核,确保安全合规
## API接口设计
### 1. 创建订单并支付
**方案选择:兼容性修改现有接口**
基于现有 `POST /api/cu/orders` 接口进行兼容性修改:
**接口路径**: `POST /api/cu/orders`(保持不变)
**新增请求参数**:
```json
{
"package_id": "basic",
"license_count": 100,
"payment_method": "alipay" // 新增:支付方式,不传则为免费订单
}
```
**响应格式变化**:
**免费订单(试用版)响应** - 保持原有格式:
```json
{
"code": "000000",
"message": "成功",
"data": {
"order_id": "order_id_123",
"order_no": "ORD202401010001",
"total_amount": 0.00,
"status": "paid",
"authorization_code": "AC-240101-A1B2C3D4",
"expired_at": "2024-01-25T23:59:59Z"
}
}
```
**付费订单响应** - 新增支付信息:
```json
{
"code": "000000",
"message": "成功",
"data": {
"order_id": "order_id_456",
"order_no": "ORD202401010002",
"payment_no": "PAY202401010002",
"total_amount": 24000.00,
"status": "pending",
"payment_url": "https://openapi.alipay.com/gateway.do?...",
"expire_time": "2024-01-01T13:00:00Z"
}
}
```
**业务逻辑**:
- 根据套餐类型和支付方式决定处理流程
- 免费订单:直接创建paid状态订单并生成授权码
- 付费订单:创建pending状态订单和支付单,返回支付链接
### 2. 查询订单状态
**接口**: `GET /api/cu/orders/{order_no}/status`(保持与现有API风格一致)
**响应**:
**免费订单状态**:
```json
{
"code": "000000",
"data": {
"order_no": "ORD202401010001",
"status": "paid",
"authorization_code": "AC-240101-A1B2C3D4",
"expired_at": "2024-01-25T23:59:59Z"
}
}
```
**付费订单状态**:
```json
{
"code": "000000",
"data": {
"order_no": "ORD202401010002",
"status": "paid",
"payment_status": "paid",
"authorization_code": "AC-240102-B2C3D4E5",
"payment_time": "2024-01-01T12:35:00Z"
}
}
```
**业务逻辑**:
- 兼容现有查询接口
- 对于付费订单,额外返回支付相关信息
### 3. 支付回调处理
**接口**: `POST /api/payment/alipay/callback`(兼容:`POST /api/v1/payment/alipay/callback`)
支付宝服务器回调,系统验证签名后更新订单状态。
### 4. 获取支付历史
**接口**: `GET /api/v1/payment/history`
支持分页查询用户的支付记录。
## 支付流程设计
### 1. 支付流程图
```
用户选择套餐 → 创建业务订单(cu_orders, status=pending) → 创建支付单(payments, pending) → 跳转支付 → 用户支付 → 支付回调 → 更新payments为paid → 更新cu_orders为paid → 生成授权码
```
### 2. 详细流程
1. **创建业务订单**
- 前端调用创建订单接口,传入套餐ID和许可数量
- 后端验证套餐配置,计算价格和折扣
- 在`cu_orders`表创建记录,状态为`pending`,生成业务订单
- **创建对应的支付单**,关联业务订单,状态为`pending`
- 调用支付接口生成支付链接
- 返回支付信息给前端
2. **支付处理**
- 用户跳转到支付页面完成支付
- 第三方支付平台处理支付并异步回调系统
3. **回调处理**
- 验证支付回调签名和数据
- 确认支付金额一致
- 在事务中执行:
- 更新`payments`表状态为`paid`
- 更新对应的`cu_orders`表状态为`paid`
- 生成授权码
- 返回成功响应给支付平台
4. **状态查询**
- 前端可查询支付状态
- 返回支付状态和业务订单信息
## 安全设计
### 1. 支付验证
- 使用支付平台公钥验证回调签名
- 防止伪造回调请求
- 支持多种支付方式的签名验证
### 2. 金额防篡改
- 支付单金额在创建时计算并存储
- 支付回调时二次验证金额
- 业务数据与支付数据分离,确保一致性
### 3. 防重复支付
- 支付单状态机严格控制
- 第三方交易号唯一性验证
- 支付过期时间控制
### 4. 业务隔离
- 支付失败:只更新payments状态,业务订单保持pending
- 支付成功:事务中同时更新payments和业务订单状态
- 支持多种业务类型的支付处理
## 配置管理
在`configs/config.yaml`中添加统一的支付配置:
```yaml
payment:
# 默认支付方式
default_method: alipay
# 支付提供商配置
providers:
alipay:
enabled: true
app_id: "your_app_id"
private_key: "your_private_key"
public_key: "alipay_public_key"
gateway_url: "https://openapi.alipay.com/gateway.do"
notify_url: "https://your-domain.com/api/payment/alipay/callback"
return_url: "https://your-domain.com/payment/success"
sign_type: "RSA2"
charset: "utf-8"
format: "json"
# 通用支付配置
expire_minutes: 30 # 支付过期时间(分钟)
retry_times: 3 # 支付重试次数
enable_log: true # 启用支付日志
```
**配置说明**:
- `default_method`: 默认支付方式,可设置为 `alipay` 或 `wechat`
- `providers`: 各支付提供商的详细配置,支持动态开启/关闭
- 通用配置:支付过期时间、重试次数、日志开关等
## 错误处理
### 支付相关错误码
- `PAYMENT_ORDER_NOT_FOUND`: 订单不存在
- `PAYMENT_ALREADY_PAID`: 订单已支付
- `PAYMENT_EXPIRED`: 订单已过期
- `PAYMENT_AMOUNT_MISMATCH`: 支付金额不匹配
- `PAYMENT_SIGNATURE_VERIFY_FAILED`: 签名验证失败
## 扩展性设计
### 1. 多支付方式支持
通过策略模式和工厂模式支持多种支付方式:
```go
// 支付策略接口
type PaymentStrategy interface {
CreatePayment(payment *Payment) (*PaymentResponse, error)
QueryPayment(paymentNo string) (*PaymentStatus, error)
HandleCallback(data map[string]interface{}) error
ValidateSignature(data map[string]interface{}) bool
}
// 支付工厂
type PaymentFactory struct {
strategies map[string]PaymentStrategy
}
func (f *PaymentFactory) GetStrategy(method string) PaymentStrategy {
return f.strategies[method]
}
```
### 2. 支持的支付方式
- **支付宝**:当前实现
- **微信支付**:预留扩展
- **银行卡**:预留扩展
### 3. 多业务场景支持
通过`business_type`支持不同业务场景:
```go
const (
BusinessTypePackageOrder = "package_order" // 套餐购买
BusinessTypeLicenseExtend = "license_extend" // 许可延期
BusinessTypeServiceUpgrade = "service_upgrade" // 服务升级
)
```
### 4. 配置说明
详见[配置管理](#配置管理)章节,支持支付宝、微信等多支付方式统一配置。
## 测试策略
### 1. 单元测试
- 支付服务逻辑测试
- 支付宝接口mock测试
- 回调处理测试
### 2. 集成测试
- 完整支付流程测试
- 支付宝沙箱环境测试
### 3. 沙箱环境
使用支付宝提供的沙箱环境进行测试,避免真实资金操作。
## 数据迁移和兼容性
### 新增payments表
创建通用的支付表,支持多种支付方式和业务场景:
```sql
-- 创建payments表(见数据模型设计部分)
-- business_type + business_id 支持关联不同业务
```
### API兼容性处理
#### 现有接口修改策略
**POST /api/cu/orders 接口变更**:
1. **向后兼容**:不传`payment_method`参数时,保持原有免费订单逻辑
2. **扩展功能**:传入`payment_method`参数时,启用支付流程
3. **响应差异**:免费订单返回授权码,付费订单返回支付链接
#### 迁移步骤
1. **代码层面**:
```go
// 修改现有创建订单逻辑
func CreateOrder(req CreateOrderRequest) (*OrderResponse, error) {
if req.PaymentMethod == "" {
// 免费订单:直接创建并返回授权码
return createFreeOrder(req)
} else {
// 付费订单:创建订单+支付单,返回支付信息
return createPaidOrder(req)
}
}
```
2. **数据库层面**:
- 新增payments表
- cu_orders表保持不变
- 通过关联关系连接
3. **前端适配**:
- 免费订单:无需修改,正常显示授权码
- 付费订单:跳转支付页面,支付完成后查询状态
#### 错误处理兼容
保持现有的错误码体系,新增支付相关错误码:
```yaml
cu_payment:
"602001": "支付失败"
"602002": "支付金额不匹配"
"602003": "重复支付"
"602004": "支付已过期"
```
## 部署注意事项
1. **网络要求**: 支付宝回调URL必须是公网可访问的HTTPS地址
2. **密钥安全**: 配置支付宝RSA私钥文件,严格控制访问权限
3. **数据库**: 确保payments表已创建,数据库连接池充足
4. **日志监控**: 支付相关日志级别设为INFO,配置告警监控支付成功率
5. **SDK依赖**: 确保 `github.com/smartwalle/alipay/v3` 已正确安装
6. **配置验证**: 部署前验证支付宝配置参数的正确性
7. **业务隔离**: 支付失败不影响业务订单,确保数据一致性
## 对接资料
### 支付宝官方文档
- [电脑网站支付API](https://opendocs.alipay.com/open/59da99d0_alipay.trade.page.pay)
- [SDK下载与文档](https://opendocs.alipay.com/open/54/103419)
- [签名验证指南](https://opendocs.alipay.com/open/291/105974)
### Go SDK资源
- [smartwalle/alipay GitHub](https://github.com/smartwalle/alipay)
- [SDK使用示例](https://github.com/smartwalle/alipay/tree/master/examples)
- [GoDoc文档](https://pkg.go.dev/github.com/smartwalle/alipay/v3)
### 沙箱环境
- [支付宝沙箱](https://open.alipay.com/develop/sandbox/app) - 用于开发测试
- [测试账号申请](https://opendocs.alipay.com/open/200/105311) - 获取测试环境账号
@@ -0,0 +1,38 @@
沙箱账号
商家信息
商户账号enjyeu8747@sandbox.com
登录密码111111
商户PID2088721092516360
账户余额1000000.00
买家信息
买家账号mgwlmn3858@sandbox.com
登录密码111111
支付密码111111
用户UID2088722092516378
用户名称mgwlmn3858
证件类型IDENTITY_CARD
证件账号471743193305122691
账户余额1000000.00
支付宝网关地址
https://openapi-sandbox.dl.alipaydev.com/gateway.do
websocket服务地址
openchannel-sandbox.dl.alipaydev.com
基本信息
APPID
9021000159611786
应用名称
sandbox 默认应用:2088721092516360
绑定的商家账号(PID)
2088721092516360
应用公钥:复制公钥
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhxBHcY3BLmA0glH1v1e9VnLFEc72ZyI5xNF77SMA5hGutz30SmzczjYvGJNYTeyWfIjIqtP0IA49XA61kpUPiTyBy2sxjDfu6ew+Yu2waynpI+NbkpIz8TWT1g7rqgZMsUexufmvFEpvn1AmboVKN8iZVflwc4lq5pQ/GS7uTlDK3fY32jEk6jxEHy8yZvkf467X0WK9AdrJ0IxUTOLBROdZySqg7mc2Iyo61xdDOuVCJjZSWFHt+s4UWx2VPFSbL2z4m82abGicAgj696J2PU9iLhQG2HA/EwrH8zsVyJPHTMgPFwngN9sxmT4U3OH8ugCnw/amEsD8VZR3PG3ypQIDAQAB
应用私钥:
复制私钥
MIIEogIBAAKCAQEAhxBHcY3BLmA0glH1v1e9VnLFEc72ZyI5xNF77SMA5hGutz30SmzczjYvGJNYTeyWfIjIqtP0IA49XA61kpUPiTyBy2sxjDfu6ew+Yu2waynpI+NbkpIz8TWT1g7rqgZMsUexufmvFEpvn1AmboVKN8iZVflwc4lq5pQ/GS7uTlDK3fY32jEk6jxEHy8yZvkf467X0WK9AdrJ0IxUTOLBROdZySqg7mc2Iyo61xdDOuVCJjZSWFHt+s4UWx2VPFSbL2z4m82abGicAgj696J2PU9iLhQG2HA/EwrH8zsVyJPHTMgPFwngN9sxmT4U3OH8ugCnw/amEsD8VZR3PG3ypQIDAQABAoIBAFSs2Q2Q4H7xo5ncRmQ6XqKW+4Ppq8MGyw7ggoAuBL84cL/C4KfUTi4d/DK9FYxs+f+DRcMLdK6wHrnbIuyN9RqFy527S2nsGyWejZI6D/NvaBGu1Vczv8rGWzPFUOvcXTJ7jynYJnJjJE3N0vpRKDGG2tLyuhRFH7cFU/W23YJM7TflTUUh1SG0Ec+jUa+ek5nK/vsqGE7A5p/D+mW4WLfS4sGhfe1XzNKjuCznTYajG79YXHXBKljqq4SKoEdh0SQHoEy7H0BXqArjSbh4KdHYoW/KkvA/c5QpbaTuWdK3iuvq7bZ1I0SEckL/rzufj+OcgoKcnwxZG2/Eq1Nb8tUCgYEAwcuyQW1AZEVl4dbk9Y4JXFeWh82LZ9pmyvr9tmqwQP4Ywlb9zUlXy+Zbv1wa9acfRWu9dYGm3SByFk8c3UGb/s3+eY4MribqkNVkjdotC/+wfymyf3gdLypGEXjBGwNJregGFeFmUhWnS+YWOiCiH401uDTU1pRtW37DZgdD5ZMCgYEAsmqIRL4U+3bkIb8u26lgS3k2WImen8+i91jVc8wWD7/aWGNmjodimyOv9AJRt34y5apzzfcWuzr9qdNQrzGubtKjaVM6s6NNVGzUJkLjF4WpyFDmX/ZLZkTW0gQLMYAjmiEJkPlbpHQ81vSMCT7W76RB31vL8pdRJMGBz/dN6ecCgYATzaXsi6Z92J2do2EIOrh4KDcor3dkaGwCp/1PK73RFmYfTdZnqOwA2QxdFKNooOhQJr9Yh6Quioc+Al2XuoGfWxt6/moLTczAeh3pZPcfd8RoajFkVzjcPfylAmrxQLfBvqkIiVz0XGQZHGz/CaCnir2HklaBiu9V3zFGds2YYwKBgFuaNryUm1FAiYCKGBTPsZEOesiaX8bNZG56PZ1mey0642/eGRm0iPv+JqixSCNdt+QSz8I/ynv2naZDyHkqmNRtcVjyUoFIprth5MqALPg2uCUQ5DcAzVWEoobRbK3NjHnl3HOCk3qr81Cp3fwhN1lyjB+RkfTLm+2zLGMKJgahAoGAGUigAVlCnUo6zj8PVOeUPJWV3tZf8ZkTtCXTjdp9tPwZqf0ZlmVZCoQB3nclF/6PgxgH7W596DlUt8TIrdmgg28HM8C2rpY/AoCw1H3cMkha8gCUN308cx0I2duea3tvDUb8thfvR34d7bxCI2hTOsplfE/MVv+sjzprGwtH/84=
支付宝公钥:复制公钥
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjkjXfe1fXjelyi6HIzFp5l9PGPxDBTi/TyIsRrF5NaYRCGqqSSJj1sc440eJ7InKRb4q/7cpdjlroC6DDO1OHKipld1pWSqoezc2lLVxvRWGt7Tl9mDfrul1/4ywx9OnIPBx3iJvzY15XDVWlZFYWVJFQv5EriLn+3xy+3gaazzJYCp/PumDqMTUgp0Kjk+HuHJ5/PKtFt3AtN2GB8glQEybl2Wf0qNZU9SzmEm+9I7CNvB/zUC3csRxJHTvbOtkjhCC/TrS20Xm8IFjygu9lBf9qzacXyfHjG6fLMU8aDn4kPAXL+wbqE2UzFmVvMNCLpEaIS3r7I8S2TGQLPDD6QIDAQAB
@@ -0,0 +1,182 @@
# 支付宝沙箱测试指南
## 🔧 配置准备
### 1. 启动服务
```bash
# 启动数据库和后端服务
cd backend
go run cmd/main.go
```
### 2. 验证配置
启动后检查日志,确认支付宝SDK初始化成功:
```
[INFO] Alipay client initialized successfully
```
## 🧪 测试步骤
### 步骤1: 创建订单并支付
#### API请求
```bash
# 1. 获取套餐列表
curl -X GET "http://localhost:18888/api/cu/packages" \
-H "Content-Type: application/json"
# 2. 计算价格
curl -X POST "http://localhost:18888/api/cu/orders/calculate" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"package_id": "basic",
"license_count": 1
}'
# 3. 创建付费订单
curl -X POST "http://localhost:18888/api/cu/orders" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"package_id": "basic",
"license_count": 1,
"payment_method": "alipay"
}'
```
#### 预期响应
```json
{
"code": "000000",
"message": "成功",
"data": {
"id": "order_xxx",
"order_no": "ORD20240101001",
"payment_no": "PAY20240101001",
"total_amount": 300.00,
"status": "pending",
"payment_url": "https://openapi-sandbox.dl.alipaydev.com/gateway.do?...",
"expire_time": "2024-01-01T13:00:00Z"
}
}
```
### 步骤2: 模拟支付
#### 方法1: 浏览器访问支付链接
1. 复制API响应中的 `payment_url`
2. 在浏览器中打开该链接
3. 使用沙箱买家账号登录:
- 账号:`mgwlmn3858@sandbox.com`
- 密码:`111111`
- 支付密码:`111111`
#### 方法2: 使用支付宝App扫码
如果支付链接是二维码,可以用支付宝App扫描支付
### 步骤3: 验证支付结果
#### 查询支付状态
```bash
curl -X GET "http://localhost:18888/api/payment/PAY20240101001/status" \
-H "Authorization: Bearer YOUR_TOKEN"
```
#### 预期成功响应
```json
{
"code": "000000",
"data": {
"payment_no": "PAY20240101001",
"status": "paid",
"amount": 300.00,
"payment_time": "2024-01-01T12:35:00Z",
"trade_no": "20240101220014xxxxxx",
"business_order": {
"order_no": "ORD20240101001",
"authorization_code": "AC-240101-XXXXXX",
"status": "paid"
}
}
}
```
## 🔍 常见问题排查
### 1. 支付链接无法访问
- 检查 `gateway_url` 是否正确
- 确认网络能访问支付宝沙箱环境
### 2. 回调失败
- 检查 `notify_url` 是否可公网访问
- 查看服务日志中的回调处理信息
### 3. 签名验证失败
- 确认私钥格式正确(PKCS8)
- 检查公钥是否匹配
### 4. 订单状态未更新
- 检查回调URL配置
- 查看支付宝回调日志
## 📊 测试数据
### 沙箱账号信息
- **买家账号**: mgwlmn3858@sandbox.com / 111111
- **支付密码**: 111111
- **余额**: 1,000,000.00
### 测试金额建议
- 小金额测试:0.01元
- 正常金额测试:1.00-100.00元
- 大金额测试:1000.00元
## 🐛 调试技巧
### 1. 查看详细日志
```bash
# 设置日志级别为debug
log:
level: "debug"
```
### 2. 手动触发回调测试
```bash
# 模拟支付宝回调(需构造正确的回调参数)
curl -X POST "http://localhost:18888/api/payment/alipay/callback" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "回调参数..."
```
### 3. 数据库检查
```sql
-- 查看支付订单
SELECT * FROM payments WHERE payment_no = 'PAY20240101001';
-- 查看业务订单
SELECT * FROM cu_orders WHERE order_no = 'ORD20240101001';
```
## ✅ 测试 checklist
- [ ] 服务启动成功,无错误日志
- [ ] 创建付费订单API返回支付链接
- [ ] 支付链接可正常访问支付宝沙箱
- [ ] 沙箱账号能正常登录和支付
- [ ] 支付完成后订单状态更新为paid
- [ ] 授权码正确生成
- [ ] 回调日志正常,无错误
## 🚀 生产环境部署
测试通过后,修改配置为生产环境:
1. 更换为正式APPID
2. 使用生产RSA密钥对
3. 修改网关地址为正式环境
4. 更新回调URL为生产域名
5. 配置生产数据库
---
**🎉 按照此指南测试,你的支付宝支付功能就能正常工作了!**
+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/feature_limit_change/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. **硬件更换** - 新硬件指纹触发新许可证生成
@@ -0,0 +1,193 @@
# 阿里云短信发送SDK集成
## 概述
基于阿里云短信服务,实现验证码发送功能。已集成到项目中,支持注册、登录、密码重置等场景。
## 已实现功能 ✅
- ✅ AccessKey认证方式(推荐)
- ✅ 短信发送API调用
- ✅ 错误处理和响应解析
- ✅ 缓存集成
- ✅ 频率限制
- ✅ 多环境配置支持
## 技术栈
```go
package main
import (
"context"
"fmt"
dysmsapi20170525 "github.com/alibabacloud-go/dysmsapi-20170525/v5/client"
openapi "github.com/alibabacloud-go/darabonba-openapi/v2/client"
util "github.com/alibabacloud-go/tea-utils/v2/service"
"github.com/alibabacloud-go/tea/tea"
)
// createSMSClient 创建阿里云SMS客户端 (AccessKey方式)
func (s *smsService) createSMSClient() (*dysmsapi20170525.Client, error) {
config := &openapi.Config{
AccessKeyId: tea.String(s.config.AccessKeyID),
AccessKeySecret: tea.String(s.config.AccessKeySecret),
}
// 设置地域和端点
if s.config.RegionID != "" {
config.RegionId = tea.String(s.config.RegionID)
} else {
config.RegionId = tea.String("cn-hangzhou")
}
if s.config.Endpoint != "" {
config.Endpoint = tea.String(s.config.Endpoint)
} else {
config.Endpoint = tea.String("dysmsapi.aliyuncs.com")
}
client, err := dysmsapi20170525.NewClient(config)
if err != nil {
return nil, err
}
return client, nil
}
// CreateClient 使用凭据方式初始化账号Client (阿里云示例)
// 工程代码建议使用更安全的无AK方式,凭据配置方式请参见:https://help.aliyun.com/document_detail/378661.html。
func CreateClientWithCredentials() (_result *dysmsapi20170525.Client, _err error) {
// 此方法仅供参考,项目中使用AccessKey方式
return _result, fmt.Errorf("credentials方式未实现,请使用AccessKey方式")
}
// sendSMS 调用阿里云API发送短信 (项目中的实现)
func (s *smsService) sendSMS(phone, templateCode, code string) error {
// 创建阿里云SMS客户端
client, err := s.createSMSClient()
if err != nil {
return fmt.Errorf("failed to create SMS client: %w", err)
}
// 构建发送短信请求
sendSmsRequest := &dysmsapi20170525.SendSmsRequest{
SignName: tea.String(s.config.SignName),
TemplateCode: tea.String(templateCode),
PhoneNumbers: tea.String(phone),
TemplateParam: tea.String(fmt.Sprintf(`{"code":"%s"}`, code)),
}
// 创建运行时选项
runtime := &util.RuntimeOptions{}
// 发送短信
resp, err := client.SendSmsWithOptions(sendSmsRequest, runtime)
if err != nil {
return fmt.Errorf("failed to send SMS: %w", err)
}
// 检查响应结果
if resp.Body == nil {
return fmt.Errorf("SMS response body is nil")
}
// 检查返回码
if tea.StringValue(resp.Body.Code) != "OK" {
return fmt.Errorf("SMS send failed, code: %s, message: %s",
tea.StringValue(resp.Body.Code),
tea.StringValue(resp.Body.Message))
}
// 记录成功发送的日志
fmt.Printf("SMS sent successfully, BizId: %s, Code: %s\n",
tea.StringValue(resp.Body.BizId),
tea.StringValue(resp.Body.Code))
return nil
}
// _main 阿里云官方示例代码 (仅供参考)
func _main(args []*string) (_err error) {
// 项目中不使用此方法,改用AccessKey方式
return fmt.Errorf("此示例方法已废弃,请查看sendSMS方法")
}
## 配置和使用
### 1. 配置文件
```yaml
# configs/config.yaml
sms:
enabled: true # 启用短信服务
access_key_id: "your-access-key-id" # 阿里云AccessKey ID
access_key_secret: "your-access-key-secret" # 阿里云AccessKey Secret
sign_name: "惠州顺视智能科技" # 短信签名
region_id: "cn-hangzhou" # 地域ID
endpoint: "dysmsapi.aliyuncs.com" # 服务端点
templates:
register: "SMS_330275014" # 注册验证码模板
reset_pwd: "SMS_330275014" # 重置密码验证码模板
login: "SMS_330275014" # 登录验证码模板
current_phone: "SMS_330275014" # 当前手机号验证码模板
new_phone: "SMS_330275014" # 新手机号验证码模板
```
### 2. 使用示例
```go
// 发送注册验证码
err := smsService.SendVerificationCode(ctx, "+8613800012345", "+86", "register")
if err != nil {
log.Printf("Failed to send SMS: %v", err)
}
// 验证验证码
valid, err := smsService.VerifyCode(ctx, "+8613800012345", "+86", "123456")
if err != nil {
log.Printf("Failed to verify code: %v", err)
}
if !valid {
log.Println("Invalid verification code")
}
```
### 3. 错误处理
项目中的SMS服务包含完善的错误处理:
- **认证错误**: AccessKey无效等
- **参数错误**: 手机号格式、模板不存在等
- **频率限制**: 自动频率控制和缓存
- **网络错误**: 重试和超时处理
### 4. 测试验证
```bash
# 运行SMS测试
go test ./pkg/utils/ -v -run TestSMS
# 查看测试输出,确认API调用正常
```
## 注意事项
1. **AccessKey安全**: 生产环境使用RAM用户AccessKey,不要使用主账号
2. **费用控制**: 阿里云SMS按条收费,注意测试用量
3. **频率限制**: 已实现客户端频率限制,避免被阿里云限制
4. **错误日志**: 重要错误已记录,便于排查问题
## 技术栈版本
- **阿里云SMS SDK**: `github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0`
- **OpenAPI SDK**: `github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13`
- **Tea Utils**: `github.com/alibabacloud-go/tea-utils/v2 v2.0.9`
- **Tea Core**: `github.com/alibabacloud-go/tea v1.4.0`
---
*此文档基于阿里云SMS服务官方SDK示例,项目中已完整实现并集成。*
+82
View File
@@ -0,0 +1,82 @@
# 开发提示词
> 用于指导 AI 助手在项目中的开发行为和决策方式
---
## 一、核心定位
作为一名经验丰富的开发工程师和架构师,你需要:
1. **主动审查** - 发现架构设计不合理或存在潜在问题时,必须及时与请求者讨论,说明具体问题和建议
2. **充分调研** - 不要凭空设计,而要基于现有实践和项目规范
3. **主动沟通** - 遇到无法决策的问题时,主动询问请求者而不是随便决策
---
## 二、开发原则
### 2.1 代码规范
| 规范项 | 要求 |
|:------|:----|
| 日志库 | 使用 logrus,不要使用其他日志库 |
| 注释 | 接口的请求和响应结构体需要加上**中文注释** |
| 接口参数 | 可选参数(非必输)必须使用指针类型(如 `*string`),以便正确区分“未传”和“明确传空”的区别 |
| 参数校验 | 返回无效请求错误(如 `900001`)时,必须在提示信息后追加具体的原因,例如:`Message: msg + ": " + err.Error()` |
| 设计原则 | 避免过度设计,保持代码的简洁和可读性 |
| 错误处理 | 不可为了处理错误而处理错误,让错误在源头尖叫,而不是让它在下游腐烂。|
### 2.2 接口设计
1. **参考学习** - 设计新接口前,先查看项目中至少 3 个已实现的类似接口
2. **保持一致** - 遵循项目现有的接口设计模式和命名规范
3. **文档完善** - 所有请求和响应结构体字段必须添加中文注释,说明字段用途、数据类型、是否必填、取值范围等
### 2.3 开发实施
- 如果当前项目已在开发中,按照项目现有代码规范和最佳实践开发,不要独辟蹊径
- 如果当前项目尚未开发,按照项目规范和最佳实践开发,不要独辟蹊径
- 代码风格、目录结构、命名方式应与项目保持一致
- 实现新功能前,先研究项目中类似功能的实现方式
---
## 三、沟通协作
### 必须主动询问的情况
| 情况 | 说明 |
|:----|:----|
| 需求不明确 | 需求存在歧义或不够清晰 |
| 技术方案选择 | 有多种技术方案可选择 |
| 重要决策 | 涉及架构或技术的重要决策 |
| 规范冲突 | 与现有规范冲突需要权衡 |
### 禁止的行为
- 不要在不确定的情况下自行决策
- 不要臆断需求,避免返工
---
## 四、行为准则
### 应该做的
- ✅ 主动反馈架构设计中的问题
- ✅ 设计接口前充分调研现有实现
- ✅ 保持代码风格与项目一致
- ✅ 为所有接口字段添加中文注释
- ✅ 遇到不确定的问题时主动询问
- ✅ 让错误在源头尖叫,而不是让它在下游腐烂
- ✅ 可选参数使用指针类型
- ✅ 参数校验报错追加具体原因 (`err.Error()`)
### 不应该做的
- ❌ 不要凭空设计,要基于现有实践
- ❌ 不要独辟蹊径,要遵循项目规范
- ❌ 不要在不确定的情况下自行决策
- ❌ 不要使用 logrus 以外的日志库
- ❌ 不要为了处理错误而处理错误
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+34 -3
View File
@@ -3,27 +3,46 @@ module license-manager
go 1.23
require (
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0
github.com/alibabacloud-go/tea v1.4.0
github.com/alibabacloud-go/tea-utils/v2 v2.0.9
github.com/gin-gonic/gin v1.10.0
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.4.0
github.com/redis/go-redis/v9 v9.17.2
github.com/shirou/gopsutil/v3 v3.24.5
github.com/sirupsen/logrus v1.9.3
github.com/smartwalle/alipay/v3 v3.2.28
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
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/aliyun/credentials-go v1.4.5 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
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 +50,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 +67,33 @@ 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/shoenig/go-m1cpu v0.1.6 // indirect
github.com/smartwalle/ncrypto v1.0.4 // indirect
github.com/smartwalle/ngx v1.0.12 // indirect
github.com/smartwalle/nsign v1.0.9 // 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/tjfoc/gmsm v1.4.1 // 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/net v0.26.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
)
+250 -5
View File
@@ -1,22 +1,90 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
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=
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 h1:Q00FU3H94Ts0ZIHDmY+fYGgB7dV9D/YX6FGsgorQPgw=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0 h1:rYyjylSQ0xdA2AaYIFky9CSyKDOZpEHdAsIMAcbSIjw=
github.com/alibabacloud-go/dysmsapi-20170525/v5 v5.4.0/go.mod h1:6ax2auNlAXYhdIqYOTpXWm7vS9AwaWigZFzHKkDm9k8=
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea v1.4.0 h1:MSKhu/kWLPX7mplWMngki8nNt+CyUZ+kfkzaR5VpMhA=
github.com/alibabacloud-go/tea v1.4.0/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9 h1:y6pUIlhjxbZl9ObDAcmA1H3c21eaAxADHTDQmBnAIgA=
github.com/alibabacloud-go/tea-utils/v2 v2.0.9/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk=
github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
@@ -29,6 +97,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,19 +117,47 @@ 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/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
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.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
@@ -73,6 +171,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=
@@ -86,6 +186,8 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
@@ -95,14 +197,36 @@ 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/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
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/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartwalle/alipay/v3 v3.2.28 h1:pANvguTjmFtfTw3le44qjQHYDrRcjCy8r8YUonFeblE=
github.com/smartwalle/alipay/v3 v3.2.28/go.mod h1:dwPyjY5y17qUsDrsVYCQqmDBtQ/qpvY8JmQMvuJYGLQ=
github.com/smartwalle/ncrypto v1.0.4 h1:P2rqQxDepJwgeO5ShoC+wGcK2wNJDmcdBOWAksuIgx8=
github.com/smartwalle/ncrypto v1.0.4/go.mod h1:Dwlp6sfeNaPMnOxMNayMTacvC5JGEVln3CVdiVDgbBk=
github.com/smartwalle/ngx v1.0.12 h1:jcoCyu/0HtQ1y/gbiSLzqOUZcHnVLlKOmm0awRF7Mcg=
github.com/smartwalle/ngx v1.0.12/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0=
github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E=
github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
@@ -114,10 +238,12 @@ github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -134,11 +260,22 @@ 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/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
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.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
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=
@@ -147,27 +284,79 @@ golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUu
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210421230115-4e50805a0758/go.mod h1:72T/g9IO56b78aLF+1Kcs5dz7/ng1VjMUvfKvpfy+jM=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.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-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/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,38 +364,94 @@ 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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
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=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+52 -29
View File
@@ -2,10 +2,14 @@ package handlers
import (
"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"
"github.com/gin-gonic/gin"
)
type AuthHandler struct {
@@ -34,34 +38,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 +91,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 +114,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 +126,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,
},
@@ -132,7 +155,7 @@ func (h *AuthHandler) RefreshToken(c *gin.Context) {
// getCurrentTimestamp 获取当前时间戳
func getCurrentTimestamp() string {
return "2024-07-30T12:00:00Z" // 简化实现,实际应该使用time.Now().Format(time.RFC3339)
return time.Now().Format(time.RFC3339)
}
// extractTokenFromHeader 从请求头提取Token
@@ -142,4 +165,4 @@ func extractTokenFromHeader(c *gin.Context) string {
return authHeader[7:]
}
return ""
}
}
@@ -0,0 +1,647 @@
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 code query string false "授权码(模糊匹配)"
// @Param status query string false "状态筛选" Enums(normal, locked, expired)
// @Param start_date query string false "创建开始时间"
// @Param end_date query string false "创建结束时间"
// @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,443 @@
package handlers
import (
"net/http"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"license-manager/pkg/utils"
"github.com/gin-gonic/gin"
)
type CuAuthHandler struct {
cuUserService service.CuUserService
}
func NewCuAuthHandler(cuUserService service.CuUserService) *CuAuthHandler {
return &CuAuthHandler{
cuUserService: cuUserService,
}
}
// CuUserRegister 客户用户注册
// @Summary 客户用户注册
// @Description 通过手机号注册客户用户账号
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Param user body models.CuUserRegisterRequest true "注册信息"
// @Success 200 {object} models.APIResponse{data=object{user=object{id=string,customer_id=string,phone=string,real_name=string,email=string,user_role=string,status=string},token=string}} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 409 {object} models.ErrorResponse "手机号已被注册"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/register [post]
func (h *CuAuthHandler) CuUserRegister(c *gin.Context) {
var req models.CuUserRegisterRequest
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: getCurrentTimestamp(),
})
return
}
// 验证短信验证码
phoneCountryCode := req.PhoneCountryCode
if phoneCountryCode == "" {
phoneCountryCode = "+86"
}
valid, err := h.cuUserService.VerifyRegisterSmsCode(c.Request.Context(), req.Phone, phoneCountryCode, req.SmsCode)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
if !valid {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("500012", lang) // 验证码错误或已过期
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
user, err := h.cuUserService.Register(c.Request.Context(), &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 生成JWT Token
token, err := utils.GenerateCuToken(user.ID, user.CustomerID, user.UserRole, user.Phone)
if err != nil {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("500001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: gin.H{
"user": gin.H{
"id": user.ID,
"customer_id": user.CustomerID,
"phone": user.Phone,
"real_name": user.RealName,
"email": user.Email,
"user_role": user.UserRole,
"status": user.Status,
},
"token": token,
},
})
}
// CuUserLogin 客户用户登录
// @Summary 客户用户登录
// @Description 通过手机号和密码或短信验证码登录客户用户账号
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Param user body models.CuUserLoginRequest true "登录信息"
// @Success 200 {object} models.APIResponse{data=object{user=object{id=string,customer_id=string,phone=string,real_name=string,email=string,user_role=string,status=string},token=string}} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "手机号、密码或验证码错误"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/login [post]
func (h *CuAuthHandler) CuUserLogin(c *gin.Context) {
var req models.CuUserLoginRequest
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: getCurrentTimestamp(),
})
return
}
// 获取客户端IP
ip := c.ClientIP()
user, token, err := h.cuUserService.Login(c.Request.Context(), &req, ip)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: gin.H{
"user": gin.H{
"id": user.ID,
"customer_id": user.CustomerID,
"phone": user.Phone,
"real_name": user.RealName,
"email": user.Email,
"user_role": user.UserRole,
"status": user.Status,
},
"token": token,
},
})
}
// CuUserForgotPassword 忘记密码 - 发送验证码
// @Summary 忘记密码 - 发送验证码
// @Description 提交手机号,发送密码重置验证码到手机
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Param request body models.CuUserForgotPasswordRequest true "忘记密码请求"
// @Success 200 {object} models.APIResponse "验证码发送成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 404 {object} models.ErrorResponse "用户不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/forgot-password [post]
func (h *CuAuthHandler) CuUserForgotPassword(c *gin.Context) {
var req models.CuUserForgotPasswordRequest
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: getCurrentTimestamp(),
})
return
}
err := h.cuUserService.ForgotPassword(c.Request.Context(), &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// CuUserSendLoginSms 登录发送验证码
// @Summary 登录发送验证码
// @Description 登录前发送短信验证码到手机
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Param request body models.CuUserSendLoginSmsRequest true "登录发送验证码请求"
// @Success 200 {object} models.APIResponse "验证码发送成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 404 {object} models.ErrorResponse "用户不存在"
// @Failure 429 {object} models.ErrorResponse "请求过于频繁"
// @Failure 500 {object} models.ErrorResponse "短信发送失败"
// @Router /api/cu/send-login-sms [post]
func (h *CuAuthHandler) CuUserSendLoginSms(c *gin.Context) {
var req models.CuUserSendLoginSmsRequest
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: getCurrentTimestamp(),
})
return
}
err := h.cuUserService.SendLoginSms(c.Request.Context(), &req)
if err != nil {
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// CuUserSendRegisterSms 注册发送验证码
// @Summary 注册发送验证码
// @Description 注册前发送短信验证码到手机
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Param request body models.CuUserSendRegisterSmsRequest true "注册发送验证码请求"
// @Success 200 {object} models.APIResponse "验证码发送成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 409 {object} models.ErrorResponse "手机号已被注册"
// @Failure 429 {object} models.ErrorResponse "请求过于频繁"
// @Failure 500 {object} models.ErrorResponse "短信发送失败"
// @Router /api/cu/send-register-sms [post]
func (h *CuAuthHandler) CuUserSendRegisterSms(c *gin.Context) {
var req models.CuUserSendRegisterSmsRequest
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: getCurrentTimestamp(),
})
return
}
err := h.cuUserService.SendRegisterSms(c.Request.Context(), &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// CuUserResetPassword 重置密码
// @Summary 重置密码
// @Description 通过验证码重置密码
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Param request body models.CuUserResetPasswordRequest true "重置密码请求"
// @Success 200 {object} models.APIResponse "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 409 {object} models.ErrorResponse "重置密码失败"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/reset-password [post]
func (h *CuAuthHandler) CuUserResetPassword(c *gin.Context) {
var req models.CuUserResetPasswordRequest
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: getCurrentTimestamp(),
})
return
}
err := h.cuUserService.ResetPassword(c.Request.Context(), &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
@@ -0,0 +1,278 @@
package handlers
import (
"net/http"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"license-manager/pkg/utils"
"github.com/gin-gonic/gin"
)
type CuAuthorizationHandler struct {
authCodeService service.AuthorizationCodeService
}
func NewCuAuthorizationHandler(authCodeService service.AuthorizationCodeService) *CuAuthorizationHandler {
return &CuAuthorizationHandler{
authCodeService: authCodeService,
}
}
// ShareAuthorizationCode 用户分享授权码
// @Summary 用户分享授权码
// @Description 用户可以将自己的授权码分享给其他用户(通过手机号或邮箱)
// @Tags 用户端授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param codeId path string true "授权码ID"
// @Param request body models.AuthorizationCodeShareRequest true "分享请求"
// @Success 200 {object} models.APIResponse{data=models.AuthorizationCodeShareResponse} "分享成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "授权码已被锁定"
// @Failure 404 {object} models.ErrorResponse "授权码不存在或目标用户不存在"
// @Failure 409 {object} models.ErrorResponse "不能分享给自己或分享数量超过可用激活数"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/authorization-codes/{codeId}/share [post]
func (h *CuAuthorizationHandler) ShareAuthorizationCode(c *gin.Context) {
// 从JWT Token中获取用户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
userID := claims.UserID
// 获取路径参数
codeID := c.Param("codeId")
if codeID == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
var req models.AuthorizationCodeShareRequest
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: getCurrentTimestamp(),
})
return
}
// 调用服务层处理分享逻辑
data, err := h.authCodeService.ShareAuthorizationCode(c.Request.Context(), codeID, userID, &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// GetProductActivationCode 获取产品激活码
// @Summary 获取产品激活码
// @Description 获取产品激活码:{授权码}&{payload}(payload 为 RSA-PSS-SHA256 签名封装串)
// @Tags 用户端授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body models.ProductActivationCodeRequest true "获取产品激活码请求"
// @Success 200 {object} models.APIResponse{data=models.ProductActivationCodeResponse} "获取成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "授权码已被锁定"
// @Failure 404 {object} models.ErrorResponse "授权码不存在或已过期"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/authorization-codes/product-activation-code [post]
func (h *CuAuthorizationHandler) GetProductActivationCode(c *gin.Context) {
claims := c.MustGet("cu_user").(*utils.CuClaims)
customerID := claims.CustomerID
var req models.ProductActivationCodeRequest
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: getCurrentTimestamp(),
})
return
}
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.authCodeService.GetProductActivationCode(ctx, customerID, &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
})
}
// GetCuAuthorizationCodes 获取用户授权码列表
// @Summary 获取用户授权码列表
// @Description 获取当前登录用户的授权码列表,支持分页、状态筛选与授权码模糊搜索
// @Tags 用户端授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(10)
// @Param status query string false "状态筛选" Enums(normal, locked, expired)
// @Param search query string false "授权码模糊匹配"
// @Success 200 {object} models.APIResponse{data=models.CuAuthorizationCodeListResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/authorization-codes [get]
func (h *CuAuthorizationHandler) GetCuAuthorizationCodes(c *gin.Context) {
claims := c.MustGet("cu_user").(*utils.CuClaims)
var req models.CuAuthorizationCodeListRequest
if err := c.ShouldBindQuery(&req); err != nil {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.authCodeService.GetCuAuthorizationCodeList(ctx, claims.CustomerID, &req)
if err != nil {
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
Timestamp: getCurrentTimestamp(),
})
}
// GetCuAuthorizationCodeSummary 获取用户授权信息统计
// @Summary 获取用户授权信息统计
// @Description 获取当前登录用户的授权码统计信息
// @Tags 用户端授权码管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.CuAuthorizationCodeSummaryResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/authorization-codes/summary [get]
func (h *CuAuthorizationHandler) GetCuAuthorizationCodeSummary(c *gin.Context) {
claims := c.MustGet("cu_user").(*utils.CuClaims)
ctx := middleware.WithLanguage(c.Request.Context(), c)
data, err := h.authCodeService.GetCuAuthorizationCodeSummary(ctx, claims.CustomerID)
if err != nil {
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: data,
Timestamp: getCurrentTimestamp(),
})
}
@@ -0,0 +1,203 @@
package handlers
import (
"net/http"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"license-manager/pkg/utils"
"github.com/gin-gonic/gin"
)
type CuDeviceHandler struct {
cuDeviceService service.CuDeviceService
}
func NewCuDeviceHandler(cuDeviceService service.CuDeviceService) *CuDeviceHandler {
return &CuDeviceHandler{
cuDeviceService: cuDeviceService,
}
}
// GetDevices 获取设备列表
// @Summary 获取设备列表
// @Description 获取当前登录用户的设备列表,支持分页和筛选
// @Tags 客户设备管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码,默认1"
// @Param page_size query int false "每页数量,默认20,最大100"
// @Param device_name query string false "设备名称模糊搜索"
// @Param authorization_code_id query string false "按授权码ID筛选设备"
// @Param is_online query bool false "是否在线筛选:true在线,false离线"
// @Success 200 {object} models.APIResponse{data=models.DeviceListResponse} "获取成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/devices [get]
func (h *CuDeviceHandler) GetDevices(c *gin.Context) {
// 从JWT Token中获取用户ID和客户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
// 解析查询参数
var req models.DeviceListRequest
if err := c.ShouldBindQuery(&req); err != nil {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
// 调用服务层
result, err := h.cuDeviceService.GetDeviceList(c.Request.Context(), claims.CustomerID, &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: result,
})
}
// UnbindDevice 解绑设备
// @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 403 {object} models.ErrorResponse "无权限操作此设备"
// @Failure 404 {object} models.ErrorResponse "设备不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/devices/{id} [delete]
func (h *CuDeviceHandler) UnbindDevice(c *gin.Context) {
// 从JWT Token中获取用户ID和客户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
// 获取路径参数
licenseID := c.Param("id")
if licenseID == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": license id is required",
Timestamp: getCurrentTimestamp(),
})
return
}
// 调用服务层
err := h.cuDeviceService.UnbindDevice(c.Request.Context(), claims.CustomerID, licenseID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// GetDeviceSummary 获取设备汇总统计
// @Summary 获取设备汇总统计
// @Description 获取当前登录用户的设备汇总统计信息(总数、在线数、离线数)
// @Tags 客户设备管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.DeviceSummaryResponse} "获取成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/devices/summary [get]
func (h *CuDeviceHandler) GetDeviceSummary(c *gin.Context) {
// 从JWT Token中获取用户ID和客户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
// 调用服务层
result, err := h.cuDeviceService.GetDeviceSummary(c.Request.Context(), claims.CustomerID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: result,
})
}
@@ -0,0 +1,253 @@
package handlers
import (
"net/http"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"github.com/gin-gonic/gin"
)
type CuInvoiceHandler struct {
invoiceService service.CuInvoiceService
}
func NewCuInvoiceHandler(invoiceService service.CuInvoiceService) *CuInvoiceHandler {
return &CuInvoiceHandler{
invoiceService: invoiceService,
}
}
// CreateInvoice 客户申请发票
// @Summary 客户申请发票
// @Description 客户为已支付的订单申请发票
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body models.InvoiceCreateRequest true "发票申请请求"
// @Success 200 {object} models.APIResponse{data=object{id=string,invoice_no=string,status=string}} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices [post]
func (h *CuInvoiceHandler) CreateInvoice(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.InvoiceCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
invoice, err := h.invoiceService.CreateInvoice(c.Request.Context(), getCuUserID(c), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
response := invoice.ToResponse()
// 只返回关键信息
result := map[string]interface{}{
"id": response.ID,
"invoice_no": response.InvoiceNo,
"status": response.Status,
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: result,
Timestamp: getCurrentTimestamp(),
})
}
// GetUserInvoices 客户发票列表
// @Summary 客户发票列表
// @Description 获取当前客户的发票列表
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码,默认1" minimum(1)
// @Param page_size query int false "每页条数,默认10,最大100" minimum(1) maximum(100)
// @Param status query string false "状态筛选,pending-待处理/issued-已开票/rejected-已驳回"
// @Param search query string false "搜索关键词,支持发票号或订单号模糊匹配"
// @Param apply_date query string false "申请日期筛选,格式YYYY-MM-DD 或 RFC3339范围(start,end)"
// @Success 200 {object} models.APIResponse{data=models.InvoiceListResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices [get]
func (h *CuInvoiceHandler) GetUserInvoices(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.InvoiceListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
invoices, err := h.invoiceService.GetUserInvoices(c.Request.Context(), getCuUserID(c), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoices,
Timestamp: getCurrentTimestamp(),
})
}
// GetUserInvoiceDetail 客户发票详情
// @Summary 客户发票详情
// @Description 获取指定发票的详细信息
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "发票ID"
// @Success 200 {object} models.APIResponse{data=models.InvoiceResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices/{id} [get]
func (h *CuInvoiceHandler) GetUserInvoiceDetail(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
invoice, err := h.invoiceService.GetInvoice(c.Request.Context(), invoiceID, getCuUserID(c))
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoice.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// GetUserInvoiceSummary 客户发票汇总信息
// @Summary 客户发票汇总信息
// @Description 获取当前客户的发票统计汇总信息
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.InvoiceSummaryResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices/summary [get]
func (h *CuInvoiceHandler) GetUserInvoiceSummary(c *gin.Context) {
lang := middleware.GetLanguage(c)
summary, err := h.invoiceService.GetInvoiceSummary(c.Request.Context(), getCuUserID(c))
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: summary,
Timestamp: getCurrentTimestamp(),
})
}
// DownloadInvoice 客户发票下载
// @Summary 客户发票下载
// @Description 下载指定发票的文件
// @Tags 发票管理
// @Accept json
// @Produce application/pdf
// @Security BearerAuth
// @Param id path string true "发票ID"
// @Success 200 {file} binary "发票文件"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在或文件不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices/{id}/download [get]
func (h *CuInvoiceHandler) DownloadInvoice(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
fileURL, err := h.invoiceService.DownloadInvoice(c.Request.Context(), invoiceID, getCuUserID(c))
if err != nil {
handleI18nError(c, err, lang)
return
}
// 重定向到文件URL
c.Redirect(http.StatusFound, fileURL)
}
// UpdateInvoice 客户修改发票申请
// @Summary 客户修改发票申请
// @Description 客户修改已被驳回的发票申请并重新提交
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "发票ID"
// @Param request body models.InvoiceUpdateRequest true "发票修改请求"
// @Success 200 {object} models.APIResponse{data=object{id=string,invoice_no=string,status=string}} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/invoices/{id} [put]
func (h *CuInvoiceHandler) UpdateInvoice(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
var req models.InvoiceUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
invoice, err := h.invoiceService.UpdateInvoice(c.Request.Context(), invoiceID, getCuUserID(c), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
response := invoice.ToResponse()
result := map[string]interface{}{
"id": response.ID,
"invoice_no": response.InvoiceNo,
"status": response.Status,
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: result,
Timestamp: getCurrentTimestamp(),
})
}
@@ -0,0 +1,684 @@
package handlers
import (
"net/http"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"license-manager/pkg/utils"
"github.com/gin-gonic/gin"
)
type CuOrderHandler struct {
cuOrderService service.CuOrderService
paymentService service.PaymentService
packageService service.PackageService
}
func NewCuOrderHandler(cuOrderService service.CuOrderService, paymentService service.PaymentService, packageService service.PackageService) *CuOrderHandler {
return &CuOrderHandler{
cuOrderService: cuOrderService,
paymentService: paymentService,
packageService: packageService,
}
}
// GetPackages 获取套餐列表
// @Summary 获取套餐列表
// @Description 获取所有可用的产品套餐信息
// @Tags 客户订单管理
// @Accept json
// @Produce json
// @Success 200 {object} models.APIResponse{data=[]object{id=string,name=string,type=string,price=number,max_devices=integer}} "成功"
// @Router /api/cu/packages [get]
func (h *CuOrderHandler) GetPackages(c *gin.Context) {
lang := middleware.GetLanguage(c)
// 从数据库获取套餐列表
packages, err := h.packageService.GetCuPackageList(c.Request.Context())
if err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("800005", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 转换为map数组格式(兼容原有结构)
result := make([]map[string]interface{}, len(packages))
for i, pkg := range packages {
result[i] = map[string]interface{}{
"id": pkg.ID,
"name": pkg.Name,
"type": pkg.Type,
"price": pkg.Price,
"display_price": pkg.DisplayPrice,
"price_description": pkg.PriceDescription,
"max_devices": pkg.MaxDevices,
"description": pkg.Description,
"features": pkg.Features,
"details": pkg.Details,
}
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: result,
Timestamp: getCurrentTimestamp(),
})
}
// CalculatePrice 计算订单价格
// @Summary 计算订单价格
// @Description 根据套餐和许可数量计算价格和折扣
// @Tags 客户订单管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body object{package_id=string,license_count=integer} true "价格计算请求"
// @Success 200 {object} models.APIResponse{data=object{unit_price=number,license_count=integer,discount_rate=number,total_amount=number,discount_description=string}} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 404 {object} models.ErrorResponse "套餐不存在"
// @Router /api/cu/orders/calculate [post]
func (h *CuOrderHandler) CalculatePrice(c *gin.Context) {
var req struct {
PackageID string `json:"package_id" binding:"required"`
LicenseCount int `json:"license_count" binding:"required,min=1,max=1000"`
}
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: getCurrentTimestamp(),
})
return
}
// 获取用户ID(用于后续业务逻辑扩展)
_, exists := c.Get("cu_user_id")
if !exists {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("100004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 计算价格
result, err := h.cuOrderService.CalculatePrice(c.Request.Context(), req.PackageID, req.LicenseCount)
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
Message: err.(*i18n.I18nError).Message, // 直接使用错误对象中的消息
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMsg := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMsg,
Data: result,
Timestamp: getCurrentTimestamp(),
})
}
// CreateOrder 创建订单(支持支付)
// @Summary 创建订单
// @Description 创建新的产品套餐订单,支持免费和付费模式
// @Tags 客户订单管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param order body models.CuOrderCreateRequest true "订单创建信息"
// @Success 200 {object} models.APIResponse{data=object{id=string,order_no=string,total_amount=number,authorization_code=string}} "免费订单成功"
// @Success 200 {object} models.APIResponse{data=object{id=string,order_no=string,payment_no=string,total_amount=number,payment_url=string,expire_time=string}} "付费订单成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 404 {object} models.ErrorResponse "套餐不存在"
// @Failure 409 {object} models.ErrorResponse "当月已购买试用版"
// @Router /api/cu/orders [post]
func (h *CuOrderHandler) CreateOrder(c *gin.Context) {
var req models.CuOrderCreateRequest
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: getCurrentTimestamp(),
})
return
}
// 获取用户ID和客户ID
cuUserID, exists := c.Get("cu_user_id")
if !exists {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("100004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
customerID, exists := c.Get("cu_customer_id")
if !exists {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("100004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 先计算价格,根据金额决定处理逻辑
priceResult, err := h.cuOrderService.CalculatePrice(c.Request.Context(), req.PackageID, req.LicenseCount)
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
Message: err.(*i18n.I18nError).Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果金额为0(免费/试用版),直接生成授权码,无论是否传了payment_method
if priceResult.TotalAmount == 0 {
h.createFreeOrder(c, cuUserID.(string), customerID.(string), &req)
return
}
// 如果金额大于0,根据是否传了payment_method决定处理逻辑
if req.PaymentMethod == "" {
// 金额大于0但没有传支付方式,走免费订单流程(直接生成授权码)
h.createFreeOrder(c, cuUserID.(string), customerID.(string), &req)
} else {
// 付费订单:创建订单和支付单
h.createPaidOrder(c, cuUserID.(string), customerID.(string), &req)
}
}
// createFreeOrder 创建免费订单
func (h *CuOrderHandler) createFreeOrder(c *gin.Context, cuUserID, customerID string, req *models.CuOrderCreateRequest) {
order, err := h.cuOrderService.CreateOrder(c.Request.Context(), cuUserID, customerID, req)
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
Message: err.(*i18n.I18nError).Message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMsg := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMsg,
Data: gin.H{
"id": order.ID,
"order_no": order.OrderNo,
"total_amount": order.TotalAmount,
"authorization_code": order.AuthorizationCode,
},
Timestamp: getCurrentTimestamp(),
})
}
// createPaidOrder 创建付费订单
func (h *CuOrderHandler) createPaidOrder(c *gin.Context, cuUserID, customerID string, req *models.CuOrderCreateRequest) {
// 客户用户创建订单,不需要额外的管理员权限检查
// cuUserID 和 customerID 已经在上层方法中验证过了
// 计算价格
priceResult, err := h.cuOrderService.CalculatePrice(c.Request.Context(), req.PackageID, req.LicenseCount)
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
Message: err.(*i18n.I18nError).Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 创建业务订单(pending状态)
order, err := h.cuOrderService.CreatePendingOrder(c.Request.Context(), cuUserID, customerID, req)
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
Message: err.(*i18n.I18nError).Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 创建支付单
paymentReq := &models.PaymentCreateRequest{
BusinessType: models.BusinessTypePackageOrder,
BusinessID: &order.ID,
CustomerID: customerID,
CuUserID: cuUserID,
Amount: priceResult.TotalAmount,
Currency: "CNY",
PaymentMethod: req.PaymentMethod,
}
payment, err := h.paymentService.CreatePayment(c.Request.Context(), paymentReq)
if err != nil {
// 如果支付单创建失败,需要回滚业务订单
// 这里应该有事务处理,暂时先返回错误
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: "602001",
Message: "支付单创建失败",
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMsg := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMsg,
Data: gin.H{
"id": order.ID,
"order_no": order.OrderNo,
"payment_no": payment.PaymentNo,
"total_amount": priceResult.TotalAmount,
"payment_url": payment.PaymentURL,
"expire_time": payment.ExpireTime.Format("2006-01-02T15:04:05Z"),
},
Timestamp: getCurrentTimestamp(),
})
}
// GetOrder 获取订单详情
// @Summary 获取订单详情
// @Description 根据订单ID获取订单详细信息
// @Tags 客户订单管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param order_id path string true "订单ID"
// @Success 200 {object} models.APIResponse{data=models.CuOrderResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "订单不存在"
// @Router /api/cu/orders/{order_id} [get]
func (h *CuOrderHandler) GetOrder(c *gin.Context) {
orderID := c.Param("order_id")
if orderID == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 获取用户ID
cuUserID, exists := c.Get("cu_user_id")
if !exists {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("100004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 获取订单详情
order, err := h.cuOrderService.GetOrder(c.Request.Context(), orderID, cuUserID.(string))
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
Message: err.(*i18n.I18nError).Message, // 直接使用错误对象中的消息
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMsg := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMsg,
Data: order.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// CancelOrder 删除订单
// @Summary 删除订单
// @Description 删除当前用户的订单记录
// @Tags 客户订单管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param order_id path string true "订单ID"
// @Success 200 {object} models.APIResponse{} "成功"
// @Failure 400
// @Failure 401
// @Failure 403
// @Failure 404
// @Failure 409
// @Router /api/cu/orders/{order_id}/cancel [put]
func (h *CuOrderHandler) CancelOrder(c *gin.Context) {
orderID := c.Param("order_id")
if orderID == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
cuUserID, exists := c.Get("cu_user_id")
if !exists {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("100004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
ctx := middleware.WithLanguage(c.Request.Context(), c)
err := h.cuOrderService.DeleteOrder(ctx, orderID, cuUserID.(string))
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
Message: err.(*i18n.I18nError).Message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMsg := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMsg,
Data: nil,
Timestamp: getCurrentTimestamp(),
})
}
// GetUserOrders 获取用户订单列表
// @Summary 获取用户订单列表
// @Description 获取当前用户的订单列表,支持分页
// @Tags 客户订单管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query integer false "页码" default(1)
// @Param page_size query integer false "每页数量" default(10)
// @Param search query string false "订单号或授权码模糊匹配"
// @Param status query string false "订单状态筛选" Enums(pending, paid, cancelled)
// @Param time query string false "时间筛选" Enums(today, week, month, three_months)
// @Success 200 {object} models.APIResponse{data=models.CuOrderListResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Router /api/cu/orders [get]
func (h *CuOrderHandler) GetUserOrders(c *gin.Context) {
var req models.CuOrderListRequest
if err := c.ShouldBindQuery(&req); err != nil {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
// 获取用户ID
cuUserID, exists := c.Get("cu_user_id")
if !exists {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("100004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 获取订单列表
ctx := middleware.WithLanguage(c.Request.Context(), c)
orders, total, err := h.cuOrderService.GetUserOrders(ctx, cuUserID.(string), &req)
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
Message: err.(*i18n.I18nError).Message, // 直接使用错误对象中的消息
Timestamp: getCurrentTimestamp(),
})
return
}
// 转换为响应格式
orderResponses := make([]*models.CuOrderResponse, len(orders))
for i, order := range orders {
orderResponses[i] = order.ToResponse()
}
lang := middleware.GetLanguage(c)
successMsg := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMsg,
Data: &models.CuOrderListResponse{
Orders: orderResponses,
TotalCount: total,
Page: req.Page,
PageSize: req.PageSize,
},
Timestamp: getCurrentTimestamp(),
})
}
// GetOrderSummary 获取订单汇总统计
// @Summary 获取订单汇总统计
// @Description 获取当前登录用户的订单汇总统计信息(总数、待支付数、已支付数)
// @Tags 客户订单管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.OrderSummaryResponse} "获取成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/orders/summary [get]
func (h *CuOrderHandler) GetOrderSummary(c *gin.Context) {
// 从JWT Token中获取用户ID和客户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
// 调用服务层
result, err := h.cuOrderService.GetOrderSummary(c.Request.Context(), claims.CustomerID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: result,
})
}
// ContinuePay 继续支付
// @Summary 继续支付
// @Description 对指定订单继续支付,如果支付单已过期则创建新的支付单
// @Tags 客户订单管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param order_id path string true "订单ID"
// @Param payment_method query string false "支付方式" Enums(alipay, wechat)
// @Success 200 {object} models.APIResponse{data=object{order_id=string,order_no=string,payment_no=string,payment_url=string,total_amount=number,expire_time=string}} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "订单不存在"
// @Failure 400 {object} models.ErrorResponse "订单状态不允许继续支付"
// @Router /api/cu/orders/{order_id}/pay [post]
func (h *CuOrderHandler) ContinuePay(c *gin.Context) {
orderID := c.Param("order_id")
if orderID == "" {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 获取用户ID和客户ID
cuUserID, exists := c.Get("cu_user_id")
if !exists {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("100004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
customerID, exists := c.Get("cu_customer_id")
if !exists {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("100004", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 获取支付方式(可选,默认使用配置的默认支付方式)
paymentMethod := c.DefaultQuery("payment_method", "")
// 调用服务层
ctx := middleware.WithLanguage(c.Request.Context(), c)
result, err := h.cuOrderService.ContinuePay(ctx, orderID, cuUserID.(string), customerID.(string), paymentMethod)
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
Message: err.(*i18n.I18nError).Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果支付单号为空,说明需要创建新的支付单
if result.PaymentNo == "" {
// 获取订单信息以获取金额
order, err := h.cuOrderService.GetOrder(ctx, orderID, cuUserID.(string))
if err != nil {
c.JSON(err.(*i18n.I18nError).HttpCode, models.ErrorResponse{
Code: err.(*i18n.I18nError).Code,
Message: err.(*i18n.I18nError).Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 创建新的支付单
paymentReq := &models.PaymentCreateRequest{
BusinessType: models.BusinessTypePackageOrder,
BusinessID: &order.ID,
CustomerID: customerID.(string),
CuUserID: cuUserID.(string),
Amount: order.TotalAmount,
Currency: "CNY",
PaymentMethod: paymentMethod,
}
payment, err := h.paymentService.CreatePayment(ctx, paymentReq)
if err != nil {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("602001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
// 更新返回结果
result.PaymentNo = payment.PaymentNo
result.PaymentURL = payment.PaymentURL
result.ExpireTime = payment.ExpireTime
}
lang := middleware.GetLanguage(c)
successMsg := i18n.GetI18nErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMsg,
Data: gin.H{
"order_id": result.OrderID,
"order_no": result.OrderNo,
"payment_no": result.PaymentNo,
"payment_url": result.PaymentURL,
"total_amount": result.TotalAmount,
"expire_time": result.ExpireTime.Format("2006-01-02T15:04:05Z"),
},
Timestamp: getCurrentTimestamp(),
})
}
@@ -0,0 +1,464 @@
package handlers
import (
"net/http"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"license-manager/pkg/utils"
"github.com/gin-gonic/gin"
)
type CuProfileHandler struct {
cuUserService service.CuUserService
}
func NewCuProfileHandler(cuUserService service.CuUserService) *CuProfileHandler {
return &CuProfileHandler{
cuUserService: cuUserService,
}
}
// GetCuUserProfile 获取用户个人资料
// @Summary 获取用户个人资料
// @Description 获取当前登录用户的个人资料信息
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.CuUserResponse} "获取成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 404 {object} models.ErrorResponse "用户不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/profile [get]
func (h *CuProfileHandler) GetCuUserProfile(c *gin.Context) {
// 从JWT Token中获取用户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
user, err := h.cuUserService.GetProfile(c.Request.Context(), claims.UserID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: user.ToResponse(),
})
}
// UpdateCuUserProfile 更新用户个人资料
// @Summary 更新用户个人资料
// @Description 更新当前登录用户的个人资料信息
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param profile body models.CuUserProfileUpdateRequest true "个人资料信息"
// @Success 200 {object} models.APIResponse "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 409 {object} models.ErrorResponse "个人资料更新失败"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/profile [put]
func (h *CuProfileHandler) UpdateCuUserProfile(c *gin.Context) {
// 从JWT Token中获取用户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
var req models.CuUserProfileUpdateRequest
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: getCurrentTimestamp(),
})
return
}
err := h.cuUserService.UpdateProfile(c.Request.Context(), claims.UserID, &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// CuUserSendCurrentPhoneSms 发送当前手机号验证码
// @Summary 发送当前手机号验证码
// @Description 发送当前手机号验证码,用于手机号更新时的身份验证
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @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/cu/profile/send-current-phone-sms [post]
func (h *CuProfileHandler) CuUserSendCurrentPhoneSms(c *gin.Context) {
claims := c.MustGet("cu_user").(*utils.CuClaims)
err := h.cuUserService.SendCurrentPhoneSms(c.Request.Context(), claims.UserID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// CuUserSendNewPhoneSms 发送新手机号验证码
// @Summary 发送新手机号验证码
// @Description 发送新手机号验证码,用于手机号更新时的可用性验证
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param request body models.CuUserSendNewPhoneSmsRequest true "新手机号信息"
// @Success 200 {object} models.APIResponse "验证码发送成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未授权"
// @Failure 409 {object} models.ErrorResponse "手机号已被注册"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/profile/send-new-phone-sms [post]
func (h *CuProfileHandler) CuUserSendNewPhoneSms(c *gin.Context) {
var req models.CuUserSendNewPhoneSmsRequest
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: getCurrentTimestamp(),
})
return
}
err := h.cuUserService.SendNewPhoneSms(c.Request.Context(), &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// UpdateCuUserPhone 更新用户手机号
// @Summary 更新用户手机号
// @Description 更新当前登录用户的手机号,需要验证新旧手机号
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param phone body models.CuUserPhoneUpdateRequest true "手机号更新信息"
// @Success 200 {object} models.APIResponse "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 409 {object} models.ErrorResponse "手机号更新失败"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/profile/phone [put]
func (h *CuProfileHandler) UpdateCuUserPhone(c *gin.Context) {
// 从JWT Token中获取用户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
var req models.CuUserPhoneUpdateRequest
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: getCurrentTimestamp(),
})
return
}
// 获取用户的当前手机号信息(从数据库)
user, err := h.cuUserService.GetProfile(c.Request.Context(), claims.UserID)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 验证当前手机号验证码
valid, err := h.cuUserService.VerifyPhoneCode(c.Request.Context(), user.Phone, user.PhoneCountryCode, req.CurrentSmsCode, "current_phone")
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
if !valid {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("500012", lang) // 验证码错误或已过期
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 验证新手机号验证码
newPhoneCountryCode := req.NewPhoneCountryCode
if newPhoneCountryCode == "" {
newPhoneCountryCode = "+86"
}
valid, err = h.cuUserService.VerifyPhoneCode(c.Request.Context(), req.NewPhone, newPhoneCountryCode, req.NewSmsCode, "new_phone")
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
if !valid {
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("500012", lang) // 验证码错误或已过期
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
err = h.cuUserService.UpdatePhone(c.Request.Context(), claims.UserID, &req)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
// ChangeCuUserPassword 修改用户密码
// @Summary 修改用户密码
// @Description 修改当前登录用户的密码,需要提供旧密码
// @Tags 客户用户管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param password body models.CuUserChangePasswordRequest true "密码修改信息"
// @Success 200 {object} models.APIResponse "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 409 {object} models.ErrorResponse "密码修改失败"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/cu/profile/password [put]
func (h *CuProfileHandler) ChangeCuUserPassword(c *gin.Context) {
// 从JWT Token中获取用户ID
claims := c.MustGet("cu_user").(*utils.CuClaims)
var req models.CuUserChangePasswordRequest
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: getCurrentTimestamp(),
})
return
}
err := h.cuUserService.ChangePassword(c.Request.Context(), claims.UserID, req.OldPassword, req.NewPassword)
if err != nil {
// err已经是i18n.I18nError,直接使用
i18nErr, ok := err.(*i18n.I18nError)
if ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 如果不是i18n错误,使用通用错误
lang := middleware.GetLanguage(c)
status, errCode, message := i18n.NewI18nErrorResponse("900004", lang, err.Error())
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
lang := middleware.GetLanguage(c)
successMessage := i18n.GetErrorMessage("000000", lang)
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: successMessage,
Data: nil,
})
}
@@ -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,378 @@
package handlers
import (
"net/http"
"path/filepath"
"strconv"
"time"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"github.com/gin-gonic/gin"
)
type AdminInvoiceHandler struct {
invoiceService service.AdminInvoiceService
}
func NewAdminInvoiceHandler(invoiceService service.AdminInvoiceService) *AdminInvoiceHandler {
return &AdminInvoiceHandler{
invoiceService: invoiceService,
}
}
// GetAdminInvoices 管理员发票列表
// @Summary 管理员发票列表
// @Description 管理员查看所有发票列表,支持按客户筛选
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码,默认1" minimum(1)
// @Param page_size query int false "每页条数,默认10,最大100" minimum(1) maximum(100)
// @Param status query string false "状态筛选,pending-待处理/issued-已开票/rejected-已驳回"
// @Param search query string false "搜索关键词,支持发票号或订单号模糊匹配"
// @Param apply_date query string false "申请日期筛选,格式YYYY-MM-DD 或 RFC3339范围(start,end)"
// @Param customer_id query string false "客户ID筛选"
// @Success 200 {object} models.APIResponse{data=models.InvoiceListResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices [get]
func (h *AdminInvoiceHandler) GetAdminInvoices(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.InvoiceListRequest
if err := c.ShouldBindQuery(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang),
Timestamp: getCurrentTimestamp(),
})
return
}
invoices, err := h.invoiceService.GetAdminInvoices(c.Request.Context(), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoices,
Timestamp: getCurrentTimestamp(),
})
}
// GetAdminInvoiceDetail 管理员发票详情
// @Summary 管理员发票详情
// @Description 管理员查看发票详细信息,包含关联信息
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "发票ID"
// @Success 200 {object} models.APIResponse{data=models.InvoiceDetailResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/{id} [get]
func (h *AdminInvoiceHandler) GetAdminInvoiceDetail(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
adminID := getUserID(c)
detail, err := h.invoiceService.GetInvoiceDetail(c.Request.Context(), invoiceID, true, adminID)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: detail,
Timestamp: getCurrentTimestamp(),
})
}
// GetAdminInvoiceSummary 管理员发票汇总信息
// @Summary 管理员发票汇总信息
// @Description 管理员查看全平台的发票统计汇总信息
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.InvoiceSummaryResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/summary [get]
func (h *AdminInvoiceHandler) GetAdminInvoiceSummary(c *gin.Context) {
lang := middleware.GetLanguage(c)
summary, err := h.invoiceService.GetAdminInvoiceSummary(c.Request.Context())
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: summary,
Timestamp: getCurrentTimestamp(),
})
}
// RejectInvoice 管理员发票驳回
// @Summary 管理员发票驳回
// @Description 管理员驳回发票申请,需要填写驳回原因和修改建议
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "发票ID"
// @Param request body models.InvoiceRejectRequest true "驳回请求"
// @Success 200 {object} models.APIResponse{data=models.InvoiceResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/{id}/reject [post]
func (h *AdminInvoiceHandler) RejectInvoice(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
adminID := getUserID(c)
var req models.InvoiceRejectRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
invoice, err := h.invoiceService.RejectInvoice(c.Request.Context(), invoiceID, adminID, &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoice.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// IssueInvoice 管理员发票开票
// @Summary 管理员发票开票
// @Description 管理员为发票开票,上传发票文件并设置开票时间
// @Tags 发票管理
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "发票ID"
// @Param request body models.InvoiceIssueRequest true "开票请求"
// @Success 200 {object} models.APIResponse{data=models.InvoiceResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "发票不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/{id}/issue [post]
func (h *AdminInvoiceHandler) IssueInvoice(c *gin.Context) {
lang := middleware.GetLanguage(c)
invoiceID := c.Param("id")
adminID := getUserID(c)
var req models.InvoiceIssueRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
invoice, err := h.invoiceService.IssueInvoice(c.Request.Context(), invoiceID, adminID, &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: invoice.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// UploadInvoiceFile 管理端发票文件上传
// @Summary 管理端发票文件上传
// @Description 管理员上传发票PDF文件
// @Tags 发票管理
// @Accept multipart/form-data
// @Produce json
// @Security BearerAuth
// @Param invoice_no formData string true "发票申请号"
// @Param file formData file true "发票PDF文件"
// @Success 200 {object} models.APIResponse{data=models.InvoiceUploadResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/v1/invoices/upload [post]
func (h *AdminInvoiceHandler) UploadInvoiceFile(c *gin.Context) {
lang := middleware.GetLanguage(c)
// 获取表单参数
invoiceNo := c.PostForm("invoice_no")
if invoiceNo == "" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": invoice_no is required",
Timestamp: getCurrentTimestamp(),
})
return
}
// 获取上传的文件
file, header, err := c.Request.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
defer file.Close()
// 验证文件类型
if filepath.Ext(header.Filename) != ".pdf" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": file type must be pdf",
Timestamp: getCurrentTimestamp(),
})
return
}
// 构建文件保存路径
uploadDir := filepath.Join("..", "files", "invoices")
filename := invoiceNo + "_" + strconv.FormatInt(time.Now().Unix(), 10) + ".pdf"
filePath := filepath.Join(uploadDir, filename)
// 保存文件
if err := c.SaveUploadedFile(header, filePath); err != nil {
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: "900004",
Message: i18n.GetI18nErrorMessage("900004", lang) + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
// 返回相对URL
fileURL := "/files/invoices/" + filename
response := models.InvoiceUploadResponse{
FileURL: fileURL,
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: response,
Timestamp: getCurrentTimestamp(),
})
}
// DownloadByToken 公共发票下载(通过token)
// @Summary 公共发票下载
// @Description 通过下载token下载发票文件,无需登录验证
// @Tags 发票管理
// @Accept json
// @Produce application/pdf
// @Param token query string true "下载token"
// @Success 200 {file} binary "发票文件"
// @Failure 400 {object} models.ErrorResponse "token无效"
// @Failure 404 {object} models.ErrorResponse "文件不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/public/invoices/download [get]
func (h *AdminInvoiceHandler) DownloadByToken(c *gin.Context) {
lang := middleware.GetLanguage(c)
token := c.Query("token")
if token == "" {
c.JSON(http.StatusBadRequest, models.ErrorResponse{
Code: "900001",
Message: i18n.GetI18nErrorMessage("900001", lang) + ": token is required",
Timestamp: getCurrentTimestamp(),
})
return
}
fileURL, err := h.invoiceService.DownloadByToken(c.Request.Context(), token)
if err != nil {
handleI18nError(c, err, lang)
return
}
// 重定向到文件URL
c.Redirect(http.StatusFound, fileURL)
}
// 辅助函数
// getCuUserID 获取客户用户ID
func getCuUserID(c *gin.Context) string {
if userID, exists := c.Get("cu_user_id"); exists {
if id, ok := userID.(string); ok {
return id
}
}
return ""
}
// getUserID 获取管理员用户ID
func getUserID(c *gin.Context) string {
if userID, exists := c.Get("user_id"); exists {
if id, ok := userID.(string); ok {
return id
}
}
return ""
}
// handleI18nError 处理国际化错误
func handleI18nError(c *gin.Context, err error, lang string) {
if i18nErr, ok := err.(*i18n.I18nError); ok {
c.JSON(i18nErr.HttpCode, models.ErrorResponse{
Code: i18nErr.Code,
Message: i18nErr.Message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 默认错误处理
c.JSON(http.StatusInternalServerError, models.ErrorResponse{
Code: "900004",
Message: i18n.GetI18nErrorMessage("900004", lang),
Timestamp: getCurrentTimestamp(),
})
}
@@ -0,0 +1,307 @@
package handlers
import (
"net/http"
"license-manager/internal/api/middleware"
"license-manager/internal/models"
"license-manager/internal/service"
"license-manager/pkg/i18n"
"github.com/gin-gonic/gin"
)
type LeadHandler struct {
leadService service.LeadService
}
func NewLeadHandler(leadService service.LeadService) *LeadHandler {
return &LeadHandler{
leadService: leadService,
}
}
// CreateLead 创建线索
// @Summary 创建线索
// @Description 用户提交企业线索,无需鉴权
// @Tags 企业线索
// @Accept json
// @Produce json
// @Param request body models.LeadCreateRequest true "线索创建请求"
// @Success 200 {object} models.APIResponse{data=models.LeadResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/leads [post]
func (h *LeadHandler) CreateLead(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.LeadCreateRequest
if err := c.ShouldBindJSON(&req); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lead, err := h.leadService.CreateLead(c.Request.Context(), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: lead.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// GetLeads 获取线索列表
// @Summary 获取线索列表
// @Description 管理员获取线索列表,支持分页和筛选
// @Tags 企业线索
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param page query int false "页码,默认1" minimum(1)
// @Param page_size query int false "每页条数,默认10,最大100" minimum(1) maximum(100)
// @Param search query string false "关键词检索"
// @Param status query string false "状态筛选"
// @Success 200 {object} models.APIResponse{data=models.LeadListResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/leads [get]
func (h *LeadHandler) GetLeads(c *gin.Context) {
lang := middleware.GetLanguage(c)
var req models.LeadListRequest
if err := c.ShouldBindQuery(&req); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
result, err := h.leadService.GetLeadList(c.Request.Context(), &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: result,
Timestamp: getCurrentTimestamp(),
})
}
// GetLeadSummary 获取线索汇总
// @Summary 获取线索汇总
// @Description 管理员获取企业线索汇总统计信息
// @Tags 企业线索
// @Accept json
// @Produce json
// @Security BearerAuth
// @Success 200 {object} models.APIResponse{data=models.LeadSummaryResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/leads/summary [get]
func (h *LeadHandler) GetLeadSummary(c *gin.Context) {
lang := middleware.GetLanguage(c)
summary, err := h.leadService.GetLeadSummary(c.Request.Context())
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: summary,
Timestamp: getCurrentTimestamp(),
})
}
// GetLead 获取线索详情
// @Summary 获取线索详情
// @Description 根据ID获取线索详细信息
// @Tags 企业线索
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "线索ID"
// @Success 200 {object} models.APIResponse{data=models.LeadResponse} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "线索不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/leads/{id} [get]
func (h *LeadHandler) GetLead(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
lead, err := h.leadService.GetLead(c.Request.Context(), id)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: lead.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// UpdateLead 更新线索
// @Summary 更新线索
// @Description 更新线索信息,用于线索跟进
// @Tags 企业线索
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "线索ID"
// @Param request body models.LeadUpdateRequest true "线索更新请求"
// @Success 200 {object} models.APIResponse{data=models.LeadResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "线索不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/leads/{id} [put]
func (h *LeadHandler) UpdateLead(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
var req models.LeadUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message + ": " + err.Error(),
Timestamp: getCurrentTimestamp(),
})
return
}
lead, err := h.leadService.UpdateLead(c.Request.Context(), id, &req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: lead.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
// DeleteLead 删除线索
// @Summary 删除线索
// @Description 删除指定线索
// @Tags 企业线索
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "线索ID"
// @Success 200 {object} models.APIResponse{} "成功"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "线索不存在"
// @Failure 500 {object} models.ErrorResponse "服务器内部错误"
// @Router /api/leads/{id} [delete]
func (h *LeadHandler) DeleteLead(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
if err := h.leadService.DeleteLead(c.Request.Context(), id); err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: nil,
Timestamp: getCurrentTimestamp(),
})
}
// UpdateLeadStatus 更新线索状态
// @Summary 更新线索状态
// @Description 快速更新线索状态
// @Tags 企业线索
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param id path string true "线索ID"
// @Param status query string true "状态: pending/contacted/converted/invalid"
// @Success 200 {object} models.APIResponse{data=models.LeadResponse} "成功"
// @Failure 400 {object} models.ErrorResponse "请求参数无效"
// @Failure 401 {object} models.ErrorResponse "未认证"
// @Failure 403 {object} models.ErrorResponse "权限不足"
// @Failure 404 {object} models.ErrorResponse "线索不存在"
// @Router /api/leads/{id}/status [put]
func (h *LeadHandler) UpdateLeadStatus(c *gin.Context) {
lang := middleware.GetLanguage(c)
id := c.Param("id")
status := c.Query("status")
if status == "" {
status, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(status, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
// 验证状态值
validStatuses := map[string]bool{
string(models.LeadStatusPending): true,
string(models.LeadStatusContacted): true,
string(models.LeadStatusConverted): true,
string(models.LeadStatusInvalid): true,
}
if !validStatuses[status] {
statusCode, errCode, message := i18n.NewI18nErrorResponse("900001", lang)
c.JSON(statusCode, models.ErrorResponse{
Code: errCode,
Message: message,
Timestamp: getCurrentTimestamp(),
})
return
}
req := &models.LeadUpdateRequest{
Status: status,
}
lead, err := h.leadService.UpdateLead(c.Request.Context(), id, req)
if err != nil {
handleI18nError(c, err, lang)
return
}
c.JSON(http.StatusOK, models.APIResponse{
Code: "000000",
Message: i18n.GetI18nErrorMessage("000000", lang),
Data: lead.ToResponse(),
Timestamp: getCurrentTimestamp(),
})
}
Loaded 100 of 357 files, more files were not shown because too many files have changed in this diff. Show more