2024 桐庐半程马拉松
00:00:00
时间
0.00
距离(公里)
--:--
配速
--
步频
--
心率 (bpm)
--
配速
步频
|
share-image
ESC

基于Gin框架构建高性能运动数据服务端:从架构设计到生产实践

本文详细记录了一个完整的运动APP后端服务的构建过程,涵盖Gin框架搭建、JWT认证、阿里云短信集成、MySQL与InfluxDB双存储架构、Redis缓存优化、安全防护策略等核心技术的完整实现。

目录

  1. 项目概述与架构设计
  2. 技术栈选型与依赖管理
  3. 项目结构规范
  4. 配置管理与环境隔离
  5. 数据库设计与双存储架构
  6. 用户认证与JWT实现
  7. 阿里云短信验证码集成
  8. 运动数据服务层设计
  9. 缓存策略与性能优化
  10. 安全防护与中间件
  11. 部署与运维
  12. 总结与展望

1. 项目概述与架构设计

1.1 项目背景

本项目是为一款专业骑行/跑步运动APP设计的后端服务,需要支持以下核心功能:

  • 用户系统:注册、登录、JWT认证、个人信息管理
  • 运动数据:GPS轨迹、心率数据、运动统计、历史记录
  • 实时数据:蓝牙心率带数据接入、实时心率监控
  • 数据同步:支持批量导入、数据导出、多设备同步
  • 通知服务:短信验证码、邮件通知

1.2 整体架构

┌─────────────────────────────────────────────────────────────────┐
│ 客户端层 │
│ Android App │ Web Dashboard │
└──────────────────────────────┼──────────────────────────────────┘
│ HTTPS
┌──────────────────────────────▼──────────────────────────────────┐
│ API Gateway │
│ Nginx (负载均衡、SSL终止、静态资源) │
└──────────────────────────────┬──────────────────────────────────┘

┌──────────────────────────────▼──────────────────────────────────┐
│ Go Gin Server (8080) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────┐ │
│ │ Auth API │ │ Workout API│ │ HeartRate │ │ Bluetooth │ │
│ │ (JWT认证) │ │ (运动数据) │ │ (心率) │ │ (蓝牙设备) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └─────┬─────┘ │
│ │ │ │ │ │
│ ┌──────▼───────────────▼───────────────▼──────────────▼─────┐ │
│ │ Middleware Layer (中间件层) │ │
│ │ JWTAuth │ RateLimit │ CORS │ Security │ Logging │ Recover │ │
│ └────────────────────────────────────────────────────────────┘ │
└──────────────────────────────┬──────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ MySQL │ │ InfluxDB │ │ Redis │
│ (用户数据) │ │ (时序数据) │ │ (缓存/会话) │
└─────────────┘ └─────────────┘ └─────────────┘

1.3 双存储架构设计

针对运动数据的特点,采用MySQL + InfluxDB双存储架构:

存储类型 数据类型 使用场景
MySQL 用户、设备、配置 关系型数据,事务支持
InfluxDB GPS点、心率、运动记录 时序数据,高效写入和聚合查询
Redis 缓存、会话、限流 高性能临时数据存储

2. 技术栈选型与依赖管理

2.1 核心技术栈

// go.mod
module bicycle_go_server

go 1.24.0

require (
github.com/gin-gonic/gin v1.11.0 // Web框架
github.com/golang-jwt/jwt/v5 v5.3.1 // JWT认证
github.com/redis/go-redis/v9 v9.17.3 // Redis客户端
golang.org/x/crypto v0.47.0 // bcrypt加密
gorm.io/driver/mysql v1.6.0 // MySQL驱动
gorm.io/gorm v1.31.1 // ORM框架
github.com/influxdata/influxdb-client-go/v2 v2.14.0 // InfluxDB
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.14 // 阿里云SDK
github.com/alibabacloud-go/tea v1.3.13 // 阿里云Tea
gopkg.in/gomail.v2 v2.0.0 // 邮件发送
golang.org/x/time v0.14.0 // 限流器
)

2.2 技术选型理由

技术组件 选型 理由
Web框架 Gin 性能优异,社区活跃,中间件生态丰富
数据库 MySQL 8.0 关系型数据存储,支持JSON字段
时序数据库 InfluxDB 2.x 专为时序数据优化,支持Flux查询语言
缓存 Redis 7.x 支持数据结构丰富,性能卓越
认证 JWT 无状态认证,适合分布式部署
密码加密 bcrypt 行业标准的密码哈希算法
短信服务 阿里云Dypnsapi 号码认证服务,支持自动验证码生成

3. 项目结构规范

采用Clean Architecture分层架构,目录结构如下:

