Enhance smoking tracking API and documentation
- Updated the main.go file to set the local time zone to Asia/Shanghai. - Changed API endpoints from `PUT` to `POST` for user profile and logs management in multiple documentation files to reflect the correct usage. - Added new fields in the API response for home summary, including `last_smoke_at`, `today_count`, `resisted_count`, and `reduced_from_yesterday`. - Enhanced documentation across various files to accurately describe the updated API endpoints and their expected behaviors.
This commit is contained in:
@@ -12,7 +12,7 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler.
|
||||
{
|
||||
// 首次进入/基础信息(用于基准、AI 个性化、作息规避等)
|
||||
smoke.GET("/profile", smokeHandler.GetProfile)
|
||||
smoke.PUT("/profile", smokeHandler.UpsertProfile)
|
||||
smoke.POST("/profile", smokeHandler.UpsertProfile)
|
||||
|
||||
// 不使用 AI 时的默认“下次抽烟时间”建议(阶梯式延时)
|
||||
smoke.GET("/next_smoke_time", smokeHandler.GetNextSmokeTime)
|
||||
@@ -23,7 +23,7 @@ func registerSmokeRoutes(protected *gin.RouterGroup, smokeHandler *smokehandler.
|
||||
smoke.GET("/logs", smokeHandler.List)
|
||||
smoke.GET("/logs/latest", smokeHandler.LatestLogs)
|
||||
smoke.GET("/logs/:id", smokeHandler.Get)
|
||||
smoke.PUT("/logs/:id", smokeHandler.Update)
|
||||
smoke.POST("/logs/:id", smokeHandler.Update)
|
||||
smoke.DELETE("/logs/:id", smokeHandler.Delete)
|
||||
|
||||
// AI 戒烟建议(会员优先;非会员需看广告解锁)
|
||||
|
||||
@@ -14,13 +14,17 @@ import (
|
||||
)
|
||||
|
||||
type nextSmokeTimeUnifiedResponse struct {
|
||||
Source string `json:"source"`
|
||||
NotBeforeAt string `json:"not_before_at"`
|
||||
SuggestedAt string `json:"suggested_at"`
|
||||
TimeNodes []string `json:"time_nodes,omitempty"`
|
||||
Advice string `json:"advice,omitempty"`
|
||||
Default smokeservice.NextSmokeSuggestion `json:"default"`
|
||||
AI *smokeservice.AINextSmokeSuggestion `json:"ai,omitempty"`
|
||||
Source string `json:"source"`
|
||||
NotBeforeAt string `json:"not_before_at"`
|
||||
SuggestedAt string `json:"suggested_at"`
|
||||
LastSmokeAt string `json:"last_smoke_at,omitempty"`
|
||||
TodayCount int `json:"today_count"`
|
||||
Resisted int `json:"resisted_count"`
|
||||
Reduced int `json:"reduced_from_yesterday"`
|
||||
TimeNodes []string `json:"time_nodes,omitempty"`
|
||||
Advice string `json:"advice,omitempty"`
|
||||
Default nextSmokeDefaultResponse `json:"default"`
|
||||
AI *nextSmokeAIResponse `json:"ai,omitempty"`
|
||||
}
|
||||
|
||||
func (h *SmokeHandler) GetNextSmokeTime(c *gin.Context) {
|
||||
@@ -68,20 +72,72 @@ func (h *SmokeHandler) GetNextSmokeTime(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
homeSummary, err := h.smokeLogService.HomeSummary(c.Request.Context(), int(user.ID), asOf)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取首页汇总失败,请稍后重试"))
|
||||
return
|
||||
}
|
||||
|
||||
mode := strings.ToLower(strings.TrimSpace(c.DefaultQuery("mode", "auto")))
|
||||
|
||||
formatPtr := func(t *time.Time) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.In(time.Local).Format(time.RFC3339)
|
||||
return t.In(time.Local).Format(dateTimeLayout)
|
||||
}
|
||||
|
||||
formatTimeString := func(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, value); err == nil {
|
||||
return t.In(time.Local).Format(dateTimeLayout)
|
||||
}
|
||||
if t, err := time.ParseInLocation(dateTimeLayout, value, time.Local); err == nil {
|
||||
return t.In(time.Local).Format(dateTimeLayout)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
formatDefault := func(s smokeservice.NextSmokeSuggestion) nextSmokeDefaultResponse {
|
||||
out := nextSmokeDefaultResponse{
|
||||
LastSmokeAt: formatPtr(s.LastSmokeAt),
|
||||
NextSmokeAt: formatPtr(s.NextSmokeAt),
|
||||
BaseIntervalMinutes: s.BaseIntervalMinutes,
|
||||
IntervalMinutes: s.IntervalMinutes,
|
||||
Stage: s.Stage,
|
||||
Resisted7d: s.Resisted7d,
|
||||
SleepAdjusted: s.SleepAdjusted,
|
||||
Algorithm: s.Algorithm,
|
||||
}
|
||||
out.AsOf = formatTimeString(s.AsOf)
|
||||
return out
|
||||
}
|
||||
|
||||
formatAI := func(s smokeservice.AINextSmokeSuggestion) nextSmokeAIResponse {
|
||||
return nextSmokeAIResponse{
|
||||
PlanDate: s.PlanDate,
|
||||
NotBeforeAt: formatTimeString(s.NotBeforeAt),
|
||||
SuggestedAt: formatTimeString(s.SuggestedAt),
|
||||
TimeNodes: s.TimeNodes,
|
||||
Advice: s.Advice,
|
||||
PromptVersion: s.PromptVersion,
|
||||
Model: s.Model,
|
||||
Provider: s.Provider,
|
||||
}
|
||||
}
|
||||
|
||||
resp := nextSmokeTimeUnifiedResponse{
|
||||
Source: "default",
|
||||
NotBeforeAt: formatPtr(defaultSuggestion.NextSmokeAt),
|
||||
SuggestedAt: formatPtr(defaultSuggestion.NextSmokeAt),
|
||||
Default: defaultSuggestion,
|
||||
LastSmokeAt: formatPtr(homeSummary.LastSmokeAt),
|
||||
TodayCount: homeSummary.TodayCount,
|
||||
Resisted: homeSummary.ResistedCount,
|
||||
Reduced: homeSummary.ReducedFromYesterday,
|
||||
Default: formatDefault(defaultSuggestion),
|
||||
}
|
||||
|
||||
// mode=default: 永远返回默认建议
|
||||
@@ -124,11 +180,12 @@ func (h *SmokeHandler) GetNextSmokeTime(c *gin.Context) {
|
||||
|
||||
if hasAI {
|
||||
resp.Source = "ai"
|
||||
resp.NotBeforeAt = ai.NotBeforeAt
|
||||
resp.SuggestedAt = ai.SuggestedAt
|
||||
resp.NotBeforeAt = formatTimeString(ai.NotBeforeAt)
|
||||
resp.SuggestedAt = formatTimeString(ai.SuggestedAt)
|
||||
resp.TimeNodes = ai.TimeNodes
|
||||
resp.Advice = ai.Advice
|
||||
resp.AI = &ai
|
||||
formattedAI := formatAI(ai)
|
||||
resp.AI = &formattedAI
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, model.Success(resp))
|
||||
@@ -138,3 +195,26 @@ func dateOnlyLocal(t time.Time) time.Time {
|
||||
local := t.In(time.Local)
|
||||
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, time.Local)
|
||||
}
|
||||
|
||||
type nextSmokeDefaultResponse struct {
|
||||
LastSmokeAt string `json:"last_smoke_at,omitempty"`
|
||||
NextSmokeAt string `json:"next_smoke_at,omitempty"`
|
||||
BaseIntervalMinutes int `json:"base_interval_minutes"`
|
||||
IntervalMinutes int `json:"interval_minutes"`
|
||||
Stage int `json:"stage"`
|
||||
Resisted7d int `json:"resisted_7d"`
|
||||
SleepAdjusted bool `json:"sleep_adjusted"`
|
||||
Algorithm string `json:"algorithm"`
|
||||
AsOf string `json:"as_of"`
|
||||
}
|
||||
|
||||
type nextSmokeAIResponse struct {
|
||||
PlanDate string `json:"plan_date"`
|
||||
NotBeforeAt string `json:"not_before_at"`
|
||||
SuggestedAt string `json:"suggested_at"`
|
||||
TimeNodes []string `json:"time_nodes"`
|
||||
Advice string `json:"advice"`
|
||||
PromptVersion string `json:"prompt_version"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Provider string `json:"provider,omitempty"`
|
||||
}
|
||||
|
||||
@@ -117,6 +117,14 @@ type SmokeDashboardResult struct {
|
||||
Weekly []DashboardWeeklyStat `json:"weekly"`
|
||||
}
|
||||
|
||||
// SmokeHomeSummary 汇总首页所需的关键指标。
|
||||
type SmokeHomeSummary struct {
|
||||
LastSmokeAt *time.Time
|
||||
TodayCount int
|
||||
ResistedCount int
|
||||
ReducedFromYesterday int
|
||||
}
|
||||
|
||||
// DashboardWeeklyStat 表示某一天的抽烟支数以及是否为今天。
|
||||
type DashboardWeeklyStat struct {
|
||||
Date string `json:"date"`
|
||||
@@ -248,6 +256,68 @@ func (s *SmokeLogService) Dashboard(ctx context.Context, uid int, req SmokeDashb
|
||||
}, nil
|
||||
}
|
||||
|
||||
// HomeSummary 返回首页所需的汇总数据(不包含时间范围的周统计)。
|
||||
func (s *SmokeLogService) HomeSummary(ctx context.Context, uid int, asOf time.Time) (SmokeHomeSummary, error) {
|
||||
today := dateOnly(asOf)
|
||||
todayKey := today.Format("2006-01-02")
|
||||
yesterdayKey := today.AddDate(0, 0, -1).Format("2006-01-02")
|
||||
|
||||
var todayCount int64
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&smokemodel.SmokeLog{}).
|
||||
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0) AND smoke_time = ?", uid, todayKey).
|
||||
Select("COALESCE(SUM(num), 0)").
|
||||
Scan(&todayCount).Error; err != nil {
|
||||
return SmokeHomeSummary{}, fmt.Errorf("count today smoke logs: %w", err)
|
||||
}
|
||||
|
||||
var resistedCount int64
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&smokemodel.SmokeLog{}).
|
||||
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
|
||||
Where("level = 0 AND num = 0 AND smoke_time = ?", todayKey).
|
||||
Count(&resistedCount).Error; err != nil {
|
||||
return SmokeHomeSummary{}, fmt.Errorf("count resisted logs: %w", err)
|
||||
}
|
||||
|
||||
var yesterdayCount int64
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&smokemodel.SmokeLog{}).
|
||||
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0) AND smoke_time = ?", uid, yesterdayKey).
|
||||
Select("COALESCE(SUM(num), 0)").
|
||||
Scan(&yesterdayCount).Error; err != nil {
|
||||
return SmokeHomeSummary{}, fmt.Errorf("count yesterday smoke logs: %w", err)
|
||||
}
|
||||
|
||||
reduced := int(yesterdayCount - todayCount)
|
||||
if reduced < 0 {
|
||||
reduced = 0
|
||||
}
|
||||
|
||||
var lastSmokeAt *time.Time
|
||||
var last smokemodel.SmokeLog
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("uid = ? AND (deletetime IS NULL OR deletetime = 0)", uid).
|
||||
Where("NOT (level = 0 AND num = 0)").
|
||||
Order("COALESCE(smoke_at, FROM_UNIXTIME(createtime), smoke_time) DESC").
|
||||
Order("id DESC").
|
||||
Limit(1).
|
||||
Take(&last).Error; err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return SmokeHomeSummary{}, fmt.Errorf("load last smoke log: %w", err)
|
||||
}
|
||||
} else if t, ok := lastEventTime(last); ok {
|
||||
lastSmokeAt = &t
|
||||
}
|
||||
|
||||
return SmokeHomeSummary{
|
||||
LastSmokeAt: lastSmokeAt,
|
||||
TodayCount: int(todayCount),
|
||||
ResistedCount: int(resistedCount),
|
||||
ReducedFromYesterday: reduced,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *SmokeLogService) ListLatest(ctx context.Context, uid int, limit int) ([]smokemodel.SmokeLog, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
|
||||
@@ -168,7 +168,6 @@ func (s *SmokeProfileService) Upsert(ctx context.Context, uid int, req UpsertSmo
|
||||
func isSmokeProfileCompleted(p smokemodel.SmokeUserProfile) bool {
|
||||
return p.BaselineCigsPerDay > 0 &&
|
||||
p.PackPriceCent > 0 &&
|
||||
len(p.SmokeMotivations) > 0 &&
|
||||
len(p.QuitMotivations) > 0 &&
|
||||
strings.TrimSpace(p.WakeUpTime) != "" &&
|
||||
strings.TrimSpace(p.SleepTime) != ""
|
||||
|
||||
@@ -98,8 +98,11 @@ func TestIsSmokeProfileCompleted(t *testing.T) {
|
||||
t.Fatalf("isSmokeProfileCompleted: expected true")
|
||||
}
|
||||
p.SmokeMotivations = nil
|
||||
if !isSmokeProfileCompleted(p) {
|
||||
t.Fatalf("isSmokeProfileCompleted: expected true when smoke_motivations empty")
|
||||
}
|
||||
p.QuitMotivations = nil
|
||||
if isSmokeProfileCompleted(p) {
|
||||
t.Fatalf("isSmokeProfileCompleted: expected false when motivations missing")
|
||||
t.Fatalf("isSmokeProfileCompleted: expected false when quit_motivations missing")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user