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:
nepiedg
2026-01-03 02:14:21 +00:00
parent 1c48fbdeaf
commit 16844d4a42
30 changed files with 1662 additions and 9 deletions
@@ -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,
}))
}
+56
View File
@@ -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]
}