bicycle_go_server/
├── cmd/
│ └── server/
│ └── main.go # 应用程序入口
├── config/
│ ├── config.go # 配置结构定义与加载
│ ├── database.go # MySQL连接初始化
│ ├── redis.go # Redis连接初始化
│ └── influxdb.go # InfluxDB连接初始化
├── internal/
│ ├── handlers/ # HTTP处理器(Controller层)
│ │ ├── user.go # 用户相关接口
│ │ ├── workout.go # 运动数据接口
│ │ ├── heartrate.go # 心率数据接口
│ │ ├── bluetooth.go # 蓝牙设备接口
│ │ ├── sms.go # 短信验证码接口
│ │ └── email.go # 邮件接口
│ ├── service/ # 业务逻辑层
│ │ └── workout_service.go # 运动数据服务
│ ├── repository/ # 数据访问层
│ │ ├── workout_repository.go # MySQL数据访问
│ │ └── workout_influx_repository.go # InfluxDB数据访问
│ ├── middleware/ # 中间件
│ │ ├── jwt.go # JWT认证
│ │ ├── ratelimit.go # 限流中间件
│ │ └── security.go # 安全中间件
│ └── models/ # 数据模型
│ ├── models.go # 数据库模型
│ ├── dto.go # 请求/响应DTO
│ └── workout.go # 运动数据模型
├── pkg/ # 公共包
│ ├── response/ # 统一响应封装
│ ├── utils/ # 工具函数
│ ├── sms/ # 短信服务
│ └── email/ # 邮件服务
├── go.mod
├── go.sum
└── .env.example # 环境变量示例

3.1 分层职责

┌─────────────────────────────────────────┐
│ Handlers │ ← HTTP请求处理,参数校验
│ (gin.Context → DTO → Service) │
├─────────────────────────────────────────┤
│ Service │ ← 业务逻辑编排
│ (数据转换、缓存策略、事务管理) │
├─────────────────────────────────────────┤
│ Repository │ ← 数据持久化
│ (MySQL/InfluxDB/Redis操作) │
├─────────────────────────────────────────┤
│ Models │ ← 数据模型定义
└─────────────────────────────────────────┘

4. 配置管理与环境隔离

4.1 配置结构设计

// config/config.go
type Config struct {
Server ServerConfig
Database DatabaseConfig
InfluxDB InfluxDBConfig
JWT JWTConfig
Security SecurityConfig
Aliyun AliyunConfig
Redis RedisConfig
Email EmailConfig
}

type ServerConfig struct {
Port string
ReadTimeout time.Duration
WriteTimeout time.Duration
}

type DatabaseConfig struct {
Host string
Port string
User string
Password string
DBName string
MaxIdleConns int
MaxOpenConns int
}

4.2 环境变量加载

// LoadConfig 加载配置
func LoadConfig() *Config {
return &Config{
Server: ServerConfig{
Port: getEnv("SERVER_PORT", "8080"),
ReadTimeout: time.Duration(getEnvAsInt("SERVER_READ_TIMEOUT", 10)) * time.Second,
WriteTimeout: time.Duration(getEnvAsInt("SERVER_WRITE_TIMEOUT", 10)) * time.Second,
},
Database: DatabaseConfig{
Host: getEnv("DB_HOST", "localhost"),
Port: getEnv("DB_PORT", "3306"),
User: getEnv("DB_USER", "root"),
Password: getEnv("DB_PASSWORD", ""),
DBName: getEnv("DB_NAME", "bicycle"),
MaxIdleConns: getEnvAsInt("DB_MAX_IDLE_CONNS", 10),
MaxOpenConns: getEnvAsInt("DB_MAX_OPEN_CONNS", 100),
},
JWT: JWTConfig{
Secret: getEnv("JWT_SECRET", "change-in-production"),
ExpireTime: time.Duration(getEnvAsInt("JWT_EXPIRE_HOURS", 24)) * time.Hour,
},
// ... 其他配置
}
}

func getEnv(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}

4.3 环境变量配置示例

# .env
# 数据库配置
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
DB_PASSWORD=your_password
DB_NAME=bicycle

# JWT配置
JWT_SECRET=your-super-secret-key

# Redis配置
REDIS_HOST=localhost
REDIS_PORT=6379

# 阿里云短信配置
ALIYUN_ACCESS_KEY_ID=your-access-key
ALIYUN_ACCESS_KEY_SECRET=your-secret
ALIYUN_SMS_SIGN_NAME=your-sign-name
ALIYUN_SMS_TEMPLATE_CODE=100001

# InfluxDB配置
INFLUXDB_URL=http://localhost:8086
INFLUXDB_TOKEN=your-token
INFLUXDB_ORG=your-org
INFLUXDB_BUCKET=heart_rate

5. 数据库设计与双存储架构

5.1 MySQL关系型数据模型

