上传文件至「/」

This commit is contained in:
XOF
2025-11-28 20:10:15 +08:00
commit a686020338
4 changed files with 600 additions and 0 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
AUTH_TOKEN=YOUR_TOKEN
PORT=8080

10
Dockerfile Normal file
View File

@@ -0,0 +1,10 @@
FROM golang:1.25-alpine AS builder
WORKDIR /build
COPY main.go .
RUN go build -ldflags="-s -w" -o stock-checker main.go
FROM alpine:latest
WORKDIR /app
COPY --from=builder /build/stock-checker .
RUN chmod +x stock-checker
EXPOSE 8080
CMD ["./stock-checker"]

15
docker-compose.yaml Normal file
View File

@@ -0,0 +1,15 @@
services:
stock-checker:
build: .
container_name: stock-checker
ports:
- "8080:8080"
volumes:
- ./data:/app/data
- ./.env:/app/.env:ro
env_file:
- .env
restart: unless-stopped
environment:
- TZ=Asia/Shanghai
- AUTH_TOKEN=${AUTH_TOKEN}

573
main.go Normal file
View File

@@ -0,0 +1,573 @@
// main.go
package main
import (
"context"
"encoding/json"
"html/template"
"io"
"log"
"fmt"
"math/rand"
"net/http"
"os"
"strings"
"sync"
"time"
)
type Task struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
PageLoaded string `json:"page_loaded"`
OutOfStock string `json:"out_of_stock"`
InStock bool `json:"in_stock"`
LastCheck time.Time `json:"last_check"`
Status string `json:"status"`
}
type Config struct {
GotifyURL string `json:"gotify_url"`
GotifyToken string `json:"gotify_token"`
Interval int `json:"interval"`
Timeout int `json:"timeout"`
}
var (
tasks = make(map[string]*Task)
config = &Config{Interval: 60, Timeout: 20}
mu sync.RWMutex
configMu sync.RWMutex
authToken = os.Getenv("AUTH_TOKEN")
client *http.Client
)
func main() {
loadEnv()
loadTasks()
loadConfig()
updateClient()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go checker(ctx)
http.HandleFunc("/", handleIndex)
http.HandleFunc("/api/tasks", auth(handleTasks))
http.HandleFunc("/api/task", auth(handleTask))
http.HandleFunc("/api/config", auth(handleConfig))
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Println("启动服务:", port)
log.Fatal(http.ListenAndServe(":"+port, nil))
}
func loadEnv() {
data, err := os.ReadFile(".env")
if err != nil {
return
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
os.Setenv(parts[0], parts[1])
}
}
}
func auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if authToken != "" && r.Header.Get("Authorization") != "Bearer "+authToken {
http.Error(w, "Unauthorized", 401)
return
}
next(w, r)
}
}
func handleIndex(w http.ResponseWriter, r *http.Request) {
tmpl := `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>库存监控</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{
--bg:#f5f5f5;--card:#fff;--text:#333;--border:#ddd;--th-bg:#f8f9fa;
--green:#4CAF50;--red:#f44336;--orange:#ff9800;--blue:#2196F3;--gray:#999
}
@media(prefers-color-scheme:dark){
:root{
--bg:#1a1a1a;--card:#2d2d2d;--text:#e0e0e0;--border:#404040;--th-bg:#383838
}
}
body{font:14px sans-serif;padding:20px;background:var(--bg);color:var(--text);transition:all .3s}
.container{max-width:1400px;margin:0 auto}
h1{margin-bottom:20px;text-align:center}
.header{display:flex;justify-content:center;gap:10px;margin-bottom:20px}
.add-btn,.config-btn{background:var(--green);color:#fff;border:none;padding:10px 20px;cursor:pointer;border-radius:4px}
.config-btn{background:var(--blue)}
table{width:100%;background:var(--card);border-collapse:collapse;box-shadow:0 2px 8px rgba(0,0,0,.15);border-radius:8px;overflow:hidden}
th,td{padding:12px;text-align:left;border-bottom:1px solid var(--border)}
th{background:var(--th-bg);font-weight:600}
tbody tr:hover{background:rgba(33,150,243,.05)}
.status-cell{text-align:center}
.status{display:inline-block;width:10px;height:10px;border-radius:50%}
.status.ok{background:var(--green)}
.status.error{background:var(--red)}
.status.checking{background:var(--orange)}
.stock-yes{color:var(--green);font-weight:600}
.stock-no{color:var(--gray)}
.btn{padding:5px 10px;margin:0 2px;cursor:pointer;border:none;border-radius:3px;background:var(--blue);color:#fff;font-size:12px}
.btn-del{background:var(--red)}
.uptime-bar{display:flex;gap:2px;height:30px}
.uptime-item{flex:1;border-radius:2px;cursor:pointer;position:relative;transition:transform .2s}
.uptime-item:hover{transform:scaleY(1.2)}
.uptime-item.stock{background:var(--green)}
.uptime-item.no-stock{background:#555}
.uptime-item.error{background:var(--orange)}
.uptime-item.unknown{background:#333}
.uptime-tooltip{position:absolute;bottom:35px;left:50%;transform:translateX(-50%);background:#000;color:#fff;padding:4px 8px;border-radius:4px;font-size:11px;white-space:nowrap;opacity:0;pointer-events:none;transition:opacity .2s;z-index:10}
.uptime-item:hover .uptime-tooltip{opacity:1}
.modal{display:none;position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.6);align-items:center;justify-content:center;z-index:1000}
.modal.show{display:flex}
.modal-content{background:var(--card);padding:24px;border-radius:8px;width:90%;max-width:500px;box-shadow:0 4px 20px rgba(0,0,0,.3)}
.form-group{margin-bottom:15px}
label{display:block;margin-bottom:5px;font-weight:600}
input{width:100%;padding:10px;border:1px solid var(--border);border-radius:4px;background:var(--card);color:var(--text)}
.form-actions{display:flex;gap:10px;justify-content:flex-end;margin-top:20px}
a{color:var(--blue)}
@media(max-width:768px){
.container{padding:10px}
table{font-size:12px}
th,td{padding:8px}
}
</style>
</head>
<body>
<div class="container">
<h1>库存监控</h1>
<div class="header">
<button class="config-btn" onclick="showConfigModal()">⚙ 系统设置</button>
<button class="add-btn" onclick="showModal()">+ 添加监控</button>
</div>
<table id="taskTable">
<thead>
<tr>
<th width="60">状态</th>
<th width="150">名称</th>
<th>监控历史</th>
<th width="80">库存</th>
<th width="150">最后检测</th>
<th width="120">操作</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
<div class="modal" id="modal">
<div class="modal-content">
<h2 id="modalTitle">添加监控</h2>
<form id="taskForm">
<input type="hidden" id="taskId">
<div class="form-group">
<label>名称</label>
<input id="name" required>
</div>
<div class="form-group">
<label>URL</label>
<input id="url" required>
</div>
<div class="form-group">
<label>页面加载标识</label>
<input id="pageLoaded" placeholder="如: froots/js-js" required>
</div>
<div class="form-group">
<label>缺货标识</label>
<input id="outOfStock" placeholder="如: Out of Stock" required>
</div>
<div class="form-actions">
<button type="button" class="btn" onclick="hideModal()">取消</button>
<button type="submit" class="btn">保存</button>
</div>
</form>
</div>
</div>
<div class="modal" id="configModal">
<div class="modal-content">
<h2>系统设置</h2>
<form id="configForm">
<div class="form-group">
<label>Gotify URL</label>
<input id="gotifyUrl" placeholder="https://gotify.example.com">
</div>
<div class="form-group">
<label>Gotify Token</label>
<input id="gotifyToken" placeholder="通知令牌">
</div>
<div class="form-group">
<label>检测间隔(秒)</label>
<input id="interval" type="number" min="10" required>
</div>
<div class="form-group">
<label>超时时间(秒)</label>
<input id="timeout" type="number" min="5" required>
</div>
<div class="form-actions">
<button type="button" class="btn" onclick="hideConfigModal()">取消</button>
<button type="submit" class="btn">保存</button>
</div>
</form>
</div>
</div>
<script>
const token = localStorage.getItem('token') || prompt('请输入访问令牌:');
if(token) localStorage.setItem('token', token);
const headers = {'Authorization': 'Bearer ' + token};
let historyData = {};
const MAX_BARS = 90;
function initHistory(id) {
if(!historyData[id]) {
historyData[id] = Array(MAX_BARS).fill({state: 'unknown', time: null});
}
}
function updateHistory(id, state, time) {
initHistory(id);
historyData[id].shift();
historyData[id].push({state, time});
}
function loadTasks() {
fetch('/api/tasks', {headers})
.then(r => r.json())
.then(tasks => {
tasks.forEach(t => {
initHistory(t.id);
const state = t.status === 'ok' ? (t.in_stock ? 'stock' : 'no-stock') : (t.status === 'error' ? 'error' : 'unknown');
const lastState = historyData[t.id][MAX_BARS - 1];
if(!lastState.time || lastState.time !== t.last_check) {
updateHistory(t.id, state, t.last_check || Date.now());
}
});
const tbody = document.querySelector('#taskTable tbody');
tbody.innerHTML = tasks.map(t => {
const statusClass = t.status || 'checking';
const stockText = t.in_stock ? '<span class="stock-yes">有货</span>' : '<span class="stock-no">无货</span>';
const lastCheck = t.last_check ? new Date(t.last_check).toLocaleString('zh-CN') : '-';
const history = historyData[t.id] || [];
const uptimeBar = history.map(h => {
const time = h.time ? new Date(h.time).toLocaleString('zh-CN') : '等待检测';
const label = h.state === 'stock' ? '有货' : h.state === 'no-stock' ? '无货' : h.state === 'error' ? '错误' : '未知';
return '<div class="uptime-item ' + h.state + '"><span class="uptime-tooltip">' + time + '<br>' + label + '</span></div>';
}).join('');
return '<tr>' +
'<td class="status-cell"><span class="status ' + statusClass + '"></span></td>' +
'<td>' + t.name + '</td>' +
'<td><div class="uptime-bar">' + uptimeBar + '</div></td>' +
'<td>' + stockText + '</td>' +
'<td>' + lastCheck + '</td>' +
'<td>' +
'<button class="btn" onclick="editTask(\'' + t.id + '\')">编辑</button>' +
'<button class="btn btn-del" onclick="delTask(\'' + t.id + '\')">删除</button>' +
'</td></tr>';
}).join('');
});
}
function showModal(task) {
document.getElementById('modalTitle').textContent = task ? '编辑监控' : '添加监控';
document.getElementById('taskId').value = task?.id || '';
document.getElementById('name').value = task?.name || '';
document.getElementById('url').value = task?.url || '';
document.getElementById('pageLoaded').value = task?.page_loaded || '';
document.getElementById('outOfStock').value = task?.out_of_stock || '';
document.getElementById('modal').classList.add('show');
}
function hideModal() {
document.getElementById('modal').classList.remove('show');
}
function showConfigModal() {
fetch('/api/config', {headers})
.then(r => r.json())
.then(cfg => {
document.getElementById('gotifyUrl').value = cfg.gotify_url || '';
document.getElementById('gotifyToken').value = cfg.gotify_token || '';
document.getElementById('interval').value = cfg.interval || 60;
document.getElementById('timeout').value = cfg.timeout || 20;
document.getElementById('configModal').classList.add('show');
});
}
function hideConfigModal() {
document.getElementById('configModal').classList.remove('show');
}
function editTask(id) {
fetch('/api/tasks', {headers})
.then(r => r.json())
.then(tasks => {
const task = tasks.find(t => t.id === id);
if(task) showModal(task);
});
}
function delTask(id) {
if(!confirm('确认删除?')) return;
fetch('/api/task?id=' + id, {method: 'DELETE', headers})
.then(() => {
delete historyData[id];
loadTasks();
});
}
document.getElementById('taskForm').onsubmit = e => {
e.preventDefault();
const data = {
id: document.getElementById('taskId').value,
name: document.getElementById('name').value,
url: document.getElementById('url').value,
page_loaded: document.getElementById('pageLoaded').value,
out_of_stock: document.getElementById('outOfStock').value
};
fetch('/api/task', {
method: 'POST',
headers: {...headers, 'Content-Type': 'application/json'},
body: JSON.stringify(data)
}).then(() => {
hideModal();
loadTasks();
});
};
document.getElementById('configForm').onsubmit = e => {
e.preventDefault();
const data = {
gotify_url: document.getElementById('gotifyUrl').value,
gotify_token: document.getElementById('gotifyToken').value,
interval: parseInt(document.getElementById('interval').value),
timeout: parseInt(document.getElementById('timeout').value)
};
fetch('/api/config', {
method: 'POST',
headers: {...headers, 'Content-Type': 'application/json'},
body: JSON.stringify(data)
}).then(() => {
hideConfigModal();
alert('设置已保存并生效');
});
};
loadTasks();
setInterval(loadTasks, 30000);
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html")
template.Must(template.New("").Parse(tmpl)).Execute(w, nil)
}
func handleTasks(w http.ResponseWriter, r *http.Request) {
mu.RLock()
defer mu.RUnlock()
list := make([]*Task, 0, len(tasks))
for _, t := range tasks {
list = append(list, t)
}
json.NewEncoder(w).Encode(list)
}
func handleTask(w http.ResponseWriter, r *http.Request) {
if r.Method == "DELETE" {
id := r.URL.Query().Get("id")
mu.Lock()
delete(tasks, id)
mu.Unlock()
saveTasks()
return
}
var task Task
json.NewDecoder(r.Body).Decode(&task)
if task.ID == "" {
task.ID = time.Now().Format("20060102150405")
}
mu.Lock()
tasks[task.ID] = &task
mu.Unlock()
saveTasks()
}
func handleConfig(w http.ResponseWriter, r *http.Request) {
if r.Method == "POST" {
var cfg Config
json.NewDecoder(r.Body).Decode(&cfg)
configMu.Lock()
config = &cfg
configMu.Unlock()
updateClient()
saveConfig()
return
}
configMu.RLock()
defer configMu.RUnlock()
json.NewEncoder(w).Encode(config)
}
func checker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
default:
}
configMu.RLock()
interval := config.Interval
configMu.RUnlock()
mu.RLock()
list := make([]*Task, 0, len(tasks))
for _, t := range tasks {
list = append(list, t)
}
mu.RUnlock()
for _, task := range list {
go checkTask(task)
time.Sleep(time.Duration(2+rand.Intn(5)) * time.Second)
}
jitter := rand.Intn(20) - 10
time.Sleep(time.Duration(interval+jitter) * time.Second)
}
}
func checkTask(task *Task) {
task.Status = "checking"
body, err := fetch(task.URL)
now := time.Now()
status := "ok"
inStock := false
if err != nil || !strings.Contains(body, task.PageLoaded) {
status = "error"
} else {
inStock = !strings.Contains(body, task.OutOfStock)
}
mu.Lock()
wasInStock := task.InStock
task.Status = status
task.InStock = inStock
task.LastCheck = now
mu.Unlock()
if inStock && !wasInStock {
notify(task.Name + " 有货了!", task.URL)
}
}
func fetch(url string) (string, error) {
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.9")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return string(body), nil
}
func notify(title, msg string) {
configMu.RLock()
url, token := config.GotifyURL, config.GotifyToken
configMu.RUnlock()
if url == "" || token == "" {
return
}
go func() {
payload := fmt.Sprintf(`{"title":"%s","message":"%s","priority":10}`, title, msg)
http.Post(url+"/message?token="+token, "application/json", strings.NewReader(payload))
}()
}
func updateClient() {
configMu.RLock()
timeout := config.Timeout
configMu.RUnlock()
client = &http.Client{Timeout: time.Duration(timeout) * time.Second}
}
func saveJSON(filename string, v interface{}) {
data, _ := json.MarshalIndent(v, "", " ")
os.WriteFile(filename, data, 0644)
}
func loadTasks() {
data, err := os.ReadFile("tasks.json")
if err != nil {
return
}
var list []*Task
json.Unmarshal(data, &list)
mu.Lock()
for _, t := range list {
tasks[t.ID] = t
}
mu.Unlock()
}
func saveTasks() {
mu.RLock()
list := make([]*Task, 0, len(tasks))
for _, t := range tasks {
list = append(list, t)
}
mu.RUnlock()
saveJSON("tasks.json", list)
}
func loadConfig() {
data, err := os.ReadFile("config.json")
if err != nil {
return
}
configMu.Lock()
json.Unmarshal(data, config)
configMu.Unlock()
}
func saveConfig() {
configMu.RLock()
defer configMu.RUnlock()
saveJSON("config.json", config)
}