From c588e9efe7888eb9628b2e73ca5b08f534abca3f Mon Sep 17 00:00:00 2001 From: Echo <1711788888@qq.com> Date: Tue, 9 Sep 2025 22:25:27 +0800 Subject: [PATCH] =?UTF-8?q?:art:=20=E6=96=B0=E5=A2=9E=E5=85=91=E6=8D=A2?= =?UTF-8?q?=E7=A0=81=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=96=B0=E5=A2=9Evip?= =?UTF-8?q?=E8=BF=87=E6=9C=9F=E6=A3=80=E6=B5=8B=E5=AE=9A=E6=97=B6=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 218 +++++++++++++++++------ api/v1/app/banner.go | 12 ++ api/v1/app/enter.go | 2 + api/v1/app/redeem_code.go | 188 ++++++++++++++++++++ initialize/gorm.go | 2 + initialize/router.go | 1 + initialize/timer.go | 16 +- main.go | 3 + model/app/redeem_code.go | 34 ++++ model/app/request/cdk.go | 23 +++ model/app/vo/cdk.go | 8 + router/app/banner.go | 1 + router/app/enter.go | 2 + router/app/redeem_code.go | 31 ++++ service/app/enter.go | 1 + service/app/redeem_code.go | 348 +++++++++++++++++++++++++++++++++++++ task/checkVip.go | 23 +++ test/rain_test.go | 31 ++++ utils/rand_code.go | 26 +++ 19 files changed, 914 insertions(+), 56 deletions(-) create mode 100644 api/v1/app/redeem_code.go create mode 100644 model/app/redeem_code.go create mode 100644 model/app/request/cdk.go create mode 100644 model/app/vo/cdk.go create mode 100644 router/app/redeem_code.go create mode 100644 service/app/redeem_code.go create mode 100644 task/checkVip.go diff --git a/README.md b/README.md index 9a34870..bbec817 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,174 @@ -## server项目结构 +# LCKT 后端服务 + +基于 Gin-Vue-Admin 框架开发的全栈开发基础平台后端服务,提供完整的用户管理、权限控制、内容管理等功能。 + +## 🚀 项目特性 + +- **现代化技术栈**: 基于 Go 1.23 + Gin + GORM + Redis + MySQL +- **完整的权限系统**: 基于 Casbin 的 RBAC 权限控制 +- **丰富的业务模块**: 用户管理、订单系统、内容管理、通知系统等 +- **多数据库支持**: MySQL、PostgreSQL、SQLite、SQL Server、Oracle +- **多种存储方案**: 阿里云OSS、腾讯云COS、七牛云、MinIO等 +- **微信生态集成**: 微信支付、微信公众号 +- **API文档**: 集成 Swagger 自动生成API文档 +- **容器化部署**: 支持 Docker 部署 + +## 📋 功能模块 + +### 系统管理 +- 用户管理 (User Management) +- 角色权限 (Role & Permission) +- 菜单管理 (Menu Management) +- API管理 (API Management) +- 操作日志 (Operation Log) +- 字典管理 (Dictionary) + +### 业务功能 +- 用户系统 (App User System) +- 订单管理 (Order Management) +- 兑换码系统 (Redeem Code) +- 文章管理 (Article Management) +- 分类管理 (Category Management) +- 横幅管理 (Banner Management) +- 通知系统 (Notice System) +- VIP会员 (VIP System) +- 机器人管理 (Bot Management) + +### 工具功能 +- 文件上传下载 (File Upload/Download) +- 代码生成器 (Code Generator) +- 表单生成器 (Form Builder) +- Excel导入导出 (Excel Import/Export) +- 验证码 (Captcha) +## 🏗️ 项 +目结构 ```shell -├── api -│   └── v1 -├── config -├── core -├── docs -├── global -├── initialize -│   └── internal -├── middleware -├── model -│   ├── request -│   └── response -├── packfile -├── resource -│   ├── excel -│   ├── page -│   └── template -├── router -├── service -├── source -└── utils - ├── timer - └── upload +├── api # API接口层 +│ └── v1 # v1版本API +├── config # 配置文件结构体 +├── core # 核心组件初始化 +├── docs # Swagger文档 +├── global # 全局变量 +├── initialize # 系统初始化 +├── middleware # 中间件 +├── model # 数据模型 +│ ├── request # 请求结构体 +│ └── response # 响应结构体 +├── resource # 静态资源 +│ ├── excel # Excel文件 +│ ├── page # 页面模板 +│ └── template # 代码模板 +├── router # 路由 +├── service # 业务逻辑层 +├── source # 初始化数据 +├── task # 定时任务 +└── utils # 工具函数 + ├── timer # 定时器 + └── upload # 文件上传 ``` -| 文件夹 | 说明 | 描述 | -| ------------ | ----------------------- | --------------------------- | -| `api` | api层 | api层 | -| `--v1` | v1版本接口 | v1版本接口 | -| `config` | 配置包 | config.yaml对应的配置结构体 | -| `core` | 核心文件 | 核心组件(zap, viper, server)的初始化 | -| `docs` | swagger文档目录 | swagger文档目录 | -| `global` | 全局对象 | 全局对象 | -| `initialize` | 初始化 | router,redis,gorm,validator, timer的初始化 | -| `--internal` | 初始化内部函数 | gorm 的 longger 自定义,在此文件夹的函数只能由 `initialize` 层进行调用 | -| `middleware` | 中间件层 | 用于存放 `gin` 中间件代码 | -| `model` | 模型层 | 模型对应数据表 | -| `--request` | 入参结构体 | 接收前端发送到后端的数据。 | -| `--response` | 出参结构体 | 返回给前端的数据结构体 | -| `packfile` | 静态文件打包 | 静态文件打包 | -| `resource` | 静态资源文件夹 | 负责存放静态文件 | -| `--excel` | excel导入导出默认路径 | excel导入导出默认路径 | -| `--page` | 表单生成器 | 表单生成器 打包后的dist | -| `--template` | 模板 | 模板文件夹,存放的是代码生成器的模板 | -| `router` | 路由层 | 路由层 | -| `service` | service层 | 存放业务逻辑问题 | -| `source` | source层 | 存放初始化数据的函数 | -| `utils` | 工具包 | 工具函数封装 | -| `--timer` | timer | 定时器接口封装 | -| `--upload` | oss | oss接口封装 | +## 🛠️ 技术栈 +### 后端框架 +- **Gin**: 高性能的Go Web框架 +- **GORM**: Go语言ORM库 +- **Casbin**: 权限管理库 +- **Viper**: 配置管理 +- **Zap**: 高性能日志库 +- **JWT**: 身份认证 +- **Redis**: 缓存和会话存储 + +### 数据库支持 +- MySQL +- PostgreSQL +- SQLite +- SQL Server +- Oracle +- MongoDB + +### 对象存储 +- 阿里云OSS +- 腾讯云COS +- 七牛云 +- MinIO +- AWS S3 +- Cloudflare R2 +- 华为云OBS + +### 第三方集成 +- 微信支付 +- 微信公众号 +- 阿里云短信 +- 邮件发送# +# 🚀 快速开始 + +### 环境要求 +- Go 1.23+ +- MySQL 5.7+ +- Redis 5.0+ + +### 安装依赖 +```bash +go mod tidy +``` + +### 配置文件 +复制配置文件并修改相关配置: +```bash +cp config.yaml.example config.yaml +``` + +主要配置项: +- 数据库连接信息 +- Redis连接信息 +- JWT密钥 +- 对象存储配置 +- 微信相关配置 + +### 运行项目 +```bash +# 开发环境 +go run main.go + +# 生产环境 +go build -o server main.go +./server +``` + +### Docker 部署 +```bash +# 构建镜像 +docker build -t lckt-server . + +# 运行容器 +docker run -d -p 8888:8888 --name lckt-server lckt-server +``` + +## 📖 API 文档 + +项目集成了 Swagger 自动生成API文档,启动项目后访问: +``` +http://localhost:8888/swagger/index.html +``` + +## 🔧 开发指南 + +### 代码生成 +项目内置代码生成器,可以快速生成CRUD代码: +1. 配置数据表结构 +2. 使用代码生成器生成相关文件 +3. 根据业务需求调整生成的代码 + +### 权限控制 +基于 Casbin 实现的 RBAC 权限控制: +- 用户 (User) +- 角色 (Role) +- 权限 (Permission) +- 菜单 (Menu) + +### 中间件 +- JWT认证中间件 +- 跨域处理中间件 +- 操作日志中间件 +- 限流中间件 \ No newline at end of file diff --git a/api/v1/app/banner.go b/api/v1/app/banner.go index 94e0652..731176b 100644 --- a/api/v1/app/banner.go +++ b/api/v1/app/banner.go @@ -1,9 +1,11 @@ package app import ( + "git.echol.cn/loser/lckt/global" "git.echol.cn/loser/lckt/model/app" common "git.echol.cn/loser/lckt/model/common/request" "git.echol.cn/loser/lckt/model/common/response" + "git.echol.cn/loser/lckt/task" "github.com/gin-gonic/gin" ) @@ -94,3 +96,13 @@ func (b *BannerApi) GetIndexBanners(context *gin.Context) { } response.OkWithData(list, context) } + +func (b *BannerApi) GetVIPBanners(context *gin.Context) { + err := task.CheckVip(global.GVA_DB) + if err != nil { + response.FailWithMessage("获取VIPBanner失败: "+err.Error(), context) + return + } + + response.OkWithMessage("检测vip成功", context) +} diff --git a/api/v1/app/enter.go b/api/v1/app/enter.go index 47a73a0..9462bb1 100644 --- a/api/v1/app/enter.go +++ b/api/v1/app/enter.go @@ -7,6 +7,7 @@ type ApiGroup struct { BannerApi OrderApi TeacherVip + RedeemCodeApi } var userService = service.ServiceGroupApp.UserServiceGroup.UserService @@ -14,3 +15,4 @@ var appUserService = service.ServiceGroupApp.AppServiceGroup.AppUserService var bannerService = service.ServiceGroupApp.AppServiceGroup.BannerService var orderService = service.ServiceGroupApp.AppServiceGroup.OrderService var teacherVipService = service.ServiceGroupApp.AppServiceGroup.TeacherVipService +var redeemCodeService = service.ServiceGroupApp.AppServiceGroup.RedeemCodeService diff --git a/api/v1/app/redeem_code.go b/api/v1/app/redeem_code.go new file mode 100644 index 0000000..ac4e756 --- /dev/null +++ b/api/v1/app/redeem_code.go @@ -0,0 +1,188 @@ +package app + +import ( + "git.echol.cn/loser/lckt/global" + "git.echol.cn/loser/lckt/model/app" + "git.echol.cn/loser/lckt/model/app/request" + common "git.echol.cn/loser/lckt/model/common/request" + r "git.echol.cn/loser/lckt/model/common/response" + "github.com/gin-gonic/gin" + "go.uber.org/zap" +) + +type RedeemCodeApi struct{} + +// Create 创建兑换码库 +func (rc *RedeemCodeApi) Create(ctx *gin.Context) { + var p app.RedeemCode + if err := ctx.ShouldBind(&p); err != nil { + r.FailWithMessage("创建兑换码库失败:"+err.Error(), ctx) + global.GVA_LOG.Error("创建兑换码库失败", zap.Error(err)) + return + } + + err := redeemCodeService.Create(p) + if err != nil { + r.FailWithMessage("创建兑换码库失败:"+err.Error(), ctx) + global.GVA_LOG.Error("创建兑换码库失败", zap.Error(err)) + return + } + + r.OkWithMessage("创建兑换码库成功", ctx) +} + +// Delete 删除兑换码库 +func (rc *RedeemCodeApi) Delete(ctx *gin.Context) { + var p app.RedeemCode + if err := ctx.ShouldBind(&p); err != nil { + r.FailWithMessage("删除兑换码库失败:"+err.Error(), ctx) + global.GVA_LOG.Error("删除兑换码库失败", zap.Error(err)) + return + } + + err := redeemCodeService.Delete(p) + if err != nil { + r.FailWithMessage("删除兑换码库失败:"+err.Error(), ctx) + global.GVA_LOG.Error("删除兑换码库失败", zap.Error(err)) + return + } + + r.OkWithMessage("删除兑换码库成功", ctx) +} + +// Update 更新兑换码库 +func (rc *RedeemCodeApi) Update(ctx *gin.Context) { + var p app.RedeemCode + if err := ctx.ShouldBind(&p); err != nil { + r.FailWithMessage("更新兑换码库失败:"+err.Error(), ctx) + global.GVA_LOG.Error("更新兑换码库失败", zap.Error(err)) + return + } + + err := redeemCodeService.Update(p) + if err != nil { + r.FailWithMessage("更新兑换码库失败:"+err.Error(), ctx) + global.GVA_LOG.Error("更新兑换码库失败", zap.Error(err)) + return + } + + r.OkWithMessage("更新兑换码库成功", ctx) +} + +// GetList 获取兑换码库列表 +func (rc *RedeemCodeApi) GetList(ctx *gin.Context) { + var p common.PageInfo + if err := ctx.ShouldBind(&p); err != nil { + r.FailWithMessage("获取兑换码库列表失败:"+err.Error(), ctx) + global.GVA_LOG.Error("获取兑换码库列表失败", zap.Error(err)) + return + } + list, total, err := redeemCodeService.GetList(p) + if err != nil { + r.FailWithMessage("获取兑换码库列表失败:"+err.Error(), ctx) + global.GVA_LOG.Error("获取兑换码库列表失败", zap.Error(err)) + return + } + r.OkWithDetailed(r.PageResult{ + List: list, + Total: total, + Page: p.Page, + PageSize: p.PageSize, + }, "获取兑换码库列表成功", ctx) +} + +// GetById 根据ID获取兑换码库 +func (rc *RedeemCodeApi) GetById(ctx *gin.Context) { + var p common.GetById + if err := ctx.ShouldBind(&p); err != nil { + r.FailWithMessage("获取兑换码库失败:"+err.Error(), ctx) + global.GVA_LOG.Error("获取兑换码库失败", zap.Error(err)) + return + } + redeem, err := redeemCodeService.GetById(p.ID) + if err != nil { + r.FailWithMessage("获取兑换码库失败:"+err.Error(), ctx) + global.GVA_LOG.Error("获取兑换码库失败", zap.Error(err)) + return + } + r.OkWithDetailed(redeem, "获取兑换码库成功", ctx) +} + +// ========================CDK相关======================== + +// CreateCDK 生成兑换码 +func (rc *RedeemCodeApi) CreateCDK(ctx *gin.Context) { + var p request.GenerateCDK + if err := ctx.ShouldBind(&p); err != nil { + r.FailWithMessage("生成兑换码失败:"+err.Error(), ctx) + global.GVA_LOG.Error("生成兑换码失败", zap.Error(err)) + return + } + + cdkVo, err := redeemCodeService.CreateCDK(p) + if err != nil { + r.FailWithMessage("生成兑换码失败:"+err.Error(), ctx) + global.GVA_LOG.Error("生成兑换码失败", zap.Error(err)) + return + } + r.OkWithDetailed(cdkVo, "生成兑换码成功", ctx) +} + +// GetCDKList 获取兑换码列表 +func (rc *RedeemCodeApi) GetCDKList(ctx *gin.Context) { + var p request.GetCDKList + if err := ctx.ShouldBind(&p); err != nil { + r.FailWithMessage("获取兑换码列表失败:"+err.Error(), ctx) + global.GVA_LOG.Error("获取兑换码列表失败", zap.Error(err)) + return + } + list, total, err := redeemCodeService.GetCDKList(p) + if err != nil { + r.FailWithMessage("获取兑换码列表失败:"+err.Error(), ctx) + global.GVA_LOG.Error("获取兑换码列表失败", zap.Error(err)) + return + } + r.OkWithDetailed(r.PageResult{ + List: list, + Total: total, + Page: p.Page, + PageSize: p.PageSize, + }, "获取兑换码列表成功", ctx) +} + +// DeleteCDK 删除兑换码 +func (rc *RedeemCodeApi) DeleteCDK(ctx *gin.Context) { + var p app.CDK + if err := ctx.ShouldBind(&p); err != nil { + r.FailWithMessage("删除兑换码失败:"+err.Error(), ctx) + global.GVA_LOG.Error("删除兑换码失败", zap.Error(err)) + return + } + + err := redeemCodeService.DeleteCDK(p) + if err != nil { + r.FailWithMessage("删除兑换码失败:"+err.Error(), ctx) + global.GVA_LOG.Error("删除兑换码失败", zap.Error(err)) + return + } + + r.OkWithMessage("删除兑换码成功", ctx) +} + +func (rc *RedeemCodeApi) Redeem(context *gin.Context) { + var p request.RedeemCDK + if err := context.ShouldBind(&p); err != nil { + r.FailWithMessage("兑换失败:"+err.Error(), context) + global.GVA_LOG.Error("兑换失败", zap.Error(err)) + return + } + + err := redeemCodeService.Redeem(p, context) + if err != nil { + r.FailWithMessage(err.Error(), context) + global.GVA_LOG.Error("兑换失败", zap.Error(err)) + return + } + + r.OkWithMessage("兑换成功", context) +} diff --git a/initialize/gorm.go b/initialize/gorm.go index c135ca4..1d83441 100644 --- a/initialize/gorm.go +++ b/initialize/gorm.go @@ -84,6 +84,8 @@ func RegisterTables() { app.TeacherVip{}, app.UserTeacherVip{}, user.LoginLog{}, + app.RedeemCode{}, + app.CDK{}, ) if err != nil { global.GVA_LOG.Error("register table failed", zap.Error(err)) diff --git a/initialize/router.go b/initialize/router.go index 267eda2..6757c5f 100644 --- a/initialize/router.go +++ b/initialize/router.go @@ -117,6 +117,7 @@ func Routers() *gin.Engine { appRouter.InitAppUserRouter(AppAuthGroup, PublicGroup) appRouter.InitBannerRouter(PrivateGroup, PublicGroup) // Banner相关路由 appRouter.InitOrderRouter(AppAuthGroup, PrivateGroup, PublicGroup) // 订单相关路由 + appRouter.InitRedeemCodeRouter(AppAuthGroup, PrivateGroup) // 兑换码相关路由 } //插件路由安装 diff --git a/initialize/timer.go b/initialize/timer.go index d405f77..13b6b6e 100644 --- a/initialize/timer.go +++ b/initialize/timer.go @@ -26,12 +26,14 @@ func Timer() { // 其他定时任务定在这里 参考上方使用方法 - //_, err := global.GVA_Timer.AddTaskByFunc("定时任务标识", "corn表达式", func() { - // 具体执行内容... - // ...... - //}, option...) - //if err != nil { - // fmt.Println("add timer error:", err) - //} + _, err = global.GVA_Timer.AddTaskByFunc("ClearVip", "@daily", func() { + err2 := task.CheckVip(global.GVA_DB) + if err2 != nil { + fmt.Println("清理过期VIP定时任务失败:", err2) + } + }, "定时清理过期VIP日志内容:", option...) + if err != nil { + fmt.Println("add timer error:", err) + } }() } diff --git a/main.go b/main.go index f0d9468..51ac3fd 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,8 @@ import ( "git.echol.cn/loser/lckt/utils/wechat" _ "go.uber.org/automaxprocs" "go.uber.org/zap" + "math/rand" + "time" ) //go:generate go env -w GO111MODULE=on @@ -48,6 +50,7 @@ func initializeSystem() { wechat.InitWechatPay() wechat.InitWeOfficial() + rand.Seed(time.Now().UnixNano()) initialize.SetupHandlers() // 注册全局函数 if global.GVA_DB != nil { diff --git a/model/app/redeem_code.go b/model/app/redeem_code.go new file mode 100644 index 0000000..f7eb65b --- /dev/null +++ b/model/app/redeem_code.go @@ -0,0 +1,34 @@ +package app + +import "git.echol.cn/loser/lckt/global" + +type RedeemCode struct { + global.GVA_MODEL + CodeName string `json:"codeName" gorm:"comment:兑换码名称"` + Type int `json:"type" gorm:"comment:类型,1-VIP,2-讲师VIP,3-课程"` + Item uint `json:"item" gorm:"comment:对应类型的ID"` + Num int `json:"num" gorm:"comment:兑换码数量"` + No int `json:"no" gorm:"comment:已使用数量"` +} + +func (RedeemCode) TableName() string { + return "app_redeem_code" +} + +type CDK struct { + global.GVA_MODEL + RedeemId uint `json:"redeemId" gorm:"comment:兑换码库ID"` + Code string `json:"code" gorm:"unique;comment:兑换码"` + Status int `json:"status" gorm:"default:1;comment:状态,1-未使用,2-已使用,3-已过期"` + UseId uint `json:"useId" gorm:"comment:使用用户ID"` + UseName string `json:"useName" gorm:"comment:使用用户名"` + UseAt string `json:"useAt" gorm:"comment:使用时间"` + // 有效期 + ValidDay int `json:"validDay" gorm:"comment:有效期,单位天 0表示永久有效"` + // 过期时间 + ExpireAt string `json:"expireAt" gorm:"comment:过期时间"` +} + +func (CDK) TableName() string { + return "app_cdk" +} diff --git a/model/app/request/cdk.go b/model/app/request/cdk.go new file mode 100644 index 0000000..404cc8e --- /dev/null +++ b/model/app/request/cdk.go @@ -0,0 +1,23 @@ +package request + +import common "git.echol.cn/loser/lckt/model/common/request" + +type GenerateCDK struct { + Eid uint `json:"eid" form:"eid" binding:"required"` // 兑换码库ID + Number int `json:"number" from:"number" binding:"required,min=1"` // 生成数量 + Expirer int `json:"expirer" form:"expirer"` // 有效期,单位天 0表示永久有效 +} + +type GetCDKList struct { + common.PageInfo + Eid uint `json:"eid" form:"eid" binding:"required"` + Code string `json:"code" form:"code"` // 兑换码 + UseName string `json:"useName" form:"useName"` // 使用人 + Status int `json:"status" form:"status"` // 状态 +} + +type RedeemCDK struct { + Code string `json:"code" form:"code" binding:"required"` // 兑换码 + UseName string `json:"useName" form:"useName"` // 使用人名称 + UserId uint `json:"userId" form:"userId"` // 用户ID +} diff --git a/model/app/vo/cdk.go b/model/app/vo/cdk.go new file mode 100644 index 0000000..7d41726 --- /dev/null +++ b/model/app/vo/cdk.go @@ -0,0 +1,8 @@ +package vo + +import "git.echol.cn/loser/lckt/model/app" + +type CDKVo struct { + Eid uint `json:"eid"` // 兑换码库ID + CDK []app.CDK `json:"cdk"` // 生成的兑换码 +} diff --git a/router/app/banner.go b/router/app/banner.go index 1b40426..26ed33d 100644 --- a/router/app/banner.go +++ b/router/app/banner.go @@ -18,5 +18,6 @@ func (b *BannerRouter) InitBannerRouter(Router, PublicRouter *gin.RouterGroup) { appRouter.GET("/list", bannerApi.GetList) // 获取Banner列表 appRouter.GET("", bannerApi.GetByID) // Banner公开接口 appRouter.GET("/index", bannerApi.GetIndexBanners) // 获取首页Banner + appRouter.GET("vip", bannerApi.GetVIPBanners) // 获取VIP Banner } } diff --git a/router/app/enter.go b/router/app/enter.go index 733990e..f1e6b5d 100644 --- a/router/app/enter.go +++ b/router/app/enter.go @@ -6,9 +6,11 @@ type RouterGroup struct { UserRouter BannerRouter OrderRouter + RedeemCodeRouter } var userApi = api.ApiGroupApp.AppApiGroup.AppUserApi var bannerApi = api.ApiGroupApp.AppApiGroup.BannerApi var orderApi = api.ApiGroupApp.AppApiGroup.OrderApi var teacherVipApi = api.ApiGroupApp.AppApiGroup.TeacherVip +var redeemCodeApi = api.ApiGroupApp.AppApiGroup.RedeemCodeApi diff --git a/router/app/redeem_code.go b/router/app/redeem_code.go new file mode 100644 index 0000000..2fcee29 --- /dev/null +++ b/router/app/redeem_code.go @@ -0,0 +1,31 @@ +package app + +import "github.com/gin-gonic/gin" + +type RedeemCodeRouter struct{} + +func (rcr *RedeemCodeRouter) InitRedeemCodeRouter(AppRouter, SysteamRouter *gin.RouterGroup) { + AppCDKRouter := AppRouter.Group("/cdk") + SysCDKRouter := SysteamRouter.Group("/cdk") + + { + // 兑换码库 + SysCDKRouter.POST("mk", redeemCodeApi.Create) // 创建兑换码库 + SysCDKRouter.DELETE("mk", redeemCodeApi.Delete) // 删除兑换码库 + SysCDKRouter.PUT("mk", redeemCodeApi.Update) // 更新兑换码库 + SysCDKRouter.GET("/mk/list", redeemCodeApi.GetList) // 分页获取兑换码库列表 + SysCDKRouter.GET("mk/:id", redeemCodeApi.GetById) // 获取单个兑换码库信息 + } + + { + // 兑换码 + SysCDKRouter.POST("/generate", redeemCodeApi.CreateCDK) // 生成兑换码 + SysCDKRouter.GET("/list", redeemCodeApi.GetCDKList) // 分页获取兑换码列表 + SysCDKRouter.DELETE("", redeemCodeApi.DeleteCDK) // 删除兑换码 + } + + { + // 用户兑换码 + AppCDKRouter.POST("/redeem", redeemCodeApi.Redeem) // 兑换码 + } +} diff --git a/service/app/enter.go b/service/app/enter.go index 15d4d4f..2ed9d99 100644 --- a/service/app/enter.go +++ b/service/app/enter.go @@ -5,4 +5,5 @@ type ServiceGroup struct { BannerService OrderService TeacherVipService + RedeemCodeService } diff --git a/service/app/redeem_code.go b/service/app/redeem_code.go new file mode 100644 index 0000000..01f87ed --- /dev/null +++ b/service/app/redeem_code.go @@ -0,0 +1,348 @@ +package app + +import ( + "errors" + "git.echol.cn/loser/lckt/global" + "git.echol.cn/loser/lckt/model/app" + request2 "git.echol.cn/loser/lckt/model/app/request" + "git.echol.cn/loser/lckt/model/article" + "git.echol.cn/loser/lckt/model/common/request" + "git.echol.cn/loser/lckt/model/user" + "git.echol.cn/loser/lckt/model/vip" + "git.echol.cn/loser/lckt/utils" + "git.echol.cn/loser/lckt/utils/wechat" + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "gorm.io/gorm" + "time" +) + +type RedeemCodeService struct{} + +// Create 创建兑换码库 +func (s RedeemCodeService) Create(p app.RedeemCode) (err error) { + return global.GVA_DB.Create(&p).Error +} + +// Delete 删除兑换码库 +func (s RedeemCodeService) Delete(p app.RedeemCode) (err error) { + return global.GVA_DB.Delete(&p).Error +} + +// Update 更新兑换码库 +func (s RedeemCodeService) Update(p app.RedeemCode) (err error) { + return global.GVA_DB.Save(&p).Error +} + +// GetList 分页获取兑换码库列表 +func (s RedeemCodeService) GetList(p request.PageInfo) (list []app.RedeemCode, total int64, err error) { + limit := p.PageSize + offset := p.PageSize * (p.Page - 1) + db := global.GVA_DB.Model(&app.RedeemCode{}) + + if p.Keyword != "" { + db = db.Where("code_name LIKE ?", "%"+p.Keyword+"%") + } + + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Limit(limit).Offset(offset).Find(&list).Error + return +} + +// GetById 根据ID获取兑换码库 +func (s RedeemCodeService) GetById(id int) (redeem app.RedeemCode, err error) { + err = global.GVA_DB.Where("id = ?", id).First(&redeem).Error + return +} + +// ========================CDK相关======================== + +// CreateCDK 生成兑换码 +func (s RedeemCodeService) CreateCDK(p request2.GenerateCDK) ([]app.CDK, error) { + codes := utils.GenerateRedeemCodes(p.Number) + var cdks []app.CDK + for _, code := range codes { + var cdk app.CDK + cdk.Code = code + cdk.RedeemId = p.Eid + cdk.ValidDay = p.Expirer + cdk.Status = 1 + if p.Expirer == 0 { + cdk.ExpireAt = "永久有效" + } else { + cdk.ExpireAt = time.Now().AddDate(0, 0, p.Expirer).Format("2006-01-02") + } + + cdks = append(cdks, cdk) + } + + err := global.GVA_DB.Create(&cdks).Error + if err != nil { + global.GVA_LOG.Error("生成兑换码失败", zap.Error(err)) + return nil, err + } + + // 更新兑换码库的数量 + err = global.GVA_DB.Model(&app.RedeemCode{}).Where("id = ?", p.Eid).Update("num", gorm.Expr("num + ?", p.Number)).Error + if err != nil { + global.GVA_LOG.Error("更新兑换码库数量失败", zap.Error(err)) + } + + return cdks, nil +} + +// GetCDKList 获取兑换码列表 +func (s RedeemCodeService) GetCDKList(p request2.GetCDKList) (list []app.CDK, total int64, err error) { + limit := p.PageSize + offset := p.PageSize * (p.Page - 1) + db := global.GVA_DB.Model(&app.CDK{}).Where("redeem_id = ?", p.Eid) + + if p.Code != "" { + db = db.Where("code LIKE ?", "%"+p.Code+"%") + } + + if p.UseName != "" { + db = db.Where("use_name LIKE ?", "%"+p.UseName+"%") + } + if p.Status != 0 { + db = db.Where("status = ?", p.Status) + } + + err = db.Count(&total).Error + if err != nil { + return + } + err = db.Limit(limit).Offset(offset).Find(&list).Error + return +} + +// DeleteCDK 删除兑换码 +func (s RedeemCodeService) DeleteCDK(p app.CDK) (err error) { + return global.GVA_DB.Delete(&p).Error +} + +// Redeem 用户兑换码 +func (s RedeemCodeService) Redeem(p request2.RedeemCDK, ctx *gin.Context) (err error) { + var cdk app.CDK + err = global.GVA_DB.Where("code = ?", p.Code).First(&cdk).Error + if err != nil { + return err + } + + // 判断状态 + if cdk.Status != 1 { + return errors.New("兑换码不可用") + } + + // 判断是否过期 + if cdk.ValidDay != 0 { + expireAt, _ := time.Parse("2006-01-02", cdk.ExpireAt) + if time.Now().After(expireAt) { + // 更新状态为已过期 + cdk.Status = 3 + _ = global.GVA_DB.Save(&cdk).Error + return errors.New("兑换码已过期") + } + } + + // 获取兑换码库信息 + var redeem app.RedeemCode + err = global.GVA_DB.Where("id = ?", cdk.RedeemId).First(&redeem).Error + if err != nil { + return err + } + + // 获取用户信息 + userId := utils.GetUserID(ctx) + var userInfo user.User + err = global.GVA_DB.Where("id = ?", userId).First(&userInfo).Error + if err != nil { + global.GVA_LOG.Error("获取用户信息出错", zap.Error(err)) + } + + // 更新兑换码状态 + cdk.Status = 2 + cdk.UseId = userInfo.ID + cdk.UseName = userInfo.NickName + cdk.UseAt = time.Now().Format("2006-01-02 15:04:05") + err = global.GVA_DB.Save(&cdk).Error + if err != nil { + global.GVA_LOG.Error("更新兑换码状态失败", zap.Error(err)) + return err + } + + // 更新兑换码库已使用数量 + err = global.GVA_DB.Model(&app.RedeemCode{}).Where("id = ?", cdk.RedeemId).Update("no", gorm.Expr("no + ?", 1)).Error + if err != nil { + global.GVA_LOG.Error("更新兑换码库已使用数量失败", zap.Error(err)) + } + + // 发放对应的权益 + switch redeem.Type { + case 1: + // VIP + var vipInfo vip.Vip + err = global.GVA_DB.Where("id = ?", redeem.Item).First(&vipInfo).Error + if err != nil { + return err + } + + vipLevel := 1 + if vipInfo.Level == 1 { + vipLevel = 2 + } else if vipInfo.Level == 2 { + vipLevel = 3 + } + + // 判断用户是否已经是VIP + if userInfo.IsVip == 1 { + // 是VIP,判断会员等级 + if userInfo.UserLabel == int64(vipLevel) { + // 等级相同,延长会员时间 + if userInfo.VipExpireTime != "" { + expireTime, _ := time.Parse("2006-01-02", userInfo.VipExpireTime) + if expireTime.After(time.Now()) { + // 如果会员未过期,则在原有的基础上增加时间 + userInfo.VipExpireTime = expireTime.AddDate(0, 0, int(vipInfo.Expiration)).Format("2006-01-02") + } else { + // 如果会员已过期,则从当前时间开始计算 + userInfo.VipExpireTime = time.Now().AddDate(0, 0, int(vipInfo.Expiration)).Format("2006-01-02") + } + } else { + // 如果没有会员时间,则从当前时间开始计算 + userInfo.VipExpireTime = time.Now().AddDate(0, 0, int(vipInfo.Expiration)).Format("2006-01-02") + } + err = global.GVA_DB.Save(&userInfo).Error + if err != nil { + global.GVA_LOG.Error("更新用户VIP信息失败", zap.Error(err)) + return err + } + } + if userInfo.UserLabel < int64(vipLevel) { + // 等级更高,直接覆盖 + userInfo.UserLabel = int64(vipLevel) + userInfo.VipExpireTime = time.Now().AddDate(0, 0, int(vipInfo.Expiration)).Format("2006-01-02") + err = global.GVA_DB.Save(&userInfo).Error + if err != nil { + global.GVA_LOG.Error("更新用户VIP信息失败", zap.Error(err)) + return err + } + } + } else { + // 不是VIP,直接设置为VIP + userInfo.IsVip = 1 + userInfo.UserLabel = int64(vipLevel) + userInfo.VipExpireTime = time.Now().AddDate(0, 0, int(vipInfo.Expiration)).Format("2006-01-02") + err = global.GVA_DB.Save(&userInfo).Error + + if err != nil { + return err + } + } + + case 2: + // 讲师VIP + var teacherVip app.TeacherVip + err = global.GVA_DB.Where("id = ?", redeem.Item).First(&teacherVip).Error + if err != nil { + return err + } + + // 判断是否已经拥有该讲师VIP + var existingTeacherVip app.UserTeacherVip + err = global.GVA_DB.Where("user_id = ? AND teacher_vip_id = ?", userInfo.ID, teacherVip.ID).First(&existingTeacherVip).Error + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + global.GVA_LOG.Error("获取用户讲师VIP信息出错", zap.Error(err)) + return err + } + + // 判断是否为空 + if errors.Is(err, gorm.ErrRecordNotFound) { + userTeacherVip := app.UserTeacherVip{ + UserId: userInfo.ID, + TeacherVipId: teacherVip.ID, + TeacherId: teacherVip.TeacherId, + IsExpire: 1, + ExpireAt: time.Now().AddDate(0, 0, 30).Format("2006-01-02"), + } + err = global.GVA_DB.Create(&userTeacherVip).Error + if err != nil { + global.GVA_LOG.Error("兑换讲师包月服务失败", zap.Error(err)) + return err + } + return + } + + // 默认未过期,获取过期时间+30天 + // 将 existingTeacherVip.ExpireAt转为time.Time类型 + parse, err := time.Parse("2006-01-02", existingTeacherVip.ExpireAt) + if err != nil { + global.GVA_LOG.Error("转换过期时间出错", zap.Error(err)) + return err + } + + // 已过期 当前时间开始计算+30天 + if existingTeacherVip.IsExpire == 2 && time.Now().After(parse) { + existingTeacherVip.IsExpire = 1 + existingTeacherVip.ExpireAt = time.Now().AddDate(0, 0, 30).Format("2006-01-02") + } else { + // 未过期 在原有时间上+30天 + existingTeacherVip.ExpireAt = parse.AddDate(0, 0, 30).Format("2006-01-02") + } + + err = global.GVA_DB.Save(&existingTeacherVip).Error + + if err != nil { + global.GVA_LOG.Error("兑换讲师包月服务失败", zap.Error(err)) + return err + } + case 3: + // 课程 + var course article.Article + err = global.GVA_DB.Where("id = ?", redeem.Item).First(&course).Error + if err != nil { + return err + } + + // 判断用户是否购买过该课程 + var count int64 + err = global.GVA_DB.Model(&app.Order{}).Where("article_id = ? AND user_id = ? AND status = 2", course.ID, userInfo.ID).Count(&count).Error + if err != nil { + global.GVA_LOG.Error("查询用户购买记录失败", zap.Error(err)) + return err + } + + if count == 0 { + // 未购买,新增购买记录 + order := app.Order{ + UserId: uint64(userInfo.ID), + Name: userInfo.NickName, + OpenId: userInfo.OpenId, + Phone: userInfo.Phone, + TeacherId: uint64(course.TeacherId), + ArticleId: course.ID, + Price: 0, + Status: 2, + OrderNo: wechat.GenerateOrderNum(), + OrderType: 1, + Title: course.Title, + Desc: "兑换码兑换" + course.Title, + PayType: 4, + } + err = global.GVA_DB.Create(&order).Error + if err != nil { + global.GVA_LOG.Error("兑换课程失败", zap.Error(err)) + return err + } + } else { + // 已购买,直接返回 + return errors.New("您已购买过该课程,无需重复兑换") + } + + return + } + return +} diff --git a/task/checkVip.go b/task/checkVip.go new file mode 100644 index 0000000..c179985 --- /dev/null +++ b/task/checkVip.go @@ -0,0 +1,23 @@ +package task + +import ( + "git.echol.cn/loser/lckt/model/user" + "gorm.io/gorm" +) + +// CheckVip 检查用户VIP是否过期 +func CheckVip(db *gorm.DB) error { + var users []user.User + // 根据当前时间和vip_expire_time对比 查看是否到过期时间 + db.Where("vip_expire_time < ? AND vip_expire_time IS NOT NULL", gorm.Expr("NOW()")).Find(&users) + for _, u := range users { + u.VipExpireTime = "" + u.IsVip = 0 + u.UserLabel = 4 + err := db.Save(&u).Error + if err != nil { + return err + } + } + return nil +} diff --git a/test/rain_test.go b/test/rain_test.go index 19d44e0..a97b06e 100644 --- a/test/rain_test.go +++ b/test/rain_test.go @@ -3,6 +3,11 @@ package test import ( "crypto/md5" "fmt" + "git.echol.cn/loser/lckt/core" + "git.echol.cn/loser/lckt/global" + "git.echol.cn/loser/lckt/initialize" + "git.echol.cn/loser/lckt/task" + "go.uber.org/zap" "golang.org/x/crypto/bcrypt" "math/rand" "net/http" @@ -71,3 +76,29 @@ func sendCode(code string) { fmt.Println("请求失败,状态码:", resp.StatusCode) } } + +func TestTime(t *testing.T) { + // 默认未过期,获取过期时间+30天 + // 将 existingTeacherVip.ExpireAt转为time.Time类型 + parse, err := time.Parse("2006-01-02", "2025-09-10") + if err != nil { + global.GVA_LOG.Error("转换过期时间出错", zap.Error(err)) + return + } + after := time.Now().After(parse) + fmt.Println(after) +} + +func TestTask(t *testing.T) { + global.GVA_VP = core.Viper() // 初始化Viper + global.GVA_LOG = core.Zap() // 初始化zap日志库 + zap.ReplaceGlobals(global.GVA_LOG) + global.GVA_DB = initialize.Gorm() // gorm连接数据库 + initialize.DBList() + err := task.CheckVip(global.GVA_DB) + if err != nil { + fmt.Println("清理表失败", err.Error()) + } else { + fmt.Println("清理表成功") + } +} diff --git a/utils/rand_code.go b/utils/rand_code.go index aa32634..9ef92e3 100644 --- a/utils/rand_code.go +++ b/utils/rand_code.go @@ -9,6 +9,7 @@ import ( const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +// GenerateInviteCode 生成邀请码,基于用户ID和随机数的MD5哈希 func GenerateInviteCode(userID uint) string { rand.Seed(time.Now().UnixNano()) // 拼接用户ID和随机数 @@ -34,3 +35,28 @@ func GenerateRandomString(length int) string { } return code } + +// GenerateRedeemCode 生成单个兑换码,格式为N7DY4kcf5z37hwz,随机大小写字母+数字 +func GenerateRedeemCode() string { + const codeCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + codeLen := 15 + b := make([]byte, codeLen) + for i := range b { + b[i] = codeCharset[rand.Intn(len(codeCharset))] + } + return string(b) +} + +// GenerateRedeemCodes 批量生成唯一兑换码,number为生成数量 +func GenerateRedeemCodes(number int) []string { + codesMap := make(map[string]struct{}, number) + for len(codesMap) < number { + code := GenerateRedeemCode() + codesMap[code] = struct{}{} + } + codes := make([]string, 0, number) + for code := range codesMap { + codes = append(codes, code) + } + return codes +}