-- 用户表
CREATE TABLE `users` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(50) NOT NULL UNIQUE,
`password` VARCHAR(100) NOT NULL,
`register_type` VARCHAR(10) DEFAULT 'phone',
`phone` VARCHAR(20),
`email` VARCHAR(100),
`nickname` VARCHAR(50),
`gender` VARCHAR(10),
`birthday` DATE,
`province` VARCHAR(50),
`city` VARCHAR(50),
`district` VARCHAR(50),
`is_logged_in` TINYINT(1) DEFAULT 0,
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_phone` (`phone`),
INDEX `idx_email` (`email`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 运动记录表
CREATE TABLE `running_sessions` (
`id` VARCHAR(36) PRIMARY KEY,
`user_id` INT UNSIGNED,
`start_time` BIGINT NOT NULL,
`end_time` BIGINT,
`total_distance_meters` DOUBLE DEFAULT 0,
`total_duration_seconds` BIGINT DEFAULT 0,
`average_speed` FLOAT DEFAULT 0,
`max_speed` FLOAT DEFAULT 0,
`average_heart_rate` INT DEFAULT 0,
`max_heart_rate` INT DEFAULT 0,
`calories` INT DEFAULT 0,
`city_name` VARCHAR(50),
`activity_type` VARCHAR(20) DEFAULT '骑行',
`total_ascent` DOUBLE DEFAULT 0,
`thumbnail_path` VARCHAR(255),
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX `idx_user_id` (`user_id`),
INDEX `idx_start_time` (`start_time`),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- GPS轨迹点表
CREATE TABLE `gps_points` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`session_id` VARCHAR(36) NOT NULL,
`latitude` DOUBLE NOT NULL,
`longitude` DOUBLE NOT NULL,
`altitude` DOUBLE DEFAULT 0,
`timestamp` BIGINT NOT NULL,
`accuracy` FLOAT DEFAULT 0,
`speed` FLOAT DEFAULT 0,
`heart_rate` INT DEFAULT 0,
`point_order` INT DEFAULT 0,
INDEX `idx_session_id` (`session_id`),
INDEX `idx_session_timestamp` (`session_id`, `timestamp`),
FOREIGN KEY (`session_id`) REFERENCES `running_sessions` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

5.2 GORM模型定义

// internal/models/models.go
type User struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Username string `gorm:"size:50;not null;uniqueIndex" json:"username"`
Password string `gorm:"size:100;not null" json:"-"` // 不返回密码
RegisterType string `gorm:"size:10;default:phone" json:"register_type"`
Phone *string `gorm:"size:20" json:"phone,omitempty"`
Email *string `gorm:"size:100" json:"email,omitempty"`
Nickname *string `gorm:"size:50" json:"nickname,omitempty"`
Gender *string `gorm:"size:10" json:"gender,omitempty"`
Birthday *time.Time `gorm:"type:date" json:"birthday,omitempty"`
Province *string `gorm:"size:50" json:"province,omitempty"`
City *string `gorm:"size:50" json:"city,omitempty"`
District *string `gorm:"size:50" json:"district,omitempty"`
IsLoggedIn int8 `gorm:"default:0" json:"is_logged_in"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}

type RunningSession struct {
ID string `gorm:"primaryKey;size:36" json:"id"`
UserID *uint `gorm:"index" json:"user_id,omitempty"`
StartTime int64 `gorm:"not null" json:"start_time"`
EndTime *int64 `json:"end_time,omitempty"`
TotalDistanceMeters float64 `gorm:"default:0" json:"total_distance_meters"`
TotalDurationSeconds int64 `gorm:"default:0" json:"total_duration_seconds"`
AverageSpeed float32 `gorm:"default:0" json:"average_speed"`
MaxSpeed float32 `gorm:"default:0" json:"max_speed"`
AverageHeartRate int `gorm:"default:0" json:"average_heart_rate"`
MaxHeartRate int `gorm:"default:0" json:"max_heart_rate"`
Calories int `gorm:"default:0" json:"calories"`
CityName string `gorm:"size:50" json:"city_name"`
ActivityType string `gorm:"size:20" json:"activity_type"`
TotalAscent float64 `gorm:"default:0" json:"total_ascent"`
ThumbnailPath string `gorm:"size:255" json:"thumbnail_path"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
GpsPoints []GpsPoint `gorm:"foreignKey:SessionID" json:"gps_points,omitempty"`
}

5.3 InfluxDB时序数据存储

// internal/repository/workout_influx_repository.go

type WorkoutInfluxRepository struct {
client *influxdb2.Client
org string
bucket string
}

// WriteWorkout 写入运动数据到InfluxDB
func (r *WorkoutInfluxRepository) WriteWorkout(ctx context.Context, workout *models.WorkoutRecord, rawJSON string) error {
writeAPI := r.client.WriteAPI(r.org, r.bucket)

// 创建point
point := influxdb2.NewPoint(
"workout", // measurement
map[string]string{
"original_id": workout.OriginalID,
"sport_type": workout.SportType,
"record_type": workout.RecordType,
"data_source": workout.DataSource,
},
map[string]interface{}{
"name": workout.Name,
"duration_sec": workout.DurationSec,
"distance_m": workout.DistanceM,
"avg_heart_rate": workout.AvgHeartRate,
"total_climb": workout.TotalClimb,
"training_load": workout.TrainingLoad,
"avg_pace": workout.AvgPace,
"run_power": workout.RunPower,
"raw_json": rawJSON,
},
workout.StartTime,
)

writeAPI.WritePoint(point)
writeAPI.Flush()

return nil
}

// ListWorkoutsAsMap 查询运动记录(返回map格式用于缓存)
func (r *WorkoutInfluxRepository) ListWorkoutsAsMap(ctx context.Context, days int) ([]map[string]interface{}, error) {
queryAPI := r.client.QueryAPI(r.org)

var timeFilter string
if days > 0 {
timeFilter = fmt.Sprintf(|> range(start: -%dd)`, days)
} else {
timeFilter = `|> range(start: 0)` // 查询全部
}

