Enhance AI and Redis integration for smoke logging features
- Added AI configuration options to .env.example and config.go for OpenAI integration. - Implemented Redis caching for session management in main.go and auth middleware. - Updated smoke logging service to support real smoking time (`smoke_at`) and AI advice retrieval. - Enhanced API routes to include endpoints for AI advice and unlock functionality for non-members. - Improved database schema with new tables for AI advice and unlock records. - Expanded documentation to cover new AI features and Redis caching implementation.
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"wx_service/internal/middleware"
|
||||
"wx_service/internal/model"
|
||||
membershipservice "wx_service/internal/membership/service"
|
||||
)
|
||||
|
||||
type RedeemCodeHandler struct {
|
||||
svc *membershipservice.RedeemCodeService
|
||||
}
|
||||
|
||||
func NewRedeemCodeHandler(svc *membershipservice.RedeemCodeService) *RedeemCodeHandler {
|
||||
return &RedeemCodeHandler{svc: svc}
|
||||
}
|
||||
|
||||
type generateRedeemCodesRequest struct {
|
||||
Count int `json:"count"`
|
||||
Plan string `json:"plan"`
|
||||
DurationDays int `json:"duration_days"`
|
||||
ExpiresAt *string `json:"expires_at"`
|
||||
MaxUses int `json:"max_uses"`
|
||||
}
|
||||
|
||||
func (h *RedeemCodeHandler) Generate(c *gin.Context) {
|
||||
adminToken := c.GetHeader("X-Admin-Token")
|
||||
if err := h.svc.ValidateAdminToken(adminToken); err != nil {
|
||||
switch {
|
||||
case errors.Is(err, membershipservice.ErrAdminTokenRequired):
|
||||
c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "未配置后台口令,请联系管理员"))
|
||||
return
|
||||
default:
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "无权限"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
var req generateRedeemCodesRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if req.ExpiresAt != nil && *req.ExpiresAt != "" {
|
||||
parsed, err := time.ParseInLocation("2006-01-02 15:04:05", *req.ExpiresAt, time.Local)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "expires_at 格式错误,应为 YYYY-MM-DD HH:MM:SS"))
|
||||
return
|
||||
}
|
||||
expiresAt = &parsed
|
||||
}
|
||||
|
||||
codes, err := h.svc.Generate(c.Request.Context(), membershipservice.GenerateRedeemCodesRequest{
|
||||
Count: req.Count,
|
||||
Plan: req.Plan,
|
||||
DurationDays: req.DurationDays,
|
||||
ExpiresAt: expiresAt,
|
||||
MaxUses: req.MaxUses,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Success(gin.H{
|
||||
"count": len(codes),
|
||||
"codes": codes,
|
||||
}))
|
||||
}
|
||||
|
||||
type redeemRequest struct {
|
||||
Code string `json:"code" binding:"required"`
|
||||
}
|
||||
|
||||
func (h *RedeemCodeHandler) Redeem(c *gin.Context) {
|
||||
user, ok := middleware.CurrentUser(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "未登录或登录已过期"))
|
||||
return
|
||||
}
|
||||
|
||||
var req redeemRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请求参数错误"))
|
||||
return
|
||||
}
|
||||
|
||||
res, err := h.svc.Redeem(c.Request.Context(), user, req.Code, c.ClientIP(), c.GetHeader("User-Agent"))
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, membershipservice.ErrRedeemCodeInvalid):
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "兑换码无效"))
|
||||
return
|
||||
case errors.Is(err, membershipservice.ErrRedeemCodeExpired):
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "兑换码已过期"))
|
||||
return
|
||||
case errors.Is(err, membershipservice.ErrRedeemCodeUsedUp):
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "兑换码已被使用"))
|
||||
return
|
||||
case errors.Is(err, membershipservice.ErrRedeemCodeDisabled):
|
||||
c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "兑换码不可用"))
|
||||
return
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "兑换失败,请稍后重试"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Success(gin.H{
|
||||
"plan": res.Plan,
|
||||
"starts_at": res.StartsAt,
|
||||
"ends_at": res.EndsAt,
|
||||
"extended": res.Extended,
|
||||
"code_suffix": res.CodeSuffix,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// MembershipRedeemCode 存储会员兑换码(只存 hash,不存明文 code)。
|
||||
type MembershipRedeemCode struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// CodeHash 是 code 的 sha256(hex);避免 DB 泄漏导致所有兑换码被直接使用。
|
||||
CodeHash string `gorm:"size:64;uniqueIndex" json:"-"`
|
||||
// CodeSuffix 用于展示/审计(例如最后 6 位)
|
||||
CodeSuffix string `gorm:"size:16;index" json:"code_suffix"`
|
||||
|
||||
Plan string `gorm:"size:30" json:"plan"`
|
||||
DurationDays int `json:"duration_days"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
|
||||
MaxUses int `gorm:"default:1" json:"max_uses"`
|
||||
UsedUses int `gorm:"default:0" json:"used_uses"`
|
||||
|
||||
Status string `gorm:"size:20;default:active" json:"status"` // active/disabled
|
||||
}
|
||||
|
||||
func (MembershipRedeemCode) TableName() string {
|
||||
return "membership_redeem_codes"
|
||||
}
|
||||
|
||||
type MembershipRedemption struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
MiniProgramID uint `gorm:"index:idx_redeem_user_date,priority:1" json:"mini_program_id"`
|
||||
UserID uint `gorm:"index:idx_redeem_user_date,priority:2" json:"user_id"`
|
||||
|
||||
RedeemCodeID uint `gorm:"index" json:"redeem_code_id"`
|
||||
CodeSuffix string `gorm:"size:16" json:"code_suffix"`
|
||||
|
||||
MembershipID uint `gorm:"index" json:"membership_id"`
|
||||
|
||||
ClientIP string `gorm:"size:64" json:"client_ip,omitempty"`
|
||||
UserAgent string `gorm:"size:255" json:"user_agent,omitempty"`
|
||||
}
|
||||
|
||||
func (MembershipRedemption) TableName() string {
|
||||
return "membership_redemptions"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"wx_service/internal/membership/model"
|
||||
usermodel "wx_service/internal/model"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAdminTokenRequired = errors.New("admin api token is not configured")
|
||||
ErrInvalidAdminToken = errors.New("invalid admin token")
|
||||
|
||||
ErrRedeemCodeInvalid = errors.New("redeem code is invalid")
|
||||
ErrRedeemCodeExpired = errors.New("redeem code is expired")
|
||||
ErrRedeemCodeUsedUp = errors.New("redeem code is already used")
|
||||
ErrRedeemCodeDisabled = errors.New("redeem code is disabled")
|
||||
)
|
||||
|
||||
type RedeemCodeService struct {
|
||||
db *gorm.DB
|
||||
adminToken string
|
||||
}
|
||||
|
||||
func NewRedeemCodeService(db *gorm.DB, adminToken string) *RedeemCodeService {
|
||||
return &RedeemCodeService{
|
||||
db: db,
|
||||
adminToken: adminToken,
|
||||
}
|
||||
}
|
||||
|
||||
type GenerateRedeemCodesRequest struct {
|
||||
Count int
|
||||
Plan string
|
||||
DurationDays int
|
||||
ExpiresAt *time.Time
|
||||
MaxUses int
|
||||
}
|
||||
|
||||
type GeneratedRedeemCode struct {
|
||||
Code string `json:"code"`
|
||||
Plan string `json:"plan"`
|
||||
}
|
||||
|
||||
func (s *RedeemCodeService) ValidateAdminToken(token string) error {
|
||||
if s.adminToken == "" {
|
||||
return ErrAdminTokenRequired
|
||||
}
|
||||
if token == "" || token != s.adminToken {
|
||||
return ErrInvalidAdminToken
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *RedeemCodeService) Generate(ctx context.Context, req GenerateRedeemCodesRequest) ([]GeneratedRedeemCode, error) {
|
||||
count := req.Count
|
||||
if count <= 0 {
|
||||
count = 1
|
||||
}
|
||||
if count > 500 {
|
||||
count = 500
|
||||
}
|
||||
plan := strings.TrimSpace(req.Plan)
|
||||
if plan == "" {
|
||||
plan = "default"
|
||||
}
|
||||
if req.DurationDays <= 0 {
|
||||
return nil, fmt.Errorf("duration_days must be > 0")
|
||||
}
|
||||
maxUses := req.MaxUses
|
||||
if maxUses <= 0 {
|
||||
maxUses = 1
|
||||
}
|
||||
|
||||
results := make([]GeneratedRedeemCode, 0, count)
|
||||
records := make([]model.MembershipRedeemCode, 0, count)
|
||||
|
||||
// 生成时尽量保证唯一性;如遇碰撞(极低概率)则重试。
|
||||
for len(records) < count {
|
||||
code, err := generateCode(20)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash := hashCode(code)
|
||||
suffix := suffixOf(code, 6)
|
||||
|
||||
records = append(records, model.MembershipRedeemCode{
|
||||
CodeHash: hash,
|
||||
CodeSuffix: suffix,
|
||||
Plan: plan,
|
||||
DurationDays: req.DurationDays,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
MaxUses: maxUses,
|
||||
UsedUses: 0,
|
||||
Status: "active",
|
||||
})
|
||||
results = append(results, GeneratedRedeemCode{Code: code, Plan: plan})
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).Create(&records).Error; err != nil {
|
||||
return nil, fmt.Errorf("save redeem codes: %w", err)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
type RedeemResult struct {
|
||||
Plan string `json:"plan"`
|
||||
StartsAt time.Time `json:"starts_at"`
|
||||
EndsAt time.Time `json:"ends_at"`
|
||||
Extended bool `json:"extended"`
|
||||
CodeSuffix string `json:"code_suffix"`
|
||||
}
|
||||
|
||||
func (s *RedeemCodeService) Redeem(ctx context.Context, user *usermodel.User, code string, clientIP string, userAgent string) (*RedeemResult, error) {
|
||||
normalized := normalizeCode(code)
|
||||
if normalized == "" {
|
||||
return nil, ErrRedeemCodeInvalid
|
||||
}
|
||||
hash := hashCode(normalized)
|
||||
|
||||
now := time.Now()
|
||||
var result *RedeemResult
|
||||
|
||||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var redeemCode model.MembershipRedeemCode
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("code_hash = ? AND deleted_at IS NULL", hash).
|
||||
First(&redeemCode).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrRedeemCodeInvalid
|
||||
}
|
||||
return fmt.Errorf("load redeem code: %w", err)
|
||||
}
|
||||
|
||||
if redeemCode.Status != "" && redeemCode.Status != "active" {
|
||||
return ErrRedeemCodeDisabled
|
||||
}
|
||||
if redeemCode.ExpiresAt != nil && redeemCode.ExpiresAt.Before(now) {
|
||||
return ErrRedeemCodeExpired
|
||||
}
|
||||
if redeemCode.MaxUses <= 0 {
|
||||
redeemCode.MaxUses = 1
|
||||
}
|
||||
if redeemCode.UsedUses >= redeemCode.MaxUses {
|
||||
return ErrRedeemCodeUsedUp
|
||||
}
|
||||
if redeemCode.DurationDays <= 0 {
|
||||
return fmt.Errorf("redeem code duration_days invalid")
|
||||
}
|
||||
|
||||
// 兑换:先创建/延长会员,再计数,最后写 redemption log。
|
||||
var membership usermodel.UserMembership
|
||||
var hasActive bool
|
||||
if err := tx.
|
||||
Where("mini_program_id = ? AND user_id = ? AND status = ? AND ends_at > ?",
|
||||
user.MiniProgramID, user.ID, "active", now).
|
||||
Order("ends_at DESC").
|
||||
First(&membership).Error; err == nil {
|
||||
hasActive = true
|
||||
} else if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("load membership: %w", err)
|
||||
}
|
||||
|
||||
base := now
|
||||
if hasActive && membership.EndsAt.After(now) {
|
||||
base = membership.EndsAt
|
||||
}
|
||||
newEnds := base.AddDate(0, 0, redeemCode.DurationDays)
|
||||
|
||||
if hasActive {
|
||||
if err := tx.Model(&membership).Updates(map[string]interface{}{
|
||||
"ends_at": newEnds,
|
||||
"updated_at": now,
|
||||
}).Error; err != nil {
|
||||
return fmt.Errorf("extend membership: %w", err)
|
||||
}
|
||||
} else {
|
||||
membership = usermodel.UserMembership{
|
||||
MiniProgramID: user.MiniProgramID,
|
||||
UserID: user.ID,
|
||||
Plan: redeemCode.Plan,
|
||||
Status: "active",
|
||||
StartsAt: now,
|
||||
EndsAt: newEnds,
|
||||
}
|
||||
if err := tx.Create(&membership).Error; err != nil {
|
||||
return fmt.Errorf("create membership: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&redeemCode).UpdateColumn("used_uses", gorm.Expr("used_uses + 1")).Error; err != nil {
|
||||
return fmt.Errorf("update redeem usage: %w", err)
|
||||
}
|
||||
|
||||
redemption := model.MembershipRedemption{
|
||||
MiniProgramID: user.MiniProgramID,
|
||||
UserID: user.ID,
|
||||
RedeemCodeID: redeemCode.ID,
|
||||
CodeSuffix: redeemCode.CodeSuffix,
|
||||
MembershipID: membership.ID,
|
||||
ClientIP: truncateString(clientIP, 64),
|
||||
UserAgent: truncateString(userAgent, 255),
|
||||
}
|
||||
if err := tx.Create(&redemption).Error; err != nil {
|
||||
return fmt.Errorf("create redemption log: %w", err)
|
||||
}
|
||||
|
||||
result = &RedeemResult{
|
||||
Plan: redeemCode.Plan,
|
||||
StartsAt: membership.StartsAt,
|
||||
EndsAt: newEnds,
|
||||
Extended: hasActive,
|
||||
CodeSuffix: redeemCode.CodeSuffix,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func normalizeCode(code string) string {
|
||||
c := strings.TrimSpace(code)
|
||||
c = strings.ReplaceAll(c, "-", "")
|
||||
c = strings.ReplaceAll(c, " ", "")
|
||||
c = strings.ToUpper(c)
|
||||
return c
|
||||
}
|
||||
|
||||
func hashCode(code string) string {
|
||||
sum := sha256.Sum256([]byte(code))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func suffixOf(code string, n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(code) <= n {
|
||||
return code
|
||||
}
|
||||
return code[len(code)-n:]
|
||||
}
|
||||
|
||||
func generateCode(length int) (string, error) {
|
||||
if length <= 0 {
|
||||
length = 16
|
||||
}
|
||||
// 去掉易混淆字符:0/O, 1/I/L
|
||||
const alphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
buf := make([]byte, length)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
return "", fmt.Errorf("rand: %w", err)
|
||||
}
|
||||
out := make([]byte, length)
|
||||
for i, b := range buf {
|
||||
out[i] = alphabet[int(b)%len(alphabet)]
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func truncateString(s string, max int) string {
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s
|
||||
}
|
||||
return s[:max]
|
||||
}
|
||||
Reference in New Issue
Block a user