├── 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 | ![version](https://img.shields.io/github/v/release/shenghuo2/sleep-status?include_prereleases&label=version) 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 | [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](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 | ![version](https://img.shields.io/github/v/release/shenghuo2/sleep-status?include_prereleases&label=version) 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 | [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](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 | --------------------------------------------------------------------------------