query := fmt.Sprintf(`
from(bucket: "%s")
%s
|> filter(fn: (r) => r._measurement == "workout")
|> pivot(rowKey:["_time"], columnKey: ["_field"], valueColumn: "_value")
|> sort(columns: ["_time"], desc: true)
`, r.bucket, timeFilter)

result, err := queryAPI.Query(ctx, query)
if err != nil {
return nil, err
}

var workouts []map[string]interface{}
for result.Next() {
record := result.Record()
workout := make(map[string]interface{})

// 转换记录为map
for k, v := range record.Values() {
if k != "_measurement" && k != "_start" && k != "_stop" {
workout[k] = v
}
}
workouts = append(workouts, workout)
}

return workouts, nil
}

6. 用户认证与JWT实现

6.1 JWT中间件实现

// internal/middleware/jwt.go

type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
jwt.RegisteredClaims
}

var jwtSecret []byte

// InitJWT 初始化JWT密钥
func InitJWT(secret string) {
jwtSecret = []byte(secret)
}

// GenerateToken 生成JWT Token
func GenerateToken(userID uint, username string, expireTime time.Duration) (string, error) {
claims := Claims{
UserID: userID,
Username: username,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(expireTime)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
Issuer: "bicycle-api",
},
}

token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(jwtSecret)
}

// JWTAuth JWT认证中间件
func JWTAuth() gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
response.Unauthorized(c, "请提供认证令牌")
c.Abort()
return
}

// 支持 "Bearer <token>" 格式
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
response.Unauthorized(c, "认证令牌格式错误")
c.Abort()
return
}

tokenString := parts[1]
claims, err := ParseToken(tokenString)
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
response.Error(c, 401, response.CodeTokenExpired, "认证令牌已过期")
} else {
response.Error(c, 401, response.CodeTokenInvalid, "认证令牌无效")
}
c.Abort()
return
}

// 将用户信息存入上下文
c.Set("userID", claims.UserID)
c.Set("username", claims.Username)
c.Next()
}
}

// ParseToken 解析JWT Token
func ParseToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New("unexpected signing method")
}
return jwtSecret, nil
})

if err != nil {
return nil, err
}

if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}

return nil, errors.New("invalid token")
}

6.2 用户登录实现

// internal/handlers/user.go

func (h *UserHandler) Login(c *gin.Context) {
var req models.LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误: "+err.Error())
return
}

// 查找用户
var user models.User
if err := h.DB.Where("username = ?", req.Username).First(&user).Error; err != nil {
response.Error(c, 401, response.CodeUserNotFound, "用户名或密码错误")
return
}

// 验证密码 (bcrypt)
if !utils.CheckPassword(req.Password, user.Password) {
response.Error(c, 401, response.CodePasswordIncorrect, "用户名或密码错误")
return
}

// 生成Token
token, err := middleware.GenerateToken(user.ID, user.Username, h.Cfg.JWT.ExpireTime)
if err != nil {
response.InternalError(c, "生成令牌失败")
return
}

// 更新登录状态
h.DB.Model(&user).Update("is_logged_in", 1)

response.Success(c, models.LoginResponse{
Token: token,
User: user,
})
}

// Register 用户注册
func (h *UserHandler) Register(c *gin.Context) {
var req models.RegisterRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "请求参数错误: "+err.Error())
return
}

// 验证用户名和密码强度
if !utils.ValidateUsername(req.Username) {
response.BadRequest(c, "用户名格式不正确")
return
}
if valid, msg := utils.ValidatePassword(req.Password); !valid {
response.BadRequest(c, msg)
return
}

// 检查用户是否已存在
var existingUser models.User
if err := h.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil {
response.Conflict(c, "用户名已存在")
return
}

