├── go.mod
├── .gitignore
├── render.yaml
├── .github
├── workflows
│ ├── release-drafter.yml
│ ├── docker-image.yml
│ └── build.yml
└── release-drafter.yml
├── logger.go
├── Dockerfile
├── main.go
├── heartbeat.go
├── README.md
├── config.go
├── README-en.md
└── handlers.go
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/shenghuo2/sleep-status
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /access.log
2 | /config.json
3 | /.idea/
4 | /sleep_record.json
5 | *.exe
6 | sleep-status
7 | .DS_Store
8 |
--------------------------------------------------------------------------------
/render.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | - type: web
3 | name: sleep-status
4 | runtime: go
5 | repo: https://github.com/shenghuo2/sleep-status
6 | plan: free
7 | region: singapore
8 | buildCommand: go build -tags netgo -ldflags '-s -w' -o app
9 | startCommand: ./app
10 | version: "1"
11 |
--------------------------------------------------------------------------------
/.github/workflows/release-drafter.yml:
--------------------------------------------------------------------------------
1 | name: Release Drafter
2 |
3 | on:
4 | pull_request:
5 | types: [opened, reopened, synchronize]
6 | pull_request_target:
7 | types: [closed]
8 |
9 | permissions:
10 | contents: read
11 |
12 | jobs:
13 | update_release_draft:
14 | permissions:
15 | contents: write
16 | pull-requests: write
17 | runs-on: ubuntu-latest
18 | steps:
19 | - uses: release-drafter/release-drafter@v5
20 | with:
21 | disable-autolabeler: true
22 | env:
23 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
24 |
--------------------------------------------------------------------------------
/logger.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 | "os"
6 | "time"
7 | )
8 |
9 | var logFilePath = "./access.log"
10 |
11 | // LogAccess logs the access information to access.log
12 | // LogAccess 将访问信息记录到 access.log
13 | func LogAccess(ip string, route string) error {
14 | file, err := os.OpenFile(logFilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
15 | if err != nil {
16 | return err
17 | }
18 | defer file.Close()
19 |
20 | logEntry := fmt.Sprintf("%s - %s - %s\n", time.Now().Format(time.RFC3339), ip, route)
21 | if _, err := file.WriteString(logEntry); err != nil {
22 | return err
23 | }
24 | fmt.Println(logEntry) // Print log entry to console
25 | return nil
26 | }
27 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | # Build stage
2 | FROM golang:1.16-alpine AS builder
3 |
4 | LABEL maintainer="shenghuo2"
5 |
6 | # Set the working directory inside the container
7 | WORKDIR /app
8 |
9 | # Copy go.mod and go.sum files
10 | COPY go.mod ./
11 |
12 | # Download all the dependencies
13 | RUN go mod download
14 |
15 | # Copy the rest of the application code
16 | COPY . .
17 |
18 | # Build the Go application
19 | RUN go build -o sleep-status .
20 |
21 | # Run stage
22 | FROM alpine:latest
23 |
24 | # Set the working directory inside the container
25 | WORKDIR /root/
26 |
27 | # Copy the built executable from the builder stage
28 | COPY --from=builder /app/sleep-status .
29 |
30 | # Expose the port the app runs on (If you need)
31 | # EXPOSE 8000
32 |
33 | # Command to run the executable
34 | CMD ["./sleep-status", "--port=8000", "--host=0.0.0.0"]
35 |
--------------------------------------------------------------------------------
/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "flag"
5 | "fmt"
6 | "net/http"
7 | )
8 |
9 | func main() {
10 | port := flag.String("port", "8000", "port to serve on")
11 | host := flag.String("host", "0.0.0.0", "host to serve on")
12 | flag.Parse()
13 |
14 | if err := LoadConfig(); err != nil {
15 | fmt.Printf("Error loading config: %v\n", err)
16 | return
17 | }
18 |
19 | // Output the current key at startup
20 | // 启动时输出当前的 key
21 | fmt.Printf("Current key: %s\n", ConfigData.Key)
22 | fmt.Printf("当前的 key: %s\n", ConfigData.Key)
23 |
24 | http.HandleFunc("/status", StatusHandler)
25 | http.HandleFunc("/change", ChangeHandler)
26 | http.HandleFunc("/heartbeat", HeartbeatHandler)
27 | http.HandleFunc("/records", RecordsHandler)
28 | http.HandleFunc("/sleep-stats", StatsHandler)
29 |
30 | // Start heartbeat checker if enabled
31 | if ConfigData.HeartbeatEnabled {
32 | startHeartbeatChecker()
33 | fmt.Printf("Heartbeat monitoring enabled (timeout: %d seconds)\n", ConfigData.HeartbeatTimeout)
34 | fmt.Printf("心跳监控已启用 (超时: %d 秒)\n", ConfigData.HeartbeatTimeout)
35 | }
36 |
37 | addr := fmt.Sprintf("%s:%s", *host, *port)
38 | fmt.Printf("Starting server on %s\n", addr)
39 | fmt.Printf("服务器启动于 %s\n", addr)
40 | if err := http.ListenAndServe(addr, nil); err != nil {
41 | fmt.Printf("Error starting server: %v\n", err)
42 | fmt.Printf("启动服务器错误: %v\n", err)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/.github/release-drafter.yml:
--------------------------------------------------------------------------------
1 | name-template: 'v$RESOLVED_VERSION'
2 | tag-template: 'v$RESOLVED_VERSION'
3 |
4 | categories:
5 | - title: '🚀 Features'
6 | labels:
7 | - 'feature'
8 | - 'enhancement'
9 | - 'feat'
10 | - title: '🐛 Bug Fixes'
11 | labels:
12 | - 'fix'
13 | - 'bugfix'
14 | - 'bug'
15 | - title: '🧰 Maintenance'
16 | labels:
17 | - 'chore'
18 | - 'dependencies'
19 | - 'maintenance'
20 | - title: '📚 Documentation'
21 | labels:
22 | - 'docs'
23 | - 'documentation'
24 | - title: '⚡ Performance'
25 | labels:
26 | - 'performance'
27 | - 'perf'
28 | - title: '🔨 Refactor'
29 | labels:
30 | - 'refactor'
31 |
32 | change-template: '- $TITLE @$AUTHOR (#$NUMBER)'
33 |
34 | version-resolver:
35 | major:
36 | labels:
37 | - 'major'
38 | - 'breaking'
39 | minor:
40 | labels:
41 | - 'minor'
42 | - 'feature'
43 | - 'feat'
44 | - 'enhancement'
45 | patch:
46 | labels:
47 | - 'patch'
48 | - 'fix'
49 | - 'bugfix'
50 | - 'bug'
51 | - 'docs'
52 | - 'dependencies'
53 | - 'maintenance'
54 | - 'refactor'
55 | - 'performance'
56 | - 'perf'
57 | - 'chore'
58 | default: patch
59 |
60 | template: |
61 | ## What's Changed 🛠
62 | $CHANGES
63 |
64 | ## 👨💻 Contributors
65 | $CONTRIBUTORS
66 |
67 | **Full Changelog**: https://github.com/shenghuo2/sleep-status/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION
68 |
--------------------------------------------------------------------------------
/.github/workflows/docker-image.yml:
--------------------------------------------------------------------------------
1 | name: Build and Push Docker Image
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - master
8 | - develop
9 |
10 | jobs:
11 | build:
12 | runs-on: ubuntu-latest
13 | environment:
14 | name: production
15 | steps:
16 | # Checkout the repository
17 | - name: Checkout repository
18 | uses: actions/checkout@v2
19 |
20 | # Set up Docker Buildx
21 | - name: Set up Docker Buildx
22 | uses: docker/setup-buildx-action@v1
23 |
24 | # Log in to Docker Hub using token
25 | - name: Log in to Docker Hub
26 | uses: docker/login-action@v2
27 | with:
28 | username: shenghuo2
29 | password: ${{ secrets.DOCKER_TOKEN }}
30 |
31 | # Get the version from the git tag or use 'latest' if not present
32 | - name: Extract version
33 | id: get_version
34 | run: |
35 | if [[ "${GITHUB_REF}" == "refs/heads/master" ]]; then
36 | echo "VERSION=latest" >> $GITHUB_ENV
37 | elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then
38 | VERSION=${GITHUB_REF#refs/tags/}
39 | echo "VERSION=${VERSION}" >> $GITHUB_ENV
40 | else
41 | VERSION=${GITHUB_REF#refs/heads/}
42 | echo "VERSION=${VERSION}" >> $GITHUB_ENV
43 | fi
44 |
45 | # Build and push the Docker image with multi-platform support
46 | - name: Build and push
47 | uses: docker/build-push-action@v2
48 | with:
49 | context: .
50 | file: ./Dockerfile
51 | push: true
52 | platforms: linux/amd64,linux/arm64,linux/arm/v7
53 | tags: |
54 | shenghuo2/sleep-status:latest
55 | shenghuo2/sleep-status:${{ env.VERSION }}
56 |
57 | # Optional: Log out from Docker Hub
58 | - name: Log out from Docker Hub
59 | run: docker logout
60 |
--------------------------------------------------------------------------------
/heartbeat.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "sync"
5 | "time"
6 | )
7 |
8 | var (
9 | lastHeartbeat time.Time
10 | heartbeatMutex sync.RWMutex
11 | heartbeatChecker *time.Timer
12 | isCheckerRunning bool
13 | checkerMutex sync.Mutex
14 | )
15 |
16 | // updateLastHeartbeat updates the timestamp of the last heartbeat
17 | func updateLastHeartbeat() {
18 | heartbeatMutex.Lock()
19 | lastHeartbeat = time.Now()
20 | heartbeatMutex.Unlock()
21 | }
22 |
23 | // getLastHeartbeat returns the timestamp of the last heartbeat
24 | func getLastHeartbeat() time.Time {
25 | heartbeatMutex.RLock()
26 | defer heartbeatMutex.RUnlock()
27 | return lastHeartbeat
28 | }
29 |
30 | // startHeartbeatChecker starts the heartbeat checking routine
31 | func startHeartbeatChecker() {
32 | checkerMutex.Lock()
33 | defer checkerMutex.Unlock()
34 |
35 | if !ConfigData.HeartbeatEnabled || isCheckerRunning {
36 | return
37 | }
38 |
39 | isCheckerRunning = true
40 | updateLastHeartbeat() // Initialize last heartbeat time
41 |
42 | heartbeatChecker = time.NewTimer(time.Duration(ConfigData.HeartbeatTimeout) * time.Second)
43 |
44 | go func() {
45 | for {
46 | <-heartbeatChecker.C
47 |
48 | if !ConfigData.HeartbeatEnabled {
49 | stopHeartbeatChecker()
50 | return
51 | }
52 |
53 | timeSinceLastHeartbeat := time.Since(getLastHeartbeat())
54 | if timeSinceLastHeartbeat > time.Duration(ConfigData.HeartbeatTimeout)*time.Second {
55 | // Mark as sleeping if heartbeat timeout exceeded
56 | setStatusToSleep()
57 | }
58 |
59 | // Reset timer for next check
60 | heartbeatChecker.Reset(time.Duration(ConfigData.HeartbeatTimeout) * time.Second)
61 | }
62 | }()
63 | }
64 |
65 | // stopHeartbeatChecker stops the heartbeat checking routine
66 | func stopHeartbeatChecker() {
67 | checkerMutex.Lock()
68 | defer checkerMutex.Unlock()
69 |
70 | if isCheckerRunning {
71 | heartbeatChecker.Stop()
72 | isCheckerRunning = false
73 | }
74 | }
75 |
76 | // setStatusToSleep 安全地将状态设置为睡眠,避免死锁
77 | func setStatusToSleep() {
78 | // 先检查当前状态,避免不必要的锁定和文件操作
79 | if ConfigData.Sleep {
80 | return
81 | }
82 |
83 | // 使用单独的锁,避免与其他函数产生死锁
84 | mutex.Lock()
85 | defer mutex.Unlock()
86 |
87 | // 再次检查状态,避免在获取锁期间状态被改变
88 | if ConfigData.Sleep {
89 | return
90 | }
91 |
92 | ConfigData.Sleep = true
93 | record := SleepRecord{
94 | Action: "sleep",
95 | Time: time.Now().Format(time.RFC3339),
96 | }
97 |
98 | // 保存配置但不要在这里处理错误,避免阻塞心跳检测器
99 | _ = SaveConfig()
100 |
101 | // 使用无锁版本保存记录,避免死锁
102 | go func(r SleepRecord) {
103 | _ = SaveSleepRecordNoLock(r)
104 | }(record)
105 | }
106 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Multi-Platform Build and Package
2 |
3 | on:
4 | workflow_dispatch:
5 | push:
6 | branches:
7 | - master
8 |
9 | permissions:
10 | contents: write
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 |
16 | strategy:
17 | matrix:
18 | include:
19 | - goos: linux
20 | goarch: amd64
21 | - goos: linux
22 | goarch: arm64
23 | - goos: windows
24 | goarch: amd64
25 | - goos: darwin
26 | goarch: amd64
27 | - goos: darwin
28 | goarch: arm64
29 |
30 | steps:
31 | - name: Checkout repository
32 | uses: actions/checkout@v2
33 |
34 | - name: Set up Go
35 | uses: actions/setup-go@v2
36 | with:
37 | go-version: 1.16
38 |
39 | - name: Build application
40 | env:
41 | GOOS: ${{ matrix.goos }}
42 | GOARCH: ${{ matrix.goarch }}
43 | run: |
44 | if [ "${{ matrix.goos }}" = "windows" ]; then \
45 | go build -o sleep-status-${{ matrix.goos }}-${{ matrix.goarch }}.exe .; \
46 | else \
47 | go build -o sleep-status-${{ matrix.goos }}-${{ matrix.goarch }} .; \
48 | fi
49 |
50 | - name: Generate SHA256 checksum
51 | run: |
52 | if [ "${{ matrix.goos }}" = "windows" ]; then \
53 | sha256sum sleep-status-${{ matrix.goos }}-${{ matrix.goarch }}.exe >> sha256-${{ matrix.goos }}-${{ matrix.goarch }}.txt; \
54 | else \
55 | sha256sum sleep-status-${{ matrix.goos }}-${{ matrix.goarch }} >> sha256-${{ matrix.goos }}-${{ matrix.goarch }}.txt; \
56 | fi
57 |
58 | - name: Upload build artifacts
59 | uses: actions/upload-artifact@v4
60 | with:
61 | name: sleep-status-${{ matrix.goos }}-${{ matrix.goarch }}
62 | path: |
63 | sleep-status-${{ matrix.goos }}-${{ matrix.goarch }}*
64 | sha256-${{ matrix.goos }}-${{ matrix.goarch }}.txt
65 |
66 | package:
67 | runs-on: ubuntu-latest
68 | needs: build
69 |
70 | steps:
71 | - name: Checkout repository
72 | uses: actions/checkout@v2
73 |
74 | - name: Checkout Magisk module branch
75 | run: |
76 | git fetch origin feature/magisk-module
77 | git worktree add magisk-module feature/magisk-module
78 |
79 | - name: Download build artifacts
80 | uses: actions/download-artifact@v4
81 | with:
82 | name: sleep-status-linux-amd64
83 | path: ./build/linux/amd64
84 | - name: Download build artifacts
85 | uses: actions/download-artifact@v4
86 | with:
87 | name: sleep-status-linux-arm64
88 | path: ./build/linux/arm64
89 | - name: Download build artifacts
90 | uses: actions/download-artifact@v4
91 | with:
92 | name: sleep-status-windows-amd64
93 | path: ./build/windows/amd64
94 | - name: Download build artifacts
95 | uses: actions/download-artifact@v4
96 | with:
97 | name: sleep-status-darwin-amd64
98 | path: ./build/darwin/amd64
99 | - name: Download build artifacts
100 | uses: actions/download-artifact@v4
101 | with:
102 | name: sleep-status-darwin-arm64
103 | path: ./build/darwin/arm64
104 |
105 | - name: Create tarballs of the binaries
106 | run: |
107 | mkdir -p release
108 | tar -czvf release/sleep-status-linux-amd64.tar.gz -C ./build/linux/amd64 .
109 | tar -czvf release/sleep-status-linux-arm64.tar.gz -C ./build/linux/arm64 .
110 | tar -czvf release/sleep-status-windows-amd64.tar.gz -C ./build/windows/amd64 .
111 | tar -czvf release/sleep-status-darwin-amd64.tar.gz -C ./build/darwin/amd64 .
112 | tar -czvf release/sleep-status-darwin-arm64.tar.gz -C ./build/darwin/arm64 .
113 |
114 | - name: Package Magisk module
115 | run: |
116 | cd magisk-module/magisk
117 | zip -r ../../release/sleep-status-magisk.zip .
118 |
119 | - name: Append SHA256 checksums
120 | run: |
121 | cat ./build/linux/amd64/sha256*.txt > release/sha256.txt
122 | cat ./build/linux/arm64/sha256*.txt >> release/sha256.txt
123 | cat ./build/windows/amd64/sha256*.txt >> release/sha256.txt
124 | cat ./build/darwin/amd64/sha256*.txt >> release/sha256.txt
125 | cat ./build/darwin/arm64/sha256*.txt >> release/sha256.txt
126 | cd release && sha256sum sleep-status-magisk.zip >> sha256.txt
127 |
128 | - name: Generate release tag
129 | id: tag
130 | run: |
131 | tag="v$(date +'%Y.%m.%d-%H%M')"
132 | build_date=$(date +'%Y-%m-%d %H:%M:%S')
133 | echo "tag=$tag" >> $GITHUB_OUTPUT
134 | echo "build_date=$build_date" >> $GITHUB_OUTPUT
135 | git config --global user.email "github-actions@github.com"
136 | git config --global user.name "GitHub Actions"
137 | git tag $tag
138 | git push origin $tag
139 |
140 | - name: Get latest release notes
141 | id: release-drafter
142 | uses: release-drafter/release-drafter@v5
143 | with:
144 | config-name: release-drafter.yml
145 | disable-autolabeler: true
146 | env:
147 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
148 |
149 | - name: Create Release
150 | uses: softprops/action-gh-release@v1
151 | with:
152 | tag_name: ${{ steps.tag.outputs.tag }}
153 | draft: true
154 | files: |
155 | release/*.tar.gz
156 | release/sleep-status-magisk.zip
157 | release/sha256.txt
158 | name: Release ${{ steps.tag.outputs.tag }}
159 | body: |
160 | ## 📦 Automated Release
161 |
162 | This release contains pre-built binaries for the following platforms:
163 | - Linux (amd64, arm64)
164 | - Windows (amd64)
165 | - macOS (amd64, arm64)
166 | - Android (Magisk module)
167 |
168 | ## 🔍 Build Information
169 | - Build Date: ${{ steps.tag.outputs.build_date }}
170 | - Commit: ${{ github.sha }}
171 |
172 | ## 📝 Release Notes
173 | ${{ steps.release-drafter.outputs.body }}
174 |
175 | ## 🔒 Checksums
176 | SHA256 checksums are provided in the sha256.txt file.
177 | env:
178 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
179 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Sleep-Status
2 |
3 | 
4 |
5 | [English](./README-en.md) [简体中文](./README.md)
6 |
7 |
8 | 目录
9 |
10 | - [项目介绍](#项目介绍)
11 | - [功能特点](#功能特点)
12 | - [配置说明](#配置说明)
13 | - [心跳检测](#心跳检测)
14 | - [部署](#部署)
15 | - [快速部署](#快速部署)
16 | - [Render](#render)
17 | - [Docker](#docker)
18 | - [Docker Compose](#docker-compose)
19 | - [自行部署](#自行部署)
20 | - [编译成二进制文件](#编译成二进制文件)
21 | - [使用](#使用)
22 | - [API接口说明](#api接口说明)
23 | - [配置文件](#配置文件)
24 | - [访问日志](#访问日志)
25 | - [配套项目](#配套项目)
26 | - [状态上报(安卓实现)](#状态上报安卓实现)
27 | - [Magisk 模块](#magisk-模块-magisk-module-分支)
28 | - [示例前端](#示例前端-frontend-example-分支)
29 | - [更新日志](#更新日志)
30 | - [v0.1.2 (2025-02-27)](#v012-2025-02-27)
31 | - [v0.1.1 (2025-02-26)](#v011-2025-02-26)
32 | - [v0.1.0 (2025-02-25)](#v010-2025-02-25)
33 | - [v0.0.9](#v009)
34 | - [v0.0.3](#v003)
35 | - [v0.0.2](#v002)
36 | - [v0.0.1](#v001)
37 | - [TODO](#todo)
38 |
39 |
40 |
41 | ## 项目介绍
42 |
43 | Sleep-Status 是一个使用 Go 语言编写的简单后端服务。该服务通过读取配置文件 `config.json` 来获取和修改 `sleep` 状态。
44 |
45 | 灵感来源于 [这个BiliBili视频](https://www.bilibili.com/video/BV1fE421A7PE/)。
46 |
47 | 使用 Go 语言是因为其跨平台交叉编译特性,使得服务可以便捷的在多个操作系统上运行。
48 |
49 | ## 功能特点
50 |
51 | - 提供 `/status` 路由来获取当前的 `sleep` 状态。
52 | - 提供 `/change` 路由来修改 `sleep` 状态。
53 | - 提供 `/heartbeat` 路由接收心跳信号,自动检测设备状态。
54 | - 支持访问日志记录,将所有路由请求的 IP 地址记录到 `access.log` 文件中。
55 | - 配置文件 `config.json` 不存在时会自动创建并填入默认值。
56 | - 支持配置文件版本管理,自动迁移旧版本配置。
57 | - 支持心跳超时自动设置睡眠状态。
58 | - 支持`Render`一键部署
59 | - 支持使用`Docker`和`Docker Compose`一键部署
60 | - 支持使用二进制文件,无需依赖快速运行
61 |
62 | ## 配置说明
63 |
64 | 配置文件 `config.json` 包含以下字段:
65 |
66 | ```json
67 | {
68 | "version": 2, // 配置文件版本
69 | "sleep": false, // 睡眠状态
70 | "key": "your-key", // API密钥
71 | "heartbeat_enabled": false, // 是否启用心跳检测
72 | "heartbeat_timeout": 60 // 心跳超时时间(秒)
73 | }
74 | ```
75 |
76 | ### 心跳检测
77 |
78 | 当启用心跳检测时(`heartbeat_enabled=true`),服务器会:
79 | 1. 监听来自客户端的心跳信号(`/heartbeat` 路由)
80 | 2. 如果超过 `heartbeat_timeout` 秒没有收到心跳,自动将状态设置为睡眠
81 | 3. 收到心跳信号时,如果状态为睡眠,自动设置为醒来
82 |
83 | 客户端需要定期发送心跳请求:
84 | ```bash
85 | curl "http://your-server:8000/heartbeat?key=your-key"
86 | ```
87 |
88 | ## 部署
89 |
90 | ## 快速部署
91 |
92 | ### Render
93 |
94 | 点击下方按钮,使用[Render](https://render.com/)一键部署
95 |
96 |
97 | [](https://render.com/deploy?repo=https://github.com/shenghuo2/sleep-status)
98 |
99 | 不过Render的免费套餐,在无请求的时候会自动 down
100 |
101 | *Free instances spin down after periods of inactivity. They do not support SSH access, scaling, one-off jobs, or persistent disks. Select any paid instance type to enable these features.*
102 |
103 | ### Docker
104 |
105 | 镜像名 `shenghuo2/sleep-status:latest`
106 |
107 | 运行命令
108 |
109 | ```shell
110 | docker run -d -p 8000:8000 --name sleep-status shenghuo2/sleep-status:latest
111 | ```
112 |
113 | 将在本地的8000端口启动,使用`docker logs sleep-status`查看自动生成的随机密码
114 |
115 |
116 | ### Docker Compose
117 |
118 | ```yaml
119 | # version: '3.8'
120 | services:
121 | sleep-status:
122 | image: shenghuo2/sleep-status:latest
123 | ports:
124 | - "8000:8000"
125 | ```
126 |
127 | 将在本地的8000端口启动,使用`docker logs sleep-status`查看自动生成的随机密码
128 |
129 | ## 自行部署
130 |
131 | ### 编译成二进制文件
132 |
133 | 1. 确保已安装 Go 语言环境(推荐使用 Go 1.16 及以上版本)。
134 | 2. 下载或克隆此项目到本地:
135 |
136 | ```sh
137 | git clone https://github.com/shenghuo2/sleep-status.git
138 | cd sleep-status
139 | ```
140 |
141 | 3. 编译项目:
142 |
143 | ```sh
144 | go build .
145 | ```
146 |
147 | # 使用
148 |
149 | 1. 运行编译后的可执行文件:
150 |
151 | ```sh
152 | ./sleep-status [--port=] [--host=]
153 | ```
154 |
155 | > `[ ]`内为可选参数
156 |
157 | 默认情况下,服务将监听在 `0.0.0.0` 的 `8000` 端口。
158 |
159 | 2. API 接口说明:
160 |
161 | - 获取当前的 `sleep` 状态:
162 |
163 | ```sh
164 | curl http://:/status
165 | ```
166 |
167 | 响应示例:
168 |
169 | ```json
170 | {
171 | "sleep": false
172 | }
173 | ```
174 |
175 | - 获取最近的睡眠记录:
176 |
177 | ```sh
178 | curl http://:/records[?limit=30]
179 | ```
180 |
181 | 参数说明:
182 | - `limit`:可选,返回的记录数量,默认为30条
183 |
184 | 响应示例:
185 |
186 | ```json
187 | {
188 | "success": true,
189 | "records": [
190 | {
191 | "action": "sleep",
192 | "time": "2025-02-25T15:30:00Z"
193 | },
194 | {
195 | "action": "wake",
196 | "time": "2025-02-25T23:00:00Z"
197 | }
198 | ]
199 | }
200 | ```
201 |
202 | - 获取睡眠统计数据:
203 |
204 | ```sh
205 | curl http://:/sleep-stats[?days=7&show_time_str=0&show_sleep=0]
206 | ```
207 |
208 | 参数说明:
209 | - `days`:可选,统计的天数范围,默认为7天
210 | - `show_time_str`:可选,是否显示原始时间字符串,1表示显示,0表示不显示,默认为0
211 | - `show_sleep`:可选,是否显示当前睡眠状态,1表示显示,0表示不显示,默认为0
212 |
213 | 响应示例:
214 |
215 | ```json
216 | {
217 | "success": true,
218 | "stats": {
219 | "avg_sleep_time": "23:30",
220 | "avg_wake_time": "07:15",
221 | "avg_duration_minutes": 465,
222 | "periods": [
223 | {
224 | "sleep_time": 1711382400,
225 | "wake_time": 1711407600,
226 | "duration_minutes": 470
227 | },
228 | {
229 | "sleep_time": 1711468800,
230 | "wake_time": 1711493100,
231 | "duration_minutes": 405,
232 | "is_short": true
233 | }
234 | ]
235 | },
236 | "days": 2,
237 | "request_days": 7
238 | }
239 | ```
240 |
241 | 字段说明:
242 | - `avg_sleep_time`:平均入睡时间(HH:MM格式)
243 | - `avg_wake_time`:平均醒来时间(HH:MM格式)
244 | - `avg_duration_minutes`:平均睡眠时长(分钟)
245 | - `periods`:睡眠周期列表
246 | - `sleep_time`:入睡时间(Unix时间戳,秒)
247 | - `wake_time`:醒来时间(Unix时间戳,秒)
248 | - `duration_minutes`:睡眠时长(分钟)
249 | - `is_short`:是否为短睡眠(小于3小时),仅当为短睡眠时才显示此字段
250 | - `days`:实际统计的天数
251 | - `request_days`:请求的天数,仅当与实际天数不同时才显示
252 | - `sleep`:当前睡眠状态,仅当 `show_sleep=1` 时才显示
253 | - `current_sleep_at`:当前睡眠开始时间(Unix 时间戳,秒),仅当当前状态为睡眠时才显示
254 |
255 | 注意:
256 | - 短睡眠段(小于3小时)不计入平均入睡和醒来时间的计算,但会计入平均睡眠时长
257 | - 当数据不足时,`days`字段会显示实际的天数范围,而不是请求的天数
258 |
259 | - 修改 `sleep` 状态:
260 |
261 | ```sh
262 | curl "http://:/change?key=&status="
263 | ```
264 |
265 | 参数说明:
266 | - `key`:配置文件中的 `key` 值,用于校验请求合法性。
267 | - `status`:期望的 `sleep` 状态,1 表示 `true`,0 表示 `false`。
268 |
269 | 成功响应示例:
270 |
271 | ```json
272 | {
273 | "success": true,
274 | "result": "status changed! now sleep is 1"
275 | }
276 | ```
277 |
278 | 失败响应示例:
279 |
280 | ```json
281 | {
282 | "success": false,
283 | "result": "sleep already 1"
284 | }
285 | ```
286 |
287 | 失败状态码为401
288 |
289 | ## 配置文件
290 |
291 | 默认的 `config.json` 文件格式如下:
292 |
293 | ```json
294 | {
295 | "sleep": false,
296 | "key": "default_key"
297 | }
298 | ```
299 |
300 | 首次运行程序时,如果配置文件不存在,将自动创建该文件并随机生成16位密钥。
301 |
302 | ## 访问日志
303 |
304 | 对 `/status` `/change` 路由的访问请求都会记录到 `access.log` 文件中,记录格式如下:
305 |
306 | ```
307 | 2024-08-03T13:18:53+08:00 - [::1]:19469 - /status
308 |
309 | 2024-08-03T13:19:01+08:00 - [::1]:19469 - /change
310 | ```
311 |
312 | ## 配套项目
313 |
314 | ### 状态上报(安卓实现)
315 |
316 | https://github.com/shenghuo2/sleep-status-sender
317 |
318 | 可以通过该应用选择“睡醒”或“睡着”状态,将状态发送到指定的服务器。
319 |
320 | 以及一个设置页面,用户可以配置服务器的 BASE_URL,并测试与服务器的连接。
321 |
322 | ### Magisk 模块 ([magisk-module 分支](https://github.com/shenghuo2/sleep-status/tree/magisk-module))
323 |
324 | 提供 Magisk 模块实现,可以在 Root 设备上实现更深度的系统集成。
325 |
326 | ### 示例前端 ([frontend-example 分支](https://github.com/shenghuo2/sleep-status/tree/frontend-example))
327 |
328 | 提供一个基于 Web 的示例前端实现,展示如何与服务端进行交互。
329 |
330 | [在线示例](https://blog.shenghuo2.top/test)
331 |
332 | ## 更新日志
333 |
334 | ### v0.1.3 (2025-03-28)
335 | - 新功能
336 | - 添加 `/sleep-stats` API 用于获取睡眠统计数据
337 | - 支持计算平均入睡时间、醒来时间和睡眠时长
338 | - 支持展示成对的睡眠周期(使用 Unix 时间戳)
339 | - 支持在 `/sleep-stats` 中显示当前睡眠状态(可选)
340 | - 当处于睡眠状态时,自动显示当前睡眠开始时间
341 | - 改进
342 | - 支持短睡眠时间段(小于3小时)的处理
343 | - 当数据不足时,自动调整实际统计的天数
344 | - 可选是否显示原始时间字符串,默认不显示
345 | - 可选是否显示当前睡眠状态,通过 `show_sleep=1` 参数控制
346 | - 代码优化
347 | - 改进了时区处理,正确处理 UTC 和本地时间转换
348 | - 优化了睡眠数据的计算逻辑
349 |
350 | ### v0.1.2 (2025-02-27)
351 | - 修复问题
352 | - 修复 `/records` API 在并发访问时可能导致的死锁问题
353 | - 优化心跳检测机制,防止与其他操作产生锁竞争
354 | - 改进睡眠记录迁移功能的稳定性
355 | - 性能优化
356 | - 使用异步日志记录,避免阻塞API响应
357 | - 实现细粒度锁机制,提高并发性能
358 | - 添加无锁版本的记录保存函数,减少锁竞争
359 | - 代码重构
360 | - 将状态变更逻辑提取到独立函数,提高代码可维护性
361 | - 优化心跳检测器的实现,避免嵌套锁导致的死锁
362 |
363 | ### v0.1.1 (2025-02-26)
364 | - 新增功能
365 | - 添加 `/records` API,支持查看最近的睡眠记录
366 | - 支持自定义返回记录数量(默认30条)
367 | - 优化改进
368 | - 优化睡眠记录的存储格式,改用JSON数组
369 | - 添加旧版本记录格式自动迁移功能
370 | - 改进错误处理,提供更友好的错误信息
371 |
372 | ### v0.1.0 (2025-02-25)
373 | - 新增心跳检测功能
374 | - 支持自动检测设备状态
375 | - 可配置心跳超时时间
376 | - 超时自动设置睡眠状态
377 | - 新增配置文件版本管理
378 | - 自动迁移旧版本配置
379 | - 支持配置文件版本号
380 | - 新增部署方式
381 | - 支持 Render 一键部署
382 | - 支持 Docker 和 Docker Compose 部署
383 | - 提供二进制文件快速部署
384 |
385 | ### v0.0.9
386 | - 初次启动时自动创建随机强密码
387 | - 对所有路由进行日志记录
388 | - 启动时输出当前key
389 | - 支持 `Render` 蓝图一键部署
390 | - 支持 `Docker`和`Docker Compose` 快速部署
391 |
392 | ### v0.0.3
393 | - 支持 CORS 请求
394 |
395 | ### v0.0.2
396 | - 支持入睡和醒来时间的记录
397 |
398 | ### v0.0.1
399 | - 初始版本,提供基本功能
400 |
401 | ## TODO
402 |
403 | - [x] 支持对所有路由的访问日志记录。
404 | - [ ] 实现在 `config.json` 读取 **host** 和 **port** 的值
405 | - [x] 实现对于**入睡和醒来时间** 的记录
406 | - [x] 支持 `CORS`
407 | - [x] 实现第一次启动时,自动创建随机`key`
408 | - [x] 每次启动时输出当前`key`
409 | - [ ] 增加更多状态和操作的 API。
410 | - [ ] 提供更加详细的错误处理和响应信息。
411 | - [x] 支持`Paas`类方法一键部署
412 | - 已使用 `Render` 实现
413 | - [x] 每次`push`自动build新镜像并推送
414 | - [x] 支持`Docker`和`Docker Compose`
415 |
--------------------------------------------------------------------------------
/config.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "crypto/rand"
6 | "encoding/hex"
7 | "encoding/json"
8 | "fmt"
9 | "os"
10 | "sync"
11 | )
12 |
13 | const (
14 | CurrentConfigVersion = 2 // Increment this when adding new versions
15 | )
16 |
17 | var (
18 | configFilePath = "./config.json"
19 | sleepRecordFilePath = "./sleep_record.json"
20 | ConfigData Config
21 | mutex = &sync.Mutex{}
22 | )
23 |
24 | // BaseConfig contains common fields across all versions
25 | type BaseConfig struct {
26 | Version int `json:"version"`
27 | }
28 |
29 | // Config represents the current version of configuration
30 | type Config struct {
31 | BaseConfig
32 | Sleep bool `json:"sleep"`
33 | Key string `json:"key"`
34 | HeartbeatEnabled bool `json:"heartbeat_enabled"`
35 | HeartbeatTimeout int `json:"heartbeat_timeout"`
36 | }
37 |
38 | // ConfigV1 represents version 1 of configuration
39 | type ConfigV1 struct {
40 | Sleep bool `json:"sleep"`
41 | Key string `json:"key"`
42 | }
43 |
44 | // migrationFunc defines the signature for version migration functions
45 | type migrationFunc func([]byte) (Config, error)
46 |
47 | // migrationMap stores all version upgrade paths
48 | var migrationMap = map[int]migrationFunc{
49 | 0: migrateFromV0ToLatest, // For configs with no version field
50 | 1: migrateFromV1ToLatest,
51 | }
52 |
53 | // migrateFromV0ToLatest handles migration from the original version (no version field)
54 | func migrateFromV0ToLatest(data []byte) (Config, error) {
55 | var oldConfig ConfigV1
56 | if err := json.Unmarshal(data, &oldConfig); err != nil {
57 | return Config{}, fmt.Errorf("failed to unmarshal v0 config: %v", err)
58 | }
59 |
60 | return Config{
61 | BaseConfig: BaseConfig{
62 | Version: CurrentConfigVersion,
63 | },
64 | Sleep: oldConfig.Sleep,
65 | Key: oldConfig.Key,
66 | HeartbeatEnabled: false,
67 | HeartbeatTimeout: 60,
68 | }, nil
69 | }
70 |
71 | // migrateFromV1ToLatest handles migration from version 1
72 | func migrateFromV1ToLatest(data []byte) (Config, error) {
73 | var v1Config ConfigV1
74 | if err := json.Unmarshal(data, &v1Config); err != nil {
75 | return Config{}, fmt.Errorf("failed to unmarshal v1 config: %v", err)
76 | }
77 |
78 | return Config{
79 | BaseConfig: BaseConfig{
80 | Version: CurrentConfigVersion,
81 | },
82 | Sleep: v1Config.Sleep,
83 | Key: v1Config.Key,
84 | HeartbeatEnabled: false,
85 | HeartbeatTimeout: 60,
86 | }, nil
87 | }
88 |
89 | // LoadConfig loads the configuration from the config file
90 | func LoadConfig() error {
91 | mutex.Lock()
92 | defer mutex.Unlock()
93 |
94 | // Check if the config file exists
95 | if _, err := os.Stat(configFilePath); os.IsNotExist(err) {
96 | // Create new config with default values
97 | randomKey, err := generateRandomKey(16)
98 | if err != nil {
99 | return err
100 | }
101 |
102 | ConfigData = Config{
103 | BaseConfig: BaseConfig{
104 | Version: CurrentConfigVersion,
105 | },
106 | Sleep: false,
107 | Key: randomKey,
108 | HeartbeatEnabled: false,
109 | HeartbeatTimeout: 60,
110 | }
111 |
112 | if err := SaveConfig(); err != nil {
113 | return err
114 | }
115 | fmt.Println("Config file created with default values.")
116 | fmt.Println("配置文件已创建默认值。")
117 | return nil
118 | }
119 |
120 | // Read existing config file
121 | content, err := os.ReadFile(configFilePath)
122 | if err != nil {
123 | return err
124 | }
125 |
126 | // Try to determine version
127 | var baseConfig BaseConfig
128 | if err := json.Unmarshal(content, &baseConfig); err != nil {
129 | baseConfig.Version = 0 // Assume version 0 if no version field
130 | }
131 |
132 | // Check if migration is needed
133 | if baseConfig.Version < CurrentConfigVersion {
134 | fmt.Printf("Upgrading config from version %d to %d...\n", baseConfig.Version, CurrentConfigVersion)
135 | fmt.Printf("正在将配置从版本 %d 升级到 %d...\n", baseConfig.Version, CurrentConfigVersion)
136 |
137 | // Get migration function
138 | migrationFunc, exists := migrationMap[baseConfig.Version]
139 | if !exists {
140 | return fmt.Errorf("no migration path from version %d", baseConfig.Version)
141 | }
142 |
143 | // Perform migration
144 | newConfig, err := migrationFunc(content)
145 | if err != nil {
146 | return fmt.Errorf("migration failed: %v", err)
147 | }
148 |
149 | ConfigData = newConfig
150 | if err := SaveConfig(); err != nil {
151 | return fmt.Errorf("failed to save migrated config: %v", err)
152 | }
153 |
154 | fmt.Println("Config upgraded successfully!")
155 | fmt.Println("配置升级成功!")
156 | return nil
157 | }
158 |
159 | // Current version, just load it
160 | decoder := json.NewDecoder(bytes.NewReader(content))
161 | if err := decoder.Decode(&ConfigData); err != nil {
162 | return fmt.Errorf("failed to decode config: %v", err)
163 | }
164 |
165 | return nil
166 | }
167 |
168 | // SaveConfig saves the current configuration to the config file
169 | func SaveConfig() error {
170 | file, err := os.OpenFile(configFilePath, os.O_WRONLY|os.O_TRUNC|os.O_CREATE, 0644)
171 | if err != nil {
172 | return err
173 | }
174 | defer file.Close()
175 |
176 | encoder := json.NewEncoder(file)
177 | return encoder.Encode(&ConfigData)
178 | }
179 |
180 | // SaveSleepRecord saves the sleep record to the sleep_record.json file
181 | func SaveSleepRecord(record SleepRecord) error {
182 | mutex.Lock()
183 | defer mutex.Unlock()
184 |
185 | // 读取现有记录
186 | var records []SleepRecord
187 | if data, err := os.ReadFile(sleepRecordFilePath); err == nil {
188 | if err := json.Unmarshal(data, &records); err != nil {
189 | // 如果解析失败,就当作是空列表
190 | records = []SleepRecord{}
191 | }
192 | }
193 |
194 | // 添加新记录
195 | records = append(records, record)
196 |
197 | // 写入文件
198 | data, err := json.MarshalIndent(records, "", " ")
199 | if err != nil {
200 | return fmt.Errorf("failed to marshal sleep records: %v", err)
201 | }
202 |
203 | if err := os.WriteFile(sleepRecordFilePath, data, 0644); err != nil {
204 | return fmt.Errorf("failed to write sleep records: %v", err)
205 | }
206 |
207 | return nil
208 | }
209 |
210 | // SaveSleepRecordNoLock 保存睡眠记录到文件,但不加锁(调用者需要确保并发安全)
211 | func SaveSleepRecordNoLock(record SleepRecord) error {
212 | // 读取现有记录
213 | var records []SleepRecord
214 | if data, err := os.ReadFile(sleepRecordFilePath); err == nil {
215 | if err := json.Unmarshal(data, &records); err != nil {
216 | // 如果解析失败,就当作是空列表
217 | records = []SleepRecord{}
218 | }
219 | }
220 |
221 | // 添加新记录
222 | records = append(records, record)
223 |
224 | // 写入文件
225 | data, err := json.MarshalIndent(records, "", " ")
226 | if err != nil {
227 | return fmt.Errorf("failed to marshal sleep records: %v", err)
228 | }
229 |
230 | if err := os.WriteFile(sleepRecordFilePath, data, 0644); err != nil {
231 | return fmt.Errorf("failed to write sleep records: %v", err)
232 | }
233 |
234 | return nil
235 | }
236 |
237 | // LoadSleepRecords 从 sleep_record.json 文件中读取指定数量的最新睡眠记录
238 | func LoadSleepRecords(limit int) ([]SleepRecord, error) {
239 | // 尝试迁移旧版本记录,但不在此函数内加锁,避免与其他函数产生死锁
240 | if err := migrateOldSleepRecordsNoLock(); err != nil {
241 | return nil, fmt.Errorf("failed to migrate old records: %v", err)
242 | }
243 |
244 | mutex.Lock()
245 | defer mutex.Unlock()
246 |
247 | // 检查文件是否存在
248 | if _, err := os.Stat(sleepRecordFilePath); os.IsNotExist(err) {
249 | return []SleepRecord{}, nil
250 | }
251 |
252 | // 读取文件内容
253 | data, err := os.ReadFile(sleepRecordFilePath)
254 | if err != nil {
255 | return nil, fmt.Errorf("failed to read sleep record file: %v", err)
256 | }
257 |
258 | var records []SleepRecord
259 | if err := json.Unmarshal(data, &records); err != nil {
260 | // 如果解析失败,返回空列表
261 | return []SleepRecord{}, nil
262 | }
263 |
264 | // 如果记录数量小于限制数,返回所有记录
265 | if len(records) <= limit {
266 | return records, nil
267 | }
268 |
269 | // 返回最新的 limit 条记录
270 | return records[len(records)-limit:], nil
271 | }
272 |
273 | // migrateOldSleepRecordsNoLock 将旧版本的睡眠记录格式转换为新版本,不加锁版本
274 | func migrateOldSleepRecordsNoLock() error {
275 | // 检查文件是否存在
276 | if _, err := os.Stat(sleepRecordFilePath); os.IsNotExist(err) {
277 | return nil
278 | }
279 |
280 | // 读取文件内容
281 | data, err := os.ReadFile(sleepRecordFilePath)
282 | if err != nil {
283 | return fmt.Errorf("failed to read sleep record file: %v", err)
284 | }
285 |
286 | // 如果文件为空,直接返回
287 | if len(bytes.TrimSpace(data)) == 0 {
288 | return nil
289 | }
290 |
291 | // 尝试解析为新格式(JSON数组)
292 | var records []SleepRecord
293 | if err := json.Unmarshal(data, &records); err == nil {
294 | // 已经是新格式,无需转换
295 | return nil
296 | }
297 |
298 | // 需要转换时才加锁
299 | mutex.Lock()
300 | defer mutex.Unlock()
301 |
302 | // 再次读取文件,确保在加锁后获取最新内容
303 | data, err = os.ReadFile(sleepRecordFilePath)
304 | if err != nil {
305 | return fmt.Errorf("failed to read sleep record file: %v", err)
306 | }
307 |
308 | // 再次尝试解析为新格式,避免重复转换
309 | if err := json.Unmarshal(data, &records); err == nil {
310 | // 已经是新格式,无需转换
311 | return nil
312 | }
313 |
314 | // 按行分割,处理旧格式(每行一个JSON对象)
315 | lines := bytes.Split(data, []byte("\n"))
316 | records = make([]SleepRecord, 0)
317 |
318 | for _, line := range lines {
319 | // 跳过空行
320 | if len(bytes.TrimSpace(line)) == 0 {
321 | continue
322 | }
323 |
324 | var record SleepRecord
325 | if err := json.Unmarshal(line, &record); err != nil {
326 | return fmt.Errorf("failed to parse record: %v", err)
327 | }
328 | records = append(records, record)
329 | }
330 |
331 | // 将转换后的记录写回文件
332 | newData, err := json.MarshalIndent(records, "", " ")
333 | if err != nil {
334 | return fmt.Errorf("failed to marshal migrated records: %v", err)
335 | }
336 |
337 | if err := os.WriteFile(sleepRecordFilePath, newData, 0644); err != nil {
338 | return fmt.Errorf("failed to write migrated records: %v", err)
339 | }
340 |
341 | return nil
342 | }
343 |
344 | // generateRandomKey generates a random key of the specified length
345 | func generateRandomKey(length int) (string, error) {
346 | bytes := make([]byte, length)
347 | _, err := rand.Read(bytes)
348 | if err != nil {
349 | return "", err
350 | }
351 | return hex.EncodeToString(bytes), nil
352 | }
353 |
--------------------------------------------------------------------------------
/README-en.md:
--------------------------------------------------------------------------------
1 | # Sleep-Status
2 |
3 | 
4 |
5 | [English](./README-en.md) [简体中文](./README.md)
6 |
7 |
8 | Table of Contents
9 |
10 | - [Project Introduction](#project-introduction)
11 | - [Features](#features)
12 | - [Configuration](#configuration)
13 | - [Heartbeat Detection](#heartbeat-detection)
14 | - [Deployment](#deployment)
15 | - [Quick Deployment](#quick-deployment)
16 | - [Render](#render)
17 | - [Docker](#docker)
18 | - [Docker Compose](#docker-compose)
19 | - [Self Deployment](#self-deployment)
20 | - [Compile to Binary](#compile-to-binary)
21 | - [Usage](#usage)
22 | - [API Description](#api-description)
23 | - [Configuration File](#configuration)
24 | - [Access Logs](#access-logs)
25 | - [Supporting Project](#supporting-project)
26 | - [Status Reporting (Android Implementation)](#status-reporting-android-implementation)
27 | - [Changelog](#changelog)
28 | - [v0.1.2 (2025-02-27)](#v012-2025-02-27)
29 | - [v0.1.1 (2025-02-26)](#v011-2025-02-26)
30 | - [v0.1.0 (2025-02-25)](#v010-2025-02-25)
31 | - [v0.0.9](#v009)
32 | - [v0.0.3](#v003)
33 | - [v0.0.2](#v002)
34 | - [v0.0.1](#v001)
35 | - [TODO](#todo)
36 |
37 |
38 |
39 | ## Project Introduction
40 |
41 | Sleep-Status is a simple backend service written in Go. The service reads and modifies the `sleep` status by reading the configuration file `config.json`.
42 |
43 | Inspired by [this BiliBili video](https://www.bilibili.com/video/BV1fE421A7PE/).
44 |
45 | Go was chosen because of its cross-platform capabilities, making it easy to run the service on multiple operating systems.
46 |
47 | ## Features
48 |
49 | - Provides `/status` route to get the current `sleep` status.
50 | - Provides `/change` route to modify the `sleep` status.
51 | - Provides `/heartbeat` route to receive heartbeat signals for automatic device status detection.
52 | - Supports access log recording, logging IP addresses of all route requests in the `access.log` file.
53 | - Automatically creates the `config.json` file with default values if it does not exist.
54 | - Supports configuration file versioning with automatic migration of old configurations.
55 | - Supports automatic sleep status setting on heartbeat timeout.
56 | - Supports one-click deployment with Render.
57 | - Supports one-click deployment using Docker and Docker Compose.
58 | - Supports running as a binary without dependencies for quick execution.
59 |
60 | ## Deployment
61 |
62 | ## Quick Deployment
63 |
64 | ### Render
65 |
66 | Click the button below to deploy with [Render](https://render.com/) in one click
67 |
68 | [](https://render.com/deploy?repo=https://github.com/shenghuo2/sleep-status)
69 |
70 | However, the free tier of Render will automatically spin down when there are no requests.
71 |
72 | *Free instances spin down after periods of inactivity. They do not support SSH access, scaling, one-off jobs, or persistent disks. Select any paid instance type to enable these features.*
73 |
74 | ### Docker
75 |
76 | Image name: `shenghuo2/sleep-status:latest`
77 |
78 | Run command:
79 |
80 | ```shell
81 | docker run -d -p 8000:8000 --name sleep-status shenghuo2/sleep-status:latest
82 | ```
83 |
84 | The service will start on port 8000. Use `docker logs sleep-status` to view the automatically generated random password.
85 |
86 | ### Docker Compose
87 |
88 | ```yaml
89 | version: '3.8'
90 | services:
91 | sleep-status:
92 | image: shenghuo2/sleep-status:latest
93 | ports:
94 | - "8000:8000"
95 | ```
96 |
97 | The service will start on port 8000. Use `docker logs sleep-status` to view the automatically generated random password.
98 |
99 | ## Self Deployment
100 |
101 | ### Compile to Binary
102 |
103 | 1. Ensure Go is installed (recommended version Go 1.16 or above).
104 | 2. Download or clone this project to your local machine:
105 |
106 | ```sh
107 | git clone https://github.com/shenghuo2/sleep-status.git
108 | cd sleep-status
109 | ```
110 |
111 | 3. Compile the project:
112 |
113 | ```sh
114 | go build .
115 | ```
116 |
117 | # Usage
118 |
119 | 1. Run the compiled executable:
120 |
121 | ```sh
122 | ./sleep-status [--port=] [--host=]
123 | ```
124 |
125 | > Parameters in `[ ]` are optional.
126 |
127 | By default, the service will listen on `0.0.0.0` port `8000`.
128 |
129 | 2. API Description:
130 |
131 | - Get the current `sleep` status:
132 |
133 | ```sh
134 | curl http://:/status
135 | ```
136 |
137 | Example response:
138 |
139 | ```json
140 | {
141 | "sleep": false
142 | }
143 | ```
144 |
145 | - Get sleep statistics:
146 |
147 | ```sh
148 | curl http://:/sleep-stats[?days=7&show_time_str=0&show_sleep=0]
149 | ```
150 |
151 | Parameter description:
152 | - `days`: Optional, the number of days to analyze, default is 7 days
153 | - `show_time_str`: Optional, whether to display original time strings, 1 for display, 0 for hide, default is 0
154 | - `show_sleep`: Optional, whether to display current sleep status, 1 for display, 0 for hide, default is 0
155 |
156 | Example response:
157 |
158 | ```json
159 | {
160 | "success": true,
161 | "stats": {
162 | "avg_sleep_time": "23:30",
163 | "avg_wake_time": "07:15",
164 | "avg_duration_minutes": 465,
165 | "periods": [
166 | {
167 | "sleep_time": 1711382400,
168 | "wake_time": 1711407600,
169 | "duration_minutes": 470
170 | },
171 | {
172 | "sleep_time": 1711468800,
173 | "wake_time": 1711493100,
174 | "duration_minutes": 405,
175 | "is_short": true
176 | }
177 | ]
178 | },
179 | "days": 2,
180 | "request_days": 7
181 | }
182 | ```
183 |
184 | Field description:
185 | - `avg_sleep_time`: Average sleep time (HH:MM format)
186 | - `avg_wake_time`: Average wake time (HH:MM format)
187 | - `avg_duration_minutes`: Average sleep duration (minutes)
188 | - `periods`: List of sleep periods
189 | - `sleep_time`: Sleep time (Unix timestamp, seconds)
190 | - `wake_time`: Wake time (Unix timestamp, seconds)
191 | - `duration_minutes`: Sleep duration (minutes)
192 | - `is_short`: Whether it's a short sleep (less than 3 hours), only shown for short sleeps
193 | - `days`: Actual number of days analyzed
194 | - `request_days`: Requested number of days, only shown when different from actual days
195 | - `sleep`: Current sleep status, only shown when `show_sleep=1`
196 | - `current_sleep_at`: Current sleep start time (Unix timestamp, seconds), only shown when currently asleep
197 |
198 | Notes:
199 | - Short sleep periods (less than 3 hours) are not included in average sleep and wake time calculations, but are included in average duration
200 | - When there's insufficient data, the `days` field shows the actual range of days, not the requested number
201 |
202 | - Modify the `sleep` status:
203 |
204 | ```sh
205 | curl "http://:/change?key=&status="
206 | ```
207 |
208 | Parameter description:
209 | - `key`: The `key` value in the configuration file, used to validate the request.
210 | - `status`: The desired `sleep` status, 1 for `true`, 0 for `false`.
211 |
212 | Successful response example:
213 |
214 | ```json
215 | {
216 | "success": true,
217 | "result": "status changed! now sleep is 1"
218 | }
219 | ```
220 |
221 | Failed response example:
222 |
223 | ```json
224 | {
225 | "success": false,
226 | "result": "sleep already 1"
227 | }
228 | ```
229 |
230 | Failure status code is 401
231 |
232 | ## Configuration
233 |
234 | The `config.json` file contains the following fields:
235 |
236 | ```json
237 | {
238 | "version": 2, // Configuration file version
239 | "sleep": false, // Sleep status
240 | "key": "your-key", // API key
241 | "heartbeat_enabled": false, // Whether to enable heartbeat detection
242 | "heartbeat_timeout": 60 // Heartbeat timeout in seconds
243 | }
244 | ```
245 |
246 | ### Heartbeat Detection
247 |
248 | When heartbeat detection is enabled (`heartbeat_enabled=true`), the server will:
249 | 1. Listen for heartbeat signals from clients (via the `/heartbeat` route)
250 | 2. Automatically set status to sleep if no heartbeat is received for `heartbeat_timeout` seconds
251 | 3. Automatically set status to awake when receiving a heartbeat while in sleep status
252 |
253 | Clients need to send periodic heartbeat requests:
254 | ```bash
255 | curl "http://your-server:8000/heartbeat?key=your-key"
256 | ```
257 |
258 | ## Access Logs
259 |
260 | Requests to `/status` and `/change` routes will be logged in the `access.log` file in the following format:
261 |
262 | ```
263 | 2024-08-03T13:18:53+08:00 - [::1]:19469 - /status
264 |
265 | 2024-08-03T13:19:01+08:00 - [::1]:19469 - /change
266 | ```
267 |
268 | ## Supporting Project
269 |
270 | ### Status Reporting (Android Implementation)
271 |
272 | https://github.com/shenghuo2/sleep-status-sender
273 |
274 | This app allows users to select either "Awake" or "Asleep" status and send the selected status to a designated server.
275 |
276 | There is also a settings page where users can configure the server's BASE_URL and test the connection to the server.
277 |
278 | # Others
279 |
280 | ## Changelog
281 |
282 | ### v0.1.3 (2025-03-28)
283 | - New Features
284 | - Added `/sleep-stats` API for retrieving sleep statistics
285 | - Support for calculating average sleep time, wake time, and sleep duration
286 | - Support for displaying paired sleep periods (using Unix timestamps)
287 | - Support for displaying current sleep status in `/sleep-stats` (optional)
288 | - Automatic display of current sleep start time when in sleep state
289 | - Improvements
290 | - Support for handling short sleep periods (less than 3 hours)
291 | - Automatic adjustment of actual days analyzed when data is insufficient
292 | - Optional display of original time strings, hidden by default
293 | - Optional display of current sleep status, controlled by `show_sleep=1` parameter
294 | - Code Optimization
295 | - Improved timezone handling for correct UTC and local time conversion
296 | - Optimized sleep data calculation logic
297 |
298 | ### v0.1.2 (2025-02-27)
299 | - Bug Fixes
300 | - Fixed deadlock issue in `/records` API during concurrent access
301 | - Optimized heartbeat detection mechanism to prevent lock contention with other operations
302 | - Improved stability of sleep records migration functionality
303 | - Performance Optimization
304 | - Implemented asynchronous logging to avoid blocking API responses
305 | - Implemented fine-grained locking mechanism to improve concurrent performance
306 | - Added non-locking version of record saving functions to reduce lock contention
307 | - Code Refactoring
308 | - Extracted status change logic to dedicated functions for better maintainability
309 | - Optimized heartbeat checker implementation to avoid deadlocks caused by nested locks
310 |
311 | ### v0.1.1 (2025-02-26)
312 | - New Features
313 | - Added `/records` API to view recent sleep records
314 | - Support for customizing the number of returned records (default 30)
315 | - Improvements
316 | - Optimized sleep record storage format, switched to JSON array
317 | - Added automatic migration functionality for old record formats
318 | - Improved error handling with more friendly error messages
319 |
320 | ### v0.1.0 (2025-02-25)
321 | - Added heartbeat detection functionality
322 | - Automatically detect sleep status based on heartbeat signals
323 | - Configurable heartbeat timeout
324 | - Can be enabled/disabled via configuration
325 |
326 | ### v0.0.9
327 | - Automatically create a random strong password on first startup
328 | - Log all routes
329 | - Output the current key at startup
330 | - Support `Render` one-click deployment blueprint
331 | - Support `Docker` and `Docker Compose` quick deployment
332 |
333 | - v0.0.3: Support CORS requests
334 | - v0.0.2: Support recording sleep and wake times
335 | - v0.0.1: Initial version, providing basic functionality.
336 |
337 | ## TODO
338 |
339 | - [x] Support access log recording for all routes.
340 | - [ ] Implement reading **host** and **port** values from `config.json`
341 | - [x] Implement recording **sleep and wake times**
342 | - [x] Support `CORS`
343 | - [x] Automatically create a random `key` on first startup
344 | - [x] Output the current `key` at startup
345 | - [ ] Add more status and operation APIs.
346 | - [ ] Provide more detailed error handling and response information.
347 | - [x] Support one-click deployment for `Paas`
348 | - Implemented using `Render`
349 | - [x] Automatically build and push new images on each `push`
350 | - [x] Support `Docker` and `Docker Compose`
--------------------------------------------------------------------------------
/handlers.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "sort"
8 | "strconv"
9 | "time"
10 | )
11 |
12 | // Response struct to standardize API responses
13 | // Response 结构体标准化 API 响应
14 | type Response struct {
15 | Success bool `json:"success"`
16 | Result string `json:"result"`
17 | }
18 |
19 | // SleepRecord struct to hold the sleep records
20 | // SleepRecord 结构体保存入睡和醒来时间的记录
21 | type SleepRecord struct {
22 | Action string `json:"action"`
23 | Time string `json:"time"`
24 | }
25 |
26 | // RecordsResponse struct to hold the sleep records response
27 | // RecordsResponse 结构体保存睡眠记录的响应
28 | type RecordsResponse struct {
29 | Success bool `json:"success"`
30 | Records []SleepRecord `json:"records"`
31 | }
32 |
33 | // SleepPeriod 结构体表示一个完整的睡眠周期(入睡和醒来)
34 | type SleepPeriod struct {
35 | SleepTime int64 `json:"sleep_time"` // Unix 时间戳(秒)
36 | WakeTime int64 `json:"wake_time"` // Unix 时间戳(秒)
37 | SleepTimeStr string `json:"sleep_time_str,omitempty"` // 原始时间字符串,可选
38 | WakeTimeStr string `json:"wake_time_str,omitempty"` // 原始时间字符串,可选
39 | Duration int `json:"duration_minutes"` // 睡眠时长(分钟)
40 | IsShort bool `json:"is_short,omitempty"` // 标记是否为短时间睡眠(小于3小时)
41 | }
42 |
43 | // SleepStats 结构体保存睡眠统计信息
44 | type SleepStats struct {
45 | AvgSleepTime string `json:"avg_sleep_time"`
46 | AvgWakeTime string `json:"avg_wake_time"`
47 | AvgDuration int `json:"avg_duration_minutes"`
48 | Periods []SleepPeriod `json:"periods"`
49 | ActualDays int `json:"-"` // 实际包含的天数,不输出到JSON
50 | }
51 |
52 | // StatsResponse 结构体保存睡眠统计响应
53 | type StatsResponse struct {
54 | Success bool `json:"success"`
55 | Stats SleepStats `json:"stats"`
56 | Days int `json:"days"` // 实际统计的天数
57 | RequestDays int `json:"request_days,omitempty"` // 请求的天数,如果与实际天数不同才显示
58 | Sleep *bool `json:"sleep,omitempty"` // 当前睡眠状态,可选
59 | CurrentSleepAt int64 `json:"current_sleep_at,omitempty"` // 当前睡眠开始时间(Unix 时间戳),仅当 sleep 为 true 时显示
60 | }
61 |
62 | // StatusHandler handles the /status route and returns the current sleep status
63 | // StatusHandler 处理 /status 路由并返回当前的 sleep 状态
64 | func StatusHandler(w http.ResponseWriter, r *http.Request) {
65 | clientIP := r.RemoteAddr
66 | // 异步记录访问日志,避免阻塞主处理流程
67 | go LogAccess(clientIP, "/status")
68 |
69 | w.Header().Set("Access-Control-Allow-Origin", "*")
70 | w.Header().Set("Content-Type", "application/json")
71 | json.NewEncoder(w).Encode(map[string]bool{"sleep": ConfigData.Sleep})
72 | }
73 |
74 | // ChangeHandler handles the /change route and updates the sleep status if the key is correct
75 | // ChangeHandler 处理 /change 路由并在 key 正确时更新 sleep 状态
76 | func ChangeHandler(w http.ResponseWriter, r *http.Request) {
77 | clientIP := r.RemoteAddr
78 | // 异步记录访问日志,避免阻塞主处理流程
79 | go LogAccess(clientIP, "/change")
80 |
81 | key := r.URL.Query().Get("key")
82 | status := r.URL.Query().Get("status")
83 |
84 | if key != ConfigData.Key {
85 | w.WriteHeader(http.StatusUnauthorized)
86 | json.NewEncoder(w).Encode(Response{Success: false, Result: "Key Wrong"})
87 | return
88 | }
89 |
90 | var desiredSleepState bool
91 | var action string
92 | if status == "1" {
93 | desiredSleepState = true
94 | action = "sleep"
95 | } else if status == "0" {
96 | desiredSleepState = false
97 | action = "wake"
98 | } else {
99 | w.WriteHeader(http.StatusBadRequest)
100 | json.NewEncoder(w).Encode(Response{Success: false, Result: "Invalid Status"})
101 | return
102 | }
103 |
104 | if ConfigData.Sleep == desiredSleepState {
105 | w.WriteHeader(http.StatusBadRequest)
106 | json.NewEncoder(w).Encode(Response{Success: false, Result: "sleep already " + status})
107 | } else {
108 | ConfigData.Sleep = desiredSleepState
109 | record := SleepRecord{
110 | Action: action,
111 | Time: time.Now().Format(time.RFC3339),
112 | }
113 | if err := SaveConfig(); err != nil {
114 | w.WriteHeader(http.StatusInternalServerError)
115 | json.NewEncoder(w).Encode(Response{Success: false, Result: "Failed to Save Config"})
116 | return
117 | }
118 | if err := SaveSleepRecord(record); err != nil {
119 | w.WriteHeader(http.StatusInternalServerError)
120 | json.NewEncoder(w).Encode(Response{Success: false, Result: "Failed to Save Sleep Record"})
121 | return
122 | }
123 | w.WriteHeader(http.StatusOK)
124 | json.NewEncoder(w).Encode(Response{Success: true, Result: "status changed! now sleep is " + status})
125 | }
126 | }
127 |
128 | // HeartbeatHandler handles the /heartbeat route for receiving heartbeat signals
129 | // HeartbeatHandler 处理 /heartbeat 路由接收心跳信号
130 | func HeartbeatHandler(w http.ResponseWriter, r *http.Request) {
131 | clientIP := r.RemoteAddr
132 | // 异步记录访问日志,避免阻塞主处理流程
133 | go LogAccess(clientIP, "/heartbeat")
134 |
135 | w.Header().Set("Access-Control-Allow-Origin", "*")
136 | w.Header().Set("Content-Type", "application/json")
137 |
138 | if !ConfigData.HeartbeatEnabled {
139 | w.WriteHeader(http.StatusServiceUnavailable)
140 | json.NewEncoder(w).Encode(Response{Success: false, Result: "Heartbeat monitoring is disabled"})
141 | return
142 | }
143 |
144 | key := r.URL.Query().Get("key")
145 | if key != ConfigData.Key {
146 | w.WriteHeader(http.StatusUnauthorized)
147 | json.NewEncoder(w).Encode(Response{Success: false, Result: "Key Wrong"})
148 | return
149 | }
150 |
151 | updateLastHeartbeat()
152 |
153 | // 检查当前状态,如果是睡眠状态则唤醒
154 | // 使用原子操作检查,避免不必要的锁争用
155 | needWakeUp := ConfigData.Sleep
156 |
157 | if needWakeUp {
158 | // 使用单独的函数处理唤醒操作,避免在处理请求时持有锁太久
159 | go wakeUpFromSleep()
160 | }
161 |
162 | json.NewEncoder(w).Encode(Response{Success: true, Result: "Heartbeat received"})
163 | }
164 |
165 | // wakeUpFromSleep 安全地将状态设置为醒来
166 | func wakeUpFromSleep() {
167 | mutex.Lock()
168 | defer mutex.Unlock()
169 |
170 | // 再次检查状态,避免在获取锁期间状态被改变
171 | if !ConfigData.Sleep {
172 | return
173 | }
174 |
175 | ConfigData.Sleep = false
176 | record := SleepRecord{
177 | Action: "wake",
178 | Time: time.Now().Format(time.RFC3339),
179 | }
180 |
181 | // 保存配置
182 | _ = SaveConfig()
183 |
184 | // 保存记录
185 | _ = SaveSleepRecordNoLock(record)
186 | }
187 |
188 | // RecordsHandler handles the /records route and returns the latest sleep records
189 | // RecordsHandler 处理 /records 路由并返回最新的睡眠记录
190 | func RecordsHandler(w http.ResponseWriter, r *http.Request) {
191 | clientIP := r.RemoteAddr
192 | // 异步记录访问日志,避免阻塞主处理流程
193 | go LogAccess(clientIP, "/records")
194 |
195 | w.Header().Set("Access-Control-Allow-Origin", "*")
196 | w.Header().Set("Content-Type", "application/json")
197 |
198 | // 获取限制数量参数,默认为30
199 | limitStr := r.URL.Query().Get("limit")
200 | limit := 30
201 | if limitStr != "" {
202 | if parsedLimit, err := strconv.Atoi(limitStr); err == nil && parsedLimit > 0 {
203 | limit = parsedLimit
204 | }
205 | }
206 |
207 | // 读取睡眠记录
208 | records, err := LoadSleepRecords(limit)
209 | if err != nil {
210 | w.WriteHeader(http.StatusInternalServerError)
211 | json.NewEncoder(w).Encode(Response{Success: false, Result: "Failed to load sleep records"})
212 | return
213 | }
214 |
215 | // 返回记录
216 | w.WriteHeader(http.StatusOK)
217 | json.NewEncoder(w).Encode(RecordsResponse{Success: true, Records: records})
218 | }
219 |
220 | // StatsHandler 处理 /sleep-stats 路由并返回睡眠统计数据
221 | func StatsHandler(w http.ResponseWriter, r *http.Request) {
222 | clientIP := r.RemoteAddr
223 | // 异步记录访问日志,避免阻塞主处理流程
224 | go LogAccess(clientIP, "/sleep-stats")
225 |
226 | w.Header().Set("Access-Control-Allow-Origin", "*")
227 | w.Header().Set("Content-Type", "application/json")
228 |
229 | // 获取天数参数,默认为7天
230 | daysStr := r.URL.Query().Get("days")
231 | days := 7
232 | if daysStr != "" {
233 | if parsedDays, err := strconv.Atoi(daysStr); err == nil && parsedDays > 0 {
234 | days = parsedDays
235 | }
236 | }
237 |
238 | // 获取是否显示原始时间字符串的参数,默认不显示
239 | showTimeStr := false
240 | showTimeStrParam := r.URL.Query().Get("show_time_str")
241 | if showTimeStrParam == "1" || showTimeStrParam == "true" {
242 | showTimeStr = true
243 | }
244 |
245 | // 获取是否显示当前睡眠状态的参数,默认不显示
246 | showSleep := false
247 | showSleepParam := r.URL.Query().Get("show_sleep")
248 | if showSleepParam == "1" || showSleepParam == "true" {
249 | showSleep = true
250 | }
251 |
252 | // 读取足够多的睡眠记录以覆盖所需天数
253 | // 由于我们不知道确切需要多少记录才能覆盖指定的天数,先获取较大数量的记录
254 | records, err := LoadSleepRecords(1000) // 获取足够多的记录
255 | if err != nil {
256 | w.WriteHeader(http.StatusInternalServerError)
257 | json.NewEncoder(w).Encode(Response{Success: false, Result: "Failed to load sleep records"})
258 | return
259 | }
260 |
261 | // 计算睡眠统计数据
262 | stats, err := calculateSleepStats(records, days, showTimeStr)
263 | if err != nil {
264 | w.WriteHeader(http.StatusInternalServerError)
265 | json.NewEncoder(w).Encode(Response{Success: false, Result: fmt.Sprintf("Failed to calculate sleep stats: %v", err)})
266 | return
267 | }
268 |
269 | // 返回统计数据
270 | w.WriteHeader(http.StatusOK)
271 |
272 | // 构建响应
273 | response := StatsResponse{
274 | Success: true,
275 | Stats: stats,
276 | Days: stats.ActualDays,
277 | }
278 |
279 | // 如果实际天数与请求天数不同,则显示请求天数
280 | if stats.ActualDays != days {
281 | response.RequestDays = days
282 | }
283 |
284 | // 如果需要显示当前睡眠状态
285 | if showSleep {
286 | // 直接使用配置中的状态
287 | sleepStatus := ConfigData.Sleep
288 | response.Sleep = &sleepStatus
289 | }
290 |
291 | // current_sleep_at 字段与 show_sleep 参数无关,只由 ConfigData.Sleep 控制
292 | if ConfigData.Sleep {
293 | // 获取最新的一条入睡记录
294 | latestSleepRecord, err := findLatestSleepRecord()
295 | if err == nil && latestSleepRecord.Action == "sleep" {
296 | // 将时间字符串转换为时间对象
297 | sleepTimeObj, err := convertToUTC8(latestSleepRecord.Time)
298 | if err == nil {
299 | // 转换为 Unix 时间戳
300 | response.CurrentSleepAt = sleepTimeObj.Unix()
301 | }
302 | }
303 | }
304 |
305 | json.NewEncoder(w).Encode(response)
306 | }
307 |
308 | // calculateSleepStats 计算睡眠统计数据
309 | func calculateSleepStats(records []SleepRecord, requestedDays int, showTimeStr bool) (SleepStats, error) {
310 | // 将记录按时间排序(从旧到新)
311 | sort.Slice(records, func(i, j int) bool {
312 | return records[i].Time < records[j].Time
313 | })
314 |
315 | // 找出成对的睡眠-醒来记录
316 | var periods []SleepPeriod
317 | var sleepTime string
318 |
319 | // 当前时间,用于计算天数限制
320 | now := time.Now()
321 | cutoffTime := now.AddDate(0, 0, -requestedDays)
322 |
323 | for i := 0; i < len(records); i++ {
324 | record := records[i]
325 |
326 | // 将时间字符串转换为时间对象
327 | recordTime, err := convertToUTC8(record.Time)
328 | if err != nil {
329 | continue // 跳过无效的时间记录
330 | }
331 |
332 | if record.Action == "sleep" {
333 | sleepTime = record.Time
334 | } else if record.Action == "wake" && sleepTime != "" {
335 | // 找到一对睡眠-醒来记录
336 | sleepTimeObj, err := convertToUTC8(sleepTime)
337 | if err != nil {
338 | sleepTime = "" // 重置睡眠时间
339 | continue
340 | }
341 |
342 | // 只包含指定天数内的记录
343 | if sleepTimeObj.Before(cutoffTime) {
344 | sleepTime = "" // 重置睡眠时间
345 | continue
346 | }
347 |
348 | // 计算睡眠时长(分钟)
349 | duration := int(recordTime.Sub(sleepTimeObj).Minutes())
350 |
351 | // 将所有睡眠时长大于10分钟且小于24小时的睡眠段都记录下来
352 | if duration >= 10 && duration <= 1440 {
353 | // 标记短时间睡眠(小于3小时)
354 | isShort := duration < 180
355 |
356 | // 创建睡眠周期对象
357 | period := SleepPeriod{
358 | SleepTime: sleepTimeObj.Unix(),
359 | WakeTime: recordTime.Unix(),
360 | Duration: duration,
361 | IsShort: isShort,
362 | }
363 |
364 | // 如果需要显示原始时间字符串,则添加相应字段
365 | if showTimeStr {
366 | period.SleepTimeStr = sleepTime
367 | period.WakeTimeStr = record.Time
368 | }
369 |
370 | periods = append(periods, period)
371 | }
372 |
373 | sleepTime = "" // 重置睡眠时间,准备下一对
374 | }
375 | }
376 |
377 | // 如果没有有效的睡眠周期,返回空结果
378 | if len(periods) == 0 {
379 | return SleepStats{
380 | AvgSleepTime: "",
381 | AvgWakeTime: "",
382 | AvgDuration: 0,
383 | Periods: []SleepPeriod{},
384 | }, nil
385 | }
386 |
387 | // 计算平均睡眠时间、醒来时间和睡眠时长
388 | var totalSleepMinutes, totalWakeMinutes, totalDuration int
389 | var normalPeriodCount int // 正常睡眠周期计数(大于3小时)
390 |
391 | for _, period := range periods {
392 | // 使用 Unix 时间戳创建时间对象
393 | sleepTimeObj := time.Unix(period.SleepTime, 0)
394 | wakeTimeObj := time.Unix(period.WakeTime, 0)
395 |
396 | // 将时间转换为 UTC+8 时区
397 | loc, err := time.LoadLocation("Asia/Shanghai")
398 | if err != nil {
399 | // 如果无法加载时区,手动创建 UTC+8 时区
400 | loc = time.FixedZone("UTC+8", 8*60*60)
401 | }
402 |
403 | sleepTimeObj = sleepTimeObj.In(loc)
404 | wakeTimeObj = wakeTimeObj.In(loc)
405 |
406 | // 计算睡眠时间的分钟数(从当天0点开始)
407 | sleepMinutes := sleepTimeObj.Hour()*60 + sleepTimeObj.Minute()
408 |
409 | // 处理跨天的情况
410 | if sleepTimeObj.Hour() < 12 {
411 | sleepMinutes += 24 * 60 // 将凌晨时间视为前一天的延续
412 | }
413 |
414 | // 计算醒来时间的分钟数
415 | wakeMinutes := wakeTimeObj.Hour()*60 + wakeTimeObj.Minute()
416 | if wakeTimeObj.Hour() < 12 {
417 | wakeMinutes += 24 * 60 // 将凌晨时间视为前一天的延续
418 | }
419 |
420 | // 对于所有睡眠周期,都计入总睡眠时长
421 | totalDuration += period.Duration
422 |
423 | // 只有非短时间睡眠(大于3小时)才计入平均入睡和醒来时间
424 | if !period.IsShort {
425 | totalSleepMinutes += sleepMinutes
426 | totalWakeMinutes += wakeMinutes
427 | normalPeriodCount++
428 | }
429 | }
430 |
431 | // 计算平均值
432 | var avgSleepMinutes, avgWakeMinutes int
433 |
434 | // 如果有正常睡眠周期,计算平均入睡和醒来时间
435 | if normalPeriodCount > 0 {
436 | avgSleepMinutes = totalSleepMinutes / normalPeriodCount
437 | avgWakeMinutes = totalWakeMinutes / normalPeriodCount
438 | }
439 |
440 | // 先初始化平均睡眠时长变量
441 | var avgDuration int
442 |
443 | // 将分钟数转换回小时:分钟格式
444 | avgSleepHour := (avgSleepMinutes % (24 * 60)) / 60
445 | avgSleepMinute := avgSleepMinutes % 60
446 |
447 | avgWakeHour := (avgWakeMinutes % (24 * 60)) / 60
448 | avgWakeMinute := avgWakeMinutes % 60
449 |
450 | // 格式化时间
451 | avgSleepTimeStr := fmt.Sprintf("%02d:%02d", avgSleepHour, avgSleepMinute)
452 | avgWakeTimeStr := fmt.Sprintf("%02d:%02d", avgWakeHour, avgWakeMinute)
453 |
454 | // 计算实际的天数范围
455 | var actualDays int
456 | if len(periods) > 0 {
457 | // 找出最早和最晚的睡眠记录时间
458 | earliest := time.Unix(periods[0].SleepTime, 0)
459 | latest := time.Unix(periods[len(periods)-1].WakeTime, 0)
460 |
461 | // 计算实际跨越的天数
462 | duration := latest.Sub(earliest)
463 | actualDays = int(duration.Hours()/24) + 1 // 加 1 是因为包含当天
464 |
465 | // 如果实际天数为 0(同一天内的记录),设置为 1
466 | if actualDays < 1 {
467 | actualDays = 1
468 | }
469 | } else {
470 | actualDays = 0
471 | }
472 |
473 | // 对于所有睡眠周期(包括短时间睡眠),计算每日平均睡眠时长
474 | // 使用实际天数作为分母,而不是睡眠次数
475 | if actualDays > 0 {
476 | avgDuration = totalDuration / actualDays
477 | } else {
478 | avgDuration = 0
479 | }
480 |
481 | return SleepStats{
482 | AvgSleepTime: avgSleepTimeStr,
483 | AvgWakeTime: avgWakeTimeStr,
484 | AvgDuration: avgDuration,
485 | Periods: periods,
486 | ActualDays: actualDays,
487 | }, nil
488 | }
489 |
490 | // convertToUTC8 将时间字符串转换为UTC+8时区的时间对象
491 | func convertToUTC8(timeStr string) (time.Time, error) {
492 | // 解析时间字符串
493 | t, err := time.Parse(time.RFC3339, timeStr)
494 | if err != nil {
495 | return time.Time{}, err
496 | }
497 |
498 | // 将时间转换为UTC+8时区
499 | loc, err := time.LoadLocation("Asia/Shanghai")
500 | if err != nil {
501 | // 如果无法加载时区,手动创建UTC+8时区
502 | loc = time.FixedZone("UTC+8", 8*60*60)
503 | }
504 |
505 | return t.In(loc), nil
506 | }
507 |
508 | // findLatestSleepRecord 查找最近的一条入睡记录
509 | func findLatestSleepRecord() (SleepRecord, error) {
510 | // 读取所有睡眠记录
511 | records, err := LoadSleepRecords(100) // 只获取最近的100条记录就足够了
512 | if err != nil {
513 | return SleepRecord{}, err
514 | }
515 |
516 | // 如果没有记录,返回错误
517 | if len(records) == 0 {
518 | return SleepRecord{}, fmt.Errorf("no sleep records found")
519 | }
520 |
521 | // 将记录按时间排序(从新到旧)
522 | sort.Slice(records, func(i, j int) bool {
523 | return records[i].Time > records[j].Time
524 | })
525 |
526 | // 找出最新的一条入睡记录
527 | for _, record := range records {
528 | if record.Action == "sleep" {
529 | return record, nil
530 | }
531 | }
532 |
533 | // 如果没有找到入睡记录,返回错误
534 | return SleepRecord{}, fmt.Errorf("no sleep record found")
535 | }
536 |
--------------------------------------------------------------------------------