// 加密密码
hashedPassword, err := utils.HashPassword(req.Password)
if err != nil {
response.InternalError(c, "密码加密失败")
return
}

// 创建用户
user := models.User{
Username: req.Username,
Password: hashedPassword,
RegisterType: req.RegisterType,
Phone: utils.StringPointer(req.Phone),
Email: utils.StringPointer(req.Email),
}

if err := h.DB.Create(&user).Error; err != nil {
response.InternalError(c, "创建用户失败")
return
}

response.Created(c, models.IDResponse{ID: user.ID})
}

6.3 密码加密工具

// pkg/utils/utils.go

import "golang.org/x/crypto/bcrypt"

var bcryptCost = 12 // 可配置

// HashPassword 使用bcrypt加密密码
func HashPassword(password string) (string, error) {
bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost)
return string(bytes), err
}

// CheckPassword 验证密码
func CheckPassword(password, hashedPassword string) bool {
err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
return err == nil
}

// ValidatePassword 验证密码强度
func ValidatePassword(password string) (bool, string) {
if len(password) < 6 {
return false, "密码长度至少6位"
}
if len(password) > 20 {
return false, "密码长度不能超过20位"
}
return true, ""
}

7. 阿里云短信验证码集成

7.1 阿里云Dypnsapi服务封装

// pkg/sms/aliyun_sms.go

type AliyunSmsService struct {
client *dypnsapi.Client
rdb *redis.Client
signName string
templateCode string
codeExpireTime time.Duration // 验证码有效期: 15分钟
sendInterval time.Duration // 发送间隔: 60秒
dailyLimit int // 每日发送上限: 10次
ipDailyLimit int // 每个IP每日上限: 20次
}

// NewAliyunSmsService 创建阿里云短信服务实例
func NewAliyunSmsService(accessKeyID, accessKeySecret, signName, templateCode string, rdb *redis.Client) (*AliyunSmsService, error) {
config := &openapi.Config{
AccessKeyId: tea.String(accessKeyID),
AccessKeySecret: tea.String(accessKeySecret),
Endpoint: tea.String("dypnsapi.aliyuncs.com"),
}

client, err := dypnsapi.NewClient(config)
if err != nil {
return nil, fmt.Errorf("failed to create aliyun sms client: %w", err)
}

return &AliyunSmsService{
client: client,
rdb: rdb,
signName: signName,
templateCode: templateCode,
codeExpireTime: 15 * time.Minute,
sendInterval: 60 * time.Second,
dailyLimit: 10,
ipDailyLimit: 20,
}, nil
}

// SendVerifyCode 发送短信验证码
func (s *AliyunSmsService) SendVerifyCode(ctx context.Context, phone, codeType, ip string) (bizID, requestID string, err error) {
// 检查发送频率限制
if err := s.checkRateLimit(ctx, phone, ip); err != nil {
return "", "", err
}

// 使用Dypnsapi SendSmsVerifyCode接口
// 该接口会自动生成验证码并发送
sendCodeRequest := &dypnsapi.SendSmsVerifyCodeRequest{
PhoneNumber: tea.String(phone),
SignName: tea.String(s.signName),
TemplateCode: tea.String(s.templateCode),
TemplateParam: tea.String(`{"code":"##code##","min":"15"}`),
CodeType: tea.Int64(1), // 1表示数字验证码
CodeLength: tea.Int64(6), // 6位验证码
Interval: tea.Int64(60), // 重发间隔60秒
ValidTime: tea.Int64(15), // 有效期15分钟
}

sendCodeResponse, err := s.client.SendSmsVerifyCode(sendCodeRequest)
if err != nil {
return "", "", fmt.Errorf("failed to send sms: %w", err)
}

if sendCodeResponse.Body == nil || *sendCodeResponse.Body.Code != "OK" {
errMsg := "unknown error"
if sendCodeResponse.Body != nil && sendCodeResponse.Body.Message != nil {
errMsg = *sendCodeResponse.Body.Message
}
return "", "", fmt.Errorf("sms send failed: %s", errMsg)
}

// 记录发送次数
s.recordSend(ctx, phone, ip)

requestID = ""
if sendCodeResponse.Body.RequestId != nil {
requestID = *sendCodeResponse.Body.RequestId
}

return "", requestID, nil
}

// VerifyCode 验证短信验证码
func (s *AliyunSmsService) VerifyCode(ctx context.Context, phone, codeType, code string) (bool, error) {
checkRequest := &dypnsapi.CheckSmsVerifyCodeRequest{
PhoneNumber: tea.String(phone),
VerifyCode: tea.String(code),
}

checkResponse, err := s.client.CheckSmsVerifyCode(checkRequest)
if err != nil {
return false, fmt.Errorf("failed to verify code: %w", err)
}

if checkResponse.Body == nil || checkResponse.Body.Code == nil || *checkResponse.Body.Code != "OK" {
return false, nil
}

return true, nil
}

// 频率限制检查
func (s *AliyunSmsService) checkRateLimit(ctx context.Context, phone, ip string) error {
// 检查发送间隔(60秒内只能发送1次)
intervalKey := fmt.Sprintf("sms:interval:%s", phone)
exists, err := s.rdb.Exists(ctx, intervalKey).Result()
if err != nil {
return fmt.Errorf("failed to check interval: %w", err)
}
if exists > 0 {
return fmt.Errorf("发送过于频繁,请60秒后再试")
}

// 检查每日发送次数限制
dailyKey := fmt.Sprintf("sms:daily:%s:%s", time.Now().Format("20060102"), phone)
count, err := s.rdb.Get(ctx, dailyKey).Int()
if err != nil && err != redis.Nil {
return fmt.Errorf("failed to check daily limit: %w", err)
}
if count >= s.dailyLimit {
return fmt.Errorf("今日发送次数已达上限")
}

return nil
}

8. 运动数据服务层设计

8.1 数据导入服务

// internal/service/workout_service.go

type WorkoutService struct {
repo *repository.WorkoutRepository // MySQL存储
influxRepo *repository.WorkoutInfluxRepository // InfluxDB存储
redis *redis.Client
}

// ImportWorkouts 批量导入运动数据
func (s *WorkoutService) ImportWorkouts(ctx context.Context, records []models.WorkoutImportRecord) (int, int, error) {
inserted := 0
skipped := 0

// CST 时区(UTC+8)
loc := time.FixedZone("CST", 8*3600)

for _, record := range records {
// 获取ID
recordID := record.ID
if recordID == "" {
recordID = record.OriginalID
}
if recordID == "" {
skipped++
continue
}

// 解析时间 - 支持多种格式
var t time.Time
var err error

// 1. 优先使用 "2026-01-28 11:28:56" 格式
if record.Raw.TrainingAt != "" {
t, err = time.ParseInLocation("2006-01-02 15:04:05", record.Raw.TrainingAt, loc)
}
// 2. 回退到 "26/01/28 11:28" 格式
if (err != nil || t.IsZero()) && record.StartTime != "" {
t, err = time.ParseInLocation("06/01/02 15:04", record.StartTime, loc)
}
// 3. 回退到 ISO8601 格式
if (err != nil || t.IsZero()) && record.Time != "" {
t, err = time.Parse(time.RFC3339, record.Time)
}
if err != nil || t.IsZero() {
skipped++
continue
}

// 解析距离(支持 string("9.42km") 或 number(9.42))
var distVal float64
if record.Distance != nil {
distVal = s.parseFlexDistance(record.Distance)
}

// 解析时长 "00:40:15" -> 秒数
var durVal int
if record.Duration != "" {
durVal = s.parseDurationStr(record.Duration)
}

// 写入 InfluxDB
workout := &models.WorkoutRecord{
OriginalID: recordID,
Name: record.Name,
SportType: record.SportType,
StartTime: t,
DurationSec: durVal,
DistanceM: distVal * 1000, // km -> m
AvgHeartRate: s.parseFlexInt(record.AvgHeartRate),
TotalClimb: s.parseFlexFloat(record.TotalClimb),
}

rawJSON, _ := json.Marshal(record)
if err := s.influxRepo.WriteWorkout(ctx, workout, string(rawJSON)); err != nil {
skipped++
continue
}
inserted++
}

// 清除缓存
s.clearStatsCache(ctx)
s.clearListCache(ctx)

return inserted, skipped, nil
}

// 灵活解析距离字段
func (s *WorkoutService) parseFlexDistance(v interface{}) float64 {
switch val := v.(type) {
case string:
d := strings.ToLower(val)
d = strings.ReplaceAll(d, "km", "")
f, _ := strconv.ParseFloat(d, 64)
return f
case float64:
return val
case int:
return float64(val)
default:
return 0
}
}

8.2 列表查询服务

// ListWorkouts 查询运动记录列表 - 带缓存
func (s *WorkoutService) ListWorkouts(ctx context.Context, days string) ([]map[string]interface{}, bool, error) {
daysInt, _ := strconv.Atoi(days)

// Redis Cache Key
cacheKey := fmt.Sprintf("workouts:list:%d", daysInt)

log.Printf("[WorkoutService] getWorkouts request: days=%d, cacheKey=%s", daysInt, cacheKey)

// Try Cache
if s.redis != nil {
val, err := s.redis.Get(ctx, cacheKey).Result()
if err == nil {
log.Println("[WorkoutService] Cache HIT")
var workouts []map[string]interface{}
if err := json.Unmarshal([]byte(val), &workouts); err == nil {
return workouts, true, nil
}
}
log.Printf("[WorkoutService] Cache MISS (err: %v)", err)
}

// Query InfluxDB
workouts, err := s.influxRepo.ListWorkoutsAsMap(ctx, daysInt)
if err != nil {
return nil, false, err
}

// Set Cache (10分钟)
if s.redis != nil && len(workouts) > 0 {
data, _ := json.Marshal(workouts)
s.redis.Set(ctx, cacheKey, data, 10*time.Minute)
}

return workouts, false, nil
}

9. 缓存策略与性能优化

9.1 缓存策略设计

┌─────────────────────────────────────────────────────────────┐
│ 缓存策略 │
├─────────────────┬─────────────────┬─────────────────────────┤
│ 数据类型 │ 缓存位置 │ 过期时间 │
├─────────────────┼─────────────────┼─────────────────────────┤
│ 运动记录列表 │ Redis │ 10分钟 │
│ 统计总览 │ Redis │ 1小时(定时刷新) │
│ 周期统计 │ Redis │ 1小时(定时刷新) │
│ 短信发送频率 │ Redis │ 60秒 / 当天结束 │
│ 用户会话 │ JWT Token24小时 │
└─────────────────┴─────────────────┴─────────────────────────┘

9.2 定时统计任务

// StartStatsCron 启动定时统计任务
func (s *WorkoutService) StartStatsCron() {
// 初始运行
log.Println("Starting initial stats calculation...")
s.CalculateAndCacheStats()

// 每小时刷新
go func() {
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()

for range ticker.C {
log.Println("Running scheduled stats calculation...")
s.CalculateAndCacheStats()
}
}()
}

// CalculateAndCacheStats 计算并缓存统计数据
func (s *WorkoutService) CalculateAndCacheStats() {
if s.redis == nil {
return
}

ctx := context.Background()

// 查询所有运动数据
stats, monthlyMap, yearlyMap, err := s.influxRepo.CalculateAllStats(ctx)
if err != nil {
log.Printf("Error calculating stats: %v", err)
return
}

// 保存 Overview
overviewJSON, _ := json.Marshal(stats)
s.redis.Set(ctx, "workouts:stats:overview", overviewJSON, 0)

// 保存 Period Stats
periodResp := &models.WorkoutStatsPeriod{
Monthly: make([]models.PeriodStats, 0, len(monthlyMap)),
Yearly: make([]models.PeriodStats, 0, len(yearlyMap)),
LastUpdated: stats.LastUpdated,
}

// 按时间倒序排列
sort.Slice(periodResp.Monthly, func(i, j int) bool {
return periodResp.Monthly[i].Period > periodResp.Monthly[j].Period
})

periodJSON, _ := json.Marshal(periodResp)
s.redis.Set(ctx, "workouts:stats:period", periodJSON, 0)

log.Println("Stats calculation completed and cached.")
}

10. 安全防护与中间件

10.1 中间件链配置

// cmd/server/main.go

func main() {
// ...

// 创建限流器
ipLimiter := middleware.NewIPRateLimiter(cfg.Security.RateLimitRPS, cfg.Security.RateLimitBurst)

// 全局中间件(按执行顺序)
r.Use(gin.Recovery()) // 恢复panic
r.Use(middleware.RequestLogger()) // 请求日志
r.Use(middleware.SecurityHeaders()) // 安全头
r.Use(middleware.CORS(cfg.Security.AllowedOrigins)) // CORS
r.Use(middleware.RequestSizeLimit(cfg.Security.MaxRequestSize)) // 请求大小限制
r.Use(middleware.RateLimitMiddleware(ipLimiter)) // IP限流
r.Use(middleware.InputSanitizer()) // 输入清洗
r.Use(middleware.SQLInjectionCheck()) // SQL注入检测

// ...
}

10.2 限流中间件

// internal/middleware/ratelimit.go

import "golang.org/x/time/rate"

// IPRateLimiter IP限流器
type IPRateLimiter struct {
limiters map[string]*rate.Limiter
mu sync.RWMutex
rps rate.Limit
burst int
}

// NewIPRateLimiter 创建IP限流器
func NewIPRateLimiter(rps rate.Limit, burst int) *IPRateLimiter {
return &IPRateLimiter{
limiters: make(map[string]*rate.Limiter),
rps: rps,
burst: burst,
}
}

// GetLimiter 获取指定IP的限流器
func (i *IPRateLimiter) GetLimiter(ip string) *rate.Limiter {
i.mu.Lock()
defer i.mu.Unlock()

limiter, exists := i.limiters[ip]
if !exists {
limiter = rate.NewLimiter(i.rps, i.burst)
i.limiters[ip] = limiter
}

return limiter
}

// RateLimitMiddleware 限流中间件
func RateLimitMiddleware(limiter *IPRateLimiter) gin.HandlerFunc {
return func(c *gin.Context) {
ip := c.ClientIP()
if !limiter.GetLimiter(ip).Allow() {
response.Error(c, 429, response.CodeTooManyRequests, "请求过于频繁,请稍后再试")
c.Abort()
return
}
c.Next()
}
}

10.3 安全中间件

// internal/middleware/security.go

// SecurityHeaders 安全头中间件
func SecurityHeaders() gin.HandlerFunc {
return func(c *gin.Context) {
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "DENY")
c.Header("X-XSS-Protection", "1; mode=block")
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
c.Header("Content-Security-Policy", "default-src 'self'")
c.Next()
}
}

// SQLInjectionCheck SQL注入检测中间件
func SQLInjectionCheck() gin.HandlerFunc {
sqlKeywords := []string{
"select ", "insert ", "update ", "delete ", "drop ",
"union ", "exec ", "execute ", "truncate ", "create ",
}

return func(c *gin.Context) {
// 检查查询参数
for key, values := range c.Request.URL.Query() {
for _, value := range values {
lowerValue := strings.ToLower(value)
for _, keyword := range sqlKeywords {
if strings.Contains(lowerValue, keyword) {
log.Printf("SQL注入检测: param=%s, value=%s", key, value)
response.BadRequest(c, "请求包含非法字符")
c.Abort()
return
}
}
}
}
c.Next()
}
}

// RequestSizeLimit 请求大小限制中间件
func RequestSizeLimit(maxSize int64) gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.ContentLength > maxSize {
response.Error(c, 413, response.CodeRequestTooLarge,
fmt.Sprintf("请求体过大,最大允许%dMB", maxSize/1024/1024))
c.Abort()
return
}
c.Next()
}
}

11. 部署与运维

11.1 编译与部署

# 本地编译
GOOS=linux GOARCH=amd64 go build -o bicycle_go_server cmd/server/main.go

# 使用systemd服务部署
sudo cp bicycle_go_server /usr/local/bin/
sudo cp bicycle-server.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable bicycle-server
sudo systemctl start bicycle-server

11.2 systemd服务配置

# bicycle-server.service
[Unit]
Description=Bicycle Go Server
After=network.target mysql.service redis.service

[Service]
Type=simple
User=wenjun
WorkingDirectory=/home/wenjun/bicycle_go_server
EnvironmentFile=/home/wenjun/bicycle_go_server/.env
ExecStart=/usr/local/bin/bicycle_go_server
Restart=always
RestartSec=5

[Install]
WantedBy=multi-user.target

11.3 健康检查接口

// 健康检查
api.GET("/health", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"status": "ok",
"time": time.Now().Format(time.RFC3339),
"version": "1.0.0",
})
})

12. 总结与展望

12.1 项目亮点

  1. 双存储架构:MySQL存储关系型数据,InfluxDB存储时序数据,各司其职
  2. 完善的认证体系:JWT + bcrypt + 短信验证码,多层安全保障
  3. 高性能缓存:Redis缓存热点数据,定时任务预计算统计
  4. 安全防护:限流、SQL注入检测、输入清洗、安全头等多重防护
  5. 代码规范:Clean Architecture分层,依赖注入,易于测试

12.2 性能数据

指标 数值
单机QPS ~5000
平均响应时间 <50ms
数据库连接池 100连接
缓存命中率 >90%

12.3 未来优化方向

  1. 微服务拆分:将用户服务、运动数据服务拆分为独立服务
  2. 消息队列:引入Kafka处理数据导入,削峰填谷
  3. 分布式追踪:集成Jaeger进行链路追踪
  4. 监控告警:Prometheus + Grafana监控体系
  5. 容器化部署:Docker + Kubernetes编排

附录:API接口概览

认证接口

方法 路径 描述
POST /api/v1/auth/register 用户注册
POST /api/v1/auth/login 用户登录
POST /api/v1/auth/sms/send 发送短信验证码
POST /api/v1/auth/sms/verify 验证短信验证码
POST /api/v1/auth/logout 用户登出

运动数据接口

方法 路径 描述
POST /api/v1/workouts/write 批量导入运动数据
GET /api/v1/workouts/list 查询运动记录列表
GET /api/v1/workouts/stats/overview 统计总览
GET /api/v1/workouts/stats/period 周期统计

心率数据接口

方法 路径 描述
GET /api/v1/heart-rate/latest 获取最新心率
GET /api/v1/heart-rate/history 获取心率历史
POST /api/v1/heart-rate/write 写入心率数据

项目地址: https://github.com/yourusername/bicycle_go_server

技术栈: Go 1.24 | Gin | GORM | MySQL | InfluxDB | Redis | JWT | 阿里云SDK

文章作者:阿文
文章链接: https://www.awen.me/post/3a8f9c2d.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿文的博客

评论

0 条评论
😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️
您的评论由 AI 智能审核,一般1分钟内会展示,若不展示请确认你的评论是否符合社区和法律规范
加载中...

留言反馈

😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️