Compare commits
15 Commits
7498da05d4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 166109a1c8 | |||
| 940e493e39 | |||
| fb546861b5 | |||
| 4a3e39d76d | |||
| a46dd6eb74 | |||
| 7aae6f9e52 | |||
| 64baba32b4 | |||
| 1890a8380b | |||
| 5e68d97127 | |||
| 1b1daa581e | |||
| 339f8211e4 | |||
| 616fe10b02 | |||
| 04d70ace99 | |||
| d99a92b6dc | |||
| 1ad735db2e |
@@ -0,0 +1,14 @@
|
||||
.git/
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
data/
|
||||
instance/
|
||||
*.db
|
||||
*.sqlite
|
||||
app.log
|
||||
node_modules/
|
||||
.trae/
|
||||
docs/
|
||||
+11
-9
@@ -1,20 +1,22 @@
|
||||
# 多阶段构建:前端静态 → 后端镜像
|
||||
FROM python:3.11-slim
|
||||
FROM python:3.11-alpine
|
||||
WORKDIR /app
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
TZ=Asia/Shanghai
|
||||
RUN sed -i 's|deb.debian.org|mirrors.cloud.tencent.com|g' /etc/apt/sources.list && \
|
||||
sed -i 's|security.debian.org|mirrors.cloud.tencent.com|g' /etc/apt/sources.list && \
|
||||
apt-get update -o Acquire::Retries=3 -o Acquire::http::Timeout=30 && \
|
||||
apt-get install -y --no-install-recommends tzdata && \
|
||||
ln -fs /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && dpkg-reconfigure -f noninteractive tzdata && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
TZ=Asia/Shanghai \
|
||||
PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple \
|
||||
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||
AUTO_IMPORT_ON_START=1
|
||||
RUN sed -i -e 's/dl-cdn.alpinelinux.org/mirrors.cloud.tencent.com/g' /etc/apk/repositories && \
|
||||
apk update && apk add --no-cache tzdata && \
|
||||
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo "Asia/Shanghai" > /etc/timezone
|
||||
COPY backend/requirements.txt .
|
||||
RUN pip install --no-cache-dir -i https://mirrors.cloud.tencent.com/pypi/simple -r requirements.txt
|
||||
RUN python -m pip install --no-cache-dir --upgrade pip -i $PIP_INDEX_URL && \
|
||||
pip install --no-cache-dir -r requirements.txt -i $PIP_INDEX_URL
|
||||
COPY backend/ ./backend/
|
||||
COPY frontend/ ./frontend/
|
||||
COPY config.json ./config.json
|
||||
RUN mkdir -p /app/data
|
||||
VOLUME ["/app/data"]
|
||||
EXPOSE 57778
|
||||
CMD ["python", "backend/app.py"]
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
## 快速部署(Docker)
|
||||
- 1:准备环境变量(不要提交真实口令到仓库)
|
||||
- 复制 `.env.example` 到 `.env` 并填写 `ADMIN_TOKEN`
|
||||
- 2:启动服务
|
||||
- 2:配置映射(确保容器读取宿主机配置与数据)
|
||||
- `docker-compose.yml` 已映射:`./data:/app/data`、`./config.json:/app/config.json:ro`
|
||||
- 3:启动服务
|
||||
```bash
|
||||
docker-compose up -d
|
||||
docker-compose up -d --build
|
||||
```
|
||||
- 3:访问入口
|
||||
- 4:访问入口
|
||||
- 浏览器打开 `http://<服务器IP或域名>:57778`
|
||||
- 5:在线重载截止时间(无需重启)
|
||||
```bash
|
||||
curl -X POST "http://<服务器>:57778/api/admin/reload_cutoff" -H "X-Admin-Token: $ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
## 腾讯云环境优化
|
||||
- 使用腾讯云 PyPI 镜像:Dockerfile 已设置 `pip -i https://mirrors.cloud.tencent.com/pypi/simple`
|
||||
@@ -16,22 +22,33 @@ docker-compose up -d
|
||||
- 数据持久化:`docker-compose.yml` 将宿主机 `./data` 映射为容器 `/app/data`,数据库路径 `sqlite:///data/data.db`
|
||||
|
||||
## 功能概览
|
||||
- 每日 23:00(服务器本地时区)自动生成并定版当日营业额
|
||||
- 每日截止时间(本地时区,支持分钟级,如 `13:18`)自动生成并定版当日营业额
|
||||
- 修正接口:支持按日期修正金额,前端即时一致更新;修正过程不直接在前端展示
|
||||
- 审计与日志:所有生成/修正写入审计表,兼容追加到 `app.log`
|
||||
- 看板:今日/昨日/前日、本周(周一~昨日)、上周(周一~周日)、本月
|
||||
- 折线图:最近 7 天、本月、上月、最近 90 天;支持缩放、导出图片/CSV
|
||||
- 飞书推送:卡片 → 帖子 → 文本三段式兼容;支持签名与 IP 白名单
|
||||
|
||||
## 项目结构
|
||||
```
|
||||
.
|
||||
├── backend/ # Flask API + APScheduler
|
||||
├── frontend/ # 单页 HTML(Tailwind+Chart.js)
|
||||
├── data/ # SQLite 数据卷(自动创建/挂载)
|
||||
├── Dockerfile # 生产镜像(已适配腾讯云)
|
||||
├── docker-compose.yml # 一键部署
|
||||
├── .env.example # 环境变量示例(不要提交真实 .env)
|
||||
└── README.md
|
||||
├── backend/ # Flask API + APScheduler
|
||||
├── frontend/ # 单页 HTML(Tailwind+Chart.js)
|
||||
├── data/ # SQLite 数据卷(容器映射 /app/data)
|
||||
├── instance/ # 历史/本地副本(当前不作为权威数据源)
|
||||
├── docs/
|
||||
│ └── 自动化营业额系统/
|
||||
│ ├── API.md # 全量接口文档
|
||||
│ ├── DESIGN.md # 设计说明
|
||||
│ └── PRD.md # 需求说明
|
||||
├── scripts/
|
||||
│ ├── deploy.ps1 # Windows 一键部署/重载/试推
|
||||
│ └── deploy.sh # Linux 一键部署/重载/试推
|
||||
├── Dockerfile # 生产镜像(已适配腾讯云)
|
||||
├── docker-compose.yml # 一键部署(映射 data 与 config)
|
||||
├── .env.example # 环境变量示例(不要提交真实 .env)
|
||||
├── README.md
|
||||
└── app.log # 运行日志(含飞书返回体片段)
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
@@ -39,6 +56,7 @@ docker-compose up -d
|
||||
- `ADMIN_TOKEN`:管理修正口令;修正时在请求头加入 `X-Admin-Token`
|
||||
- `TZ`:容器时区(默认 `Asia/Shanghai`)
|
||||
- `PORT`:应用监听端口(默认 `5000`,示例已改为 `57778`)
|
||||
- `AUTO_IMPORT_ON_START`:启动时自动导入 `data/import.csv`(DB 为空时)
|
||||
|
||||
## 修正接口示例
|
||||
```bash
|
||||
@@ -48,6 +66,63 @@ curl -X PUT http://localhost:5000/api/admin/turnover \
|
||||
-d '{"date":"2025-12-06","amount":3123.45,"reason":"调整入账"}'
|
||||
```
|
||||
|
||||
## 在线重载截止时间(无需重启)
|
||||
```bash
|
||||
curl -X POST "http://localhost:57778/api/admin/reload_cutoff" -H "X-Admin-Token: $ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
## 读取运行日志(含推送返回体片段)
|
||||
```bash
|
||||
curl "http://localhost:57778/api/admin/logs?lines=200" -H "X-Admin-Token: $ADMIN_TOKEN"
|
||||
```
|
||||
|
||||
## 数据预置与迁移
|
||||
- 首次启动自动导入(开启 `AUTO_IMPORT_ON_START=1` 且 DB为空):
|
||||
- 如果存在 `/app/data/import.csv`(宿主机 `./data/import.csv`),自动导入该文件
|
||||
- CSV格式:
|
||||
```
|
||||
date,amount
|
||||
2025-12-01,12345.67
|
||||
2025-12-02,11890.12
|
||||
```
|
||||
- 手动导入接口:
|
||||
```bash
|
||||
curl -X POST http://<服务器>:57778/api/admin/import \
|
||||
-H "X-Admin-Token: $ADMIN_TOKEN" -H "Content-Type: text/csv" \
|
||||
--data-binary @revenue.csv
|
||||
```
|
||||
|
||||
## 本地运行(默认数据库路径)
|
||||
- 默认数据库:`sqlite:///data/data.db`(项目根目录下的 `data/data.db`)
|
||||
- 启动:
|
||||
```powershell
|
||||
$env:PORT=57778; $env:ADMIN_TOKEN="<你的口令>"; python backend/app.py
|
||||
```
|
||||
- 自定义路径(Windows 绝对路径示例):
|
||||
```powershell
|
||||
$env:DATABASE_URL="sqlite:////e:/2025Code/python/YixuanYingye/data/data.db"; python backend/app.py
|
||||
```
|
||||
|
||||
## 飞书机器人配置
|
||||
- 使用飞书自定义机器人 Webhook:`https://open.feishu.cn/open-apis/bot/v2/hook/<ID>`
|
||||
- 如开启签名校验,在 `config.json` 设置 `feishu_secret`;未开启则留空
|
||||
- 如启用 IP 白名单,需在机器人配置中加入服务器出口公网 IP;否则会返回 `{"code":19022,"msg":"Ip Not Allowed"}`
|
||||
|
||||
## API 文档
|
||||
- 详见 `docs/自动化营业额系统/API.md`
|
||||
|
||||
## 归档建议
|
||||
- 保留以下文件以便复现与审计:
|
||||
- `docs/自动化营业额系统/*`、`Dockerfile`、`docker-compose.yml`、`config.json`、`app.log`
|
||||
- 生产数据卷:`data/data.db`(或导出的 CSV)
|
||||
- 如迁移外部数据库,建议取消数据卷映射并设置 `DATABASE_URL`(PostgreSQL/MySQL)
|
||||
|
||||
## 环境优化(最优解)
|
||||
- 基础镜像切换为 `python:3.11-alpine`,构建更快、体积更小
|
||||
- 依赖安装走腾讯云 PyPI 镜像:`PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple`
|
||||
- 自动数据导入:环境变量 `AUTO_IMPORT_ON_START=1`,启动时按顺序导入
|
||||
- `/app/data/import.csv`(宿主机 `./data/import.csv`)
|
||||
|
||||
## 生产建议
|
||||
- 建议使用反向代理(Nginx)暴露 `5000` 端口,并开启 gzip
|
||||
- 建议通过 `docker-compose` 的 `restart: unless-stopped` 保持服务稳定
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
准备发送消息: 【益选便利店】2025-12-07的营业额:2408.7
|
||||
准备发送消息: 【益选便利店】2025-12-08的营业额:3402.6
|
||||
准备发送消息: 【益选便利店】2025-12-08的营业额:3629.76
|
||||
准备发送消息: 【益选便利店】2025-12-06的营业额:1803.09
|
||||
准备发送消息: 【益选便利店】2025-12-09的营业额:3462.53
|
||||
准备发送消息: 【益选便利店】2025-12-10的营业额:3222.85
|
||||
准备发送消息: 【益选便利店】2025-12-11的营业额:3550.84
|
||||
+410
-30
@@ -12,13 +12,38 @@ import random
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import csv
|
||||
import io
|
||||
import requests
|
||||
import hmac
|
||||
import hashlib
|
||||
import base64
|
||||
|
||||
load_dotenv()
|
||||
app = Flask(__name__, static_folder="../frontend", static_url_path="/static")
|
||||
CORS(app)
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = os.getenv("DATABASE_URL", "sqlite:///data.db")
|
||||
|
||||
base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
|
||||
db_path = os.path.join(base_dir, "data", "data.db")
|
||||
db_url_env = os.getenv('DATABASE_URL')
|
||||
if db_url_env:
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = db_url_env
|
||||
else:
|
||||
if os.name == 'nt':
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:///{db_path}"
|
||||
else:
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = f"sqlite:////{db_path}"
|
||||
|
||||
def _ensure_sqlite_dir():
|
||||
d = os.path.dirname(db_path)
|
||||
if not os.path.exists(d):
|
||||
os.makedirs(d, exist_ok=True)
|
||||
|
||||
_ensure_sqlite_dir()
|
||||
db = SQLAlchemy(app)
|
||||
|
||||
scheduler = None
|
||||
|
||||
class DailyRevenue(db.Model):
|
||||
__tablename__ = 'daily_revenue'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
@@ -55,6 +80,102 @@ with app.app_context():
|
||||
db.session.execute(text('ALTER TABLE daily_revenue ADD COLUMN note TEXT'))
|
||||
db.session.commit()
|
||||
|
||||
def push_feishu(date_str: str, amount: float, reason: str):
|
||||
cfg = load_config()
|
||||
url = cfg.get("feishu_webhook_url")
|
||||
if not url:
|
||||
return
|
||||
shop = cfg.get("shop_name", "益选便利店")
|
||||
try:
|
||||
d = datetime.strptime(date_str, '%Y-%m-%d').date()
|
||||
except Exception:
|
||||
d = datetime.now().date()
|
||||
y = d - timedelta(days=1)
|
||||
r = DailyRevenue.query.filter_by(date=y).first()
|
||||
y_amt = (r.amount if (r and r.is_final) else None)
|
||||
arrow = ''
|
||||
diff_str = ''
|
||||
pct_str = ''
|
||||
if isinstance(y_amt, (int, float)):
|
||||
diff = amount - y_amt
|
||||
arrow = '🔺' if diff >= 0 else '🔻'
|
||||
diff_str = f"{'+' if diff >= 0 else '-'}{abs(diff):.2f}"
|
||||
if y_amt != 0:
|
||||
pct = abs(diff / y_amt) * 100.0
|
||||
pct_str = f"({'+' if diff >= 0 else '-'}{pct:.2f}%)"
|
||||
today_line = f"**今日**:¥{amount:.2f}"
|
||||
if isinstance(y_amt, (int, float)):
|
||||
today_line += f" {arrow} {diff_str} {pct_str}".strip()
|
||||
y_line = f"**昨日**:{('暂无数据' if y_amt is None else '¥' + format(y_amt, '.2f'))}"
|
||||
card = {
|
||||
"config": {"wide_screen_mode": True},
|
||||
"elements": [
|
||||
{"tag": "div", "text": {"tag": "lark_md", "content": f"📊 **{shop}** 营业额通知"}},
|
||||
{"tag": "hr"},
|
||||
{"tag": "div", "text": {"tag": "lark_md", "content": f"**日期**:{date_str}"}},
|
||||
{"tag": "div", "text": {"tag": "lark_md", "content": today_line}},
|
||||
{"tag": "div", "text": {"tag": "lark_md", "content": y_line}},
|
||||
{"tag": "note", "elements": [
|
||||
{"tag": "plain_text", "content": f"原因:{reason} | 时间:{datetime.now().isoformat(timespec='seconds')}"}
|
||||
]}
|
||||
]
|
||||
}
|
||||
payload = {"msg_type": "interactive", "card": card}
|
||||
secret = cfg.get("feishu_secret")
|
||||
if secret:
|
||||
ts = str(int(time.time()))
|
||||
sign_src = ts + "\n" + secret
|
||||
sign = base64.b64encode(hmac.new(secret.encode(), sign_src.encode(), digestmod=hashlib.sha256).digest()).decode()
|
||||
payload.update({"timestamp": ts, "sign": sign})
|
||||
def _log(s: str):
|
||||
p = os.path.join(os.path.dirname(__file__), "..", "app.log")
|
||||
with open(p, 'a', encoding='utf-8') as f:
|
||||
f.write(s + "\n")
|
||||
try:
|
||||
print(s, flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
def _post_json(u: str, payload_obj: dict):
|
||||
body = json.dumps(payload_obj, ensure_ascii=False).encode('utf-8')
|
||||
return requests.post(u, data=body, headers={'Content-Type': 'application/json; charset=utf-8'}, timeout=5)
|
||||
try:
|
||||
is_feishu = ('open.feishu.cn' in url)
|
||||
ok = False
|
||||
if is_feishu:
|
||||
resp = _post_json(url, payload)
|
||||
ok = (200 <= resp.status_code < 300)
|
||||
_log(f"飞书推送卡片{'成功' if ok else '失败'}: status={resp.status_code} body={resp.text[:500]}")
|
||||
if not ok:
|
||||
post_payload = {
|
||||
"msg_type": "post",
|
||||
"content": {
|
||||
"post": {
|
||||
"zh_cn": {
|
||||
"title": f"{shop} 营业额通知",
|
||||
"content": [[
|
||||
{"tag":"text","text": f"日期:{date_str}\n"},
|
||||
{"tag":"text","text": f"今日:¥{amount:.2f}"},
|
||||
*( [{"tag":"text","text": f" {arrow} {diff_str} {pct_str}"}] if isinstance(y_amt,(int,float)) else [] ),
|
||||
],[
|
||||
{"tag":"text","text": f"原因:{reason}"}
|
||||
]]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
resp_post = _post_json(url, post_payload)
|
||||
ok = (200 <= resp_post.status_code < 300)
|
||||
_log(f"飞书推送POST{'成功' if ok else '失败'}: status={resp_post.status_code} body={resp_post.text[:500]}")
|
||||
if not ok:
|
||||
text = f"{shop}\n日期:{date_str}\n今日:¥{amount:.2f}"
|
||||
if isinstance(y_amt, (int, float)):
|
||||
text += f" {arrow} {diff_str} {pct_str}".strip()
|
||||
text += f"\n原因:{reason}"
|
||||
resp2 = _post_json(url, {"msg_type":"text","content":{"text": text}})
|
||||
_log(f"飞书推送文本{'成功' if (200 <= resp2.status_code < 300) else '失败'}: status={resp2.status_code} body={resp2.text[:500]}")
|
||||
except Exception as e:
|
||||
_log(f"飞书推送异常: {str(e)[:200]}")
|
||||
|
||||
def generate_mock_revenue():
|
||||
"""保持原有逻辑:生成当日模拟营业额"""
|
||||
base = random.uniform(8000, 15000)
|
||||
@@ -66,25 +187,71 @@ def _append_log_line(date_str: str, amount: float, shop_name: str):
|
||||
line = f"准备发送消息: 【{shop_name}】{date_str}的营业额:{amount}"
|
||||
with open(log_path, 'a', encoding='utf-8') as f:
|
||||
f.write(line + "\n")
|
||||
try:
|
||||
print(line, flush=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def daily_job():
|
||||
"""定时任务:在本地 23:00 生成并定版当日数据(不重复生成)"""
|
||||
def daily_job(target_date=None):
|
||||
"""定时任务:生成并定版指定日期的数据(默认当日)"""
|
||||
with app.app_context():
|
||||
cfg = load_config()
|
||||
shop_name = cfg.get("shop_name", "益选便利店")
|
||||
if target_date is None:
|
||||
target_date = datetime.now().date()
|
||||
existing = DailyRevenue.query.filter_by(date=target_date).first()
|
||||
if existing:
|
||||
if not existing.is_final:
|
||||
existing.is_final = True
|
||||
existing.source = existing.source or 'generator'
|
||||
db.session.commit()
|
||||
push_feishu(target_date.isoformat(), existing.amount, "daily_finalize")
|
||||
else:
|
||||
push_feishu(target_date.isoformat(), existing.amount, "daily_exists")
|
||||
return
|
||||
amount = gen_amount_for_date(target_date, cfg)
|
||||
rev = DailyRevenue(date=target_date, amount=amount, is_final=True, source='generator')
|
||||
db.session.add(rev)
|
||||
db.session.add(AuditLog(date=target_date, old_amount=None, new_amount=amount, reason='daily_generate', actor='system', type='generate'))
|
||||
db.session.commit()
|
||||
_append_log_line(target_date.isoformat(), amount, shop_name)
|
||||
push_feishu(target_date.isoformat(), amount, "daily_generate")
|
||||
|
||||
def settle_today_if_due():
|
||||
cfg = load_config()
|
||||
shop_name = cfg.get("shop_name", "益选便利店")
|
||||
today = datetime.now().date()
|
||||
existing = DailyRevenue.query.filter_by(date=today).first()
|
||||
if existing:
|
||||
if not existing.is_final:
|
||||
existing.is_final = True
|
||||
existing.source = existing.source or 'generator'
|
||||
db.session.commit()
|
||||
return
|
||||
amount = generate_mock_revenue()
|
||||
rev = DailyRevenue(date=today, amount=amount, is_final=True, source='generator')
|
||||
db.session.add(rev)
|
||||
db.session.add(AuditLog(date=today, old_amount=None, new_amount=amount, reason='daily_generate', actor='system', type='generate'))
|
||||
db.session.commit()
|
||||
_append_log_line(today.isoformat(), amount, shop_name)
|
||||
ct = cfg.get("cutoff_time")
|
||||
ch = cfg.get("cutoff_hour", 23)
|
||||
cm = 0
|
||||
if isinstance(ct, str) and re.match(r'^\d{1,2}:\d{2}$', ct):
|
||||
try:
|
||||
p = ct.split(':')
|
||||
ch = int(p[0]); cm = int(p[1])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ch = int(ch)
|
||||
except Exception:
|
||||
ch = 23
|
||||
if ch < 0 or ch > 23:
|
||||
ch = 23
|
||||
if cm < 0 or cm > 59:
|
||||
cm = 0
|
||||
local_tz = get_localzone()
|
||||
now = datetime.now(local_tz)
|
||||
if (now.hour > ch) or (now.hour == ch and now.minute >= cm):
|
||||
daily_job()
|
||||
|
||||
def settle_past_days():
|
||||
"""启动检查:补录过去3天未定版的数据(防止服务器宕机漏单)"""
|
||||
with app.app_context():
|
||||
local_tz = get_localzone()
|
||||
today = datetime.now(local_tz).date()
|
||||
for i in range(1, 4):
|
||||
d = today - timedelta(days=i)
|
||||
existing = DailyRevenue.query.filter_by(date=d).first()
|
||||
if not existing or not existing.is_final:
|
||||
print(f"补录数据: {d}")
|
||||
daily_job(d)
|
||||
|
||||
# ---- 日志解析与聚合 ----
|
||||
def load_config():
|
||||
@@ -150,7 +317,9 @@ def get_periods(today: date):
|
||||
def api_metrics():
|
||||
cfg = load_config()
|
||||
shop_name = cfg.get("shop_name", "益选便利店")
|
||||
today_local = datetime.now().date()
|
||||
local_tz = get_localzone()
|
||||
now_local = datetime.now(local_tz)
|
||||
today_local = now_local.date()
|
||||
periods = get_periods(today_local)
|
||||
yday = periods["yesterday"]
|
||||
day_before = periods["day_before"]
|
||||
@@ -171,11 +340,33 @@ def api_metrics():
|
||||
cur += timedelta(days=1)
|
||||
return round(total, 2)
|
||||
weekdays = ['周一','周二','周三','周四','周五','周六','周日']
|
||||
def get_cutoff_hm():
|
||||
ct = cfg.get("cutoff_time")
|
||||
h = cfg.get("cutoff_hour", 23)
|
||||
m = 0
|
||||
if isinstance(ct, str) and re.match(r'^\d{1,2}:\d{2}$', ct):
|
||||
try:
|
||||
p = ct.split(':')
|
||||
h = int(p[0]); m = int(p[1])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
h = int(h)
|
||||
except Exception:
|
||||
h = 23
|
||||
if h < 0 or h > 23:
|
||||
h = 23
|
||||
if m < 0 or m > 59:
|
||||
m = 0
|
||||
return h, m
|
||||
ch, cm = get_cutoff_hm()
|
||||
show_today = (now_local.hour > ch) or (now_local.hour == ch and now_local.minute >= cm)
|
||||
out = {
|
||||
"shop_name": shop_name,
|
||||
"server_now": datetime.now().isoformat(timespec='seconds'),
|
||||
"cutoff_hour": 23,
|
||||
"today": {"date": today_local.isoformat(), "weekday": weekdays[today_local.weekday()], "amount": amt_for(today_local)},
|
||||
"server_now": now_local.isoformat(timespec='seconds'),
|
||||
"cutoff_hour": ch,
|
||||
"cutoff_time": f"{ch:02d}:{cm:02d}",
|
||||
"today": {"date": today_local.isoformat(), "weekday": weekdays[today_local.weekday()], "amount": (amt_for(today_local) if show_today else None)},
|
||||
"yesterday": {"date": yday.isoformat(), "amount": amt_for(yday)},
|
||||
"day_before": {"date": day_before.isoformat(), "amount": amt_for(day_before)},
|
||||
"this_week": {"start": tw_s.isoformat(), "end": tw_e.isoformat(), "total": sum_final(tw_s, tw_e)},
|
||||
@@ -186,16 +377,36 @@ def api_metrics():
|
||||
|
||||
@app.route("/api/series7")
|
||||
def api_series7():
|
||||
today_local = datetime.now().date()
|
||||
local_tz = get_localzone()
|
||||
now_local = datetime.now(local_tz)
|
||||
today_local = now_local.date()
|
||||
days = int(request.args.get('days', 7))
|
||||
days = max(7, min(days, 90))
|
||||
# 结束日期:若今日已定版则含今日,否则到昨日
|
||||
# 结束日期:若到达截止时间且今日已定版则含今日,否则到昨日
|
||||
end = today_local
|
||||
r_today = DailyRevenue.query.filter_by(date=today_local).first()
|
||||
if not (r_today and r_today.is_final):
|
||||
cfg = load_config()
|
||||
ct = cfg.get("cutoff_time")
|
||||
ch = cfg.get("cutoff_hour", 23)
|
||||
cm = 0
|
||||
if isinstance(ct, str) and re.match(r'^\d{1,2}:\d{2}$', ct):
|
||||
try:
|
||||
p = ct.split(':')
|
||||
ch = int(p[0]); cm = int(p[1])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ch = int(ch)
|
||||
except Exception:
|
||||
ch = 23
|
||||
if ch < 0 or ch > 23:
|
||||
ch = 23
|
||||
if cm < 0 or cm > 59:
|
||||
cm = 0
|
||||
show_today = (now_local.hour > ch) or (now_local.hour == ch and now_local.minute >= cm)
|
||||
if not (show_today and r_today and r_today.is_final):
|
||||
end = today_local - timedelta(days=1)
|
||||
start = end - timedelta(days=days-1)
|
||||
cfg = load_config()
|
||||
series = []
|
||||
cur = start
|
||||
while cur <= end:
|
||||
@@ -210,6 +421,38 @@ def api_series7():
|
||||
cur += timedelta(days=1)
|
||||
return jsonify(series)
|
||||
|
||||
@app.route('/api/admin/reload_cutoff', methods=['POST'])
|
||||
def admin_reload_cutoff():
|
||||
token = os.getenv('ADMIN_TOKEN')
|
||||
if token and request.headers.get('X-Admin-Token') != token:
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
cfg = load_config()
|
||||
ct = cfg.get("cutoff_time")
|
||||
ch = cfg.get("cutoff_hour", 23)
|
||||
cm = 0
|
||||
if isinstance(ct, str) and re.match(r'^\d{1,2}:\d{2}$', ct):
|
||||
try:
|
||||
p = ct.split(':')
|
||||
ch = int(p[0]); cm = int(p[1])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ch = int(ch)
|
||||
except Exception:
|
||||
ch = 23
|
||||
if ch < 0 or ch > 23:
|
||||
ch = 23
|
||||
if cm < 0 or cm > 59:
|
||||
cm = 0
|
||||
global scheduler
|
||||
if scheduler:
|
||||
try:
|
||||
scheduler.add_job(daily_job, "cron", hour=ch, minute=cm, id="daily", replace_existing=True)
|
||||
except Exception:
|
||||
pass
|
||||
settle_today_if_due()
|
||||
return jsonify({"ok": True, "cutoff_time": f"{ch:02d}:{cm:02d}"})
|
||||
|
||||
@app.route("/api/revenue")
|
||||
def api_revenue():
|
||||
"""查询历史营业额"""
|
||||
@@ -218,6 +461,35 @@ def api_revenue():
|
||||
rows = DailyRevenue.query.filter(DailyRevenue.date >= start).order_by(DailyRevenue.date.desc()).all()
|
||||
return jsonify([{"date": r.date.isoformat(), "amount": r.amount} for r in rows])
|
||||
|
||||
@app.route("/api/audit")
|
||||
def api_audit():
|
||||
limit = int(request.args.get("limit", 20))
|
||||
rows = AuditLog.query.order_by(AuditLog.created_at.desc()).limit(limit).all()
|
||||
return jsonify([
|
||||
{
|
||||
"date": r.date.isoformat(),
|
||||
"old_amount": r.old_amount,
|
||||
"new_amount": r.new_amount,
|
||||
"reason": r.reason,
|
||||
"actor": r.actor,
|
||||
"type": r.type,
|
||||
"created_at": r.created_at.isoformat(timespec='seconds')
|
||||
} for r in rows
|
||||
])
|
||||
|
||||
@app.route("/api/health")
|
||||
def api_health():
|
||||
cfg = load_config()
|
||||
cutoff = int(cfg.get("cutoff_hour", 23))
|
||||
today_local = datetime.now().date()
|
||||
r_today = DailyRevenue.query.filter_by(date=today_local).first()
|
||||
return jsonify({
|
||||
"server_now": datetime.now().isoformat(timespec='seconds'),
|
||||
"cutoff_hour": cutoff,
|
||||
"today_finalized": bool(r_today and r_today.is_final),
|
||||
"today_amount": (r_today.amount if r_today else None)
|
||||
})
|
||||
|
||||
@app.route("/api/export")
|
||||
def api_export():
|
||||
"""CSV导出"""
|
||||
@@ -256,12 +528,97 @@ def admin_correct():
|
||||
db.session.add(AuditLog(date=d, old_amount=old, new_amount=new_amt, reason=reason, actor=actor, type='correct'))
|
||||
db.session.commit()
|
||||
_append_log_line(d.isoformat(), new_amt, load_config().get('shop_name', '益选便利店'))
|
||||
push_feishu(d.isoformat(), new_amt, reason)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
@app.route('/api/admin/test_push', methods=['POST'])
|
||||
def admin_test_push():
|
||||
token = os.getenv('ADMIN_TOKEN')
|
||||
if token and request.headers.get('X-Admin-Token') != token:
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
payload = request.get_json(silent=True) or {}
|
||||
ds = payload.get('date') or datetime.now().date().isoformat()
|
||||
amt = float(payload.get('amount') or 1234.56)
|
||||
reason = payload.get('reason') or 'manual_test'
|
||||
push_feishu(ds, amt, reason)
|
||||
return jsonify({"ok": True, "pushed": {"date": ds, "amount": amt, "reason": reason}})
|
||||
|
||||
def import_csv_text(text: str, actor: str = 'admin'):
|
||||
buf = io.StringIO(text)
|
||||
reader = csv.reader(buf)
|
||||
imported = 0
|
||||
now = datetime.now().date()
|
||||
for row in reader:
|
||||
if not row:
|
||||
continue
|
||||
if row[0] == 'date':
|
||||
continue
|
||||
try:
|
||||
d = datetime.strptime(row[0].strip(), '%Y-%m-%d').date()
|
||||
if d > now:
|
||||
continue
|
||||
amt = float(row[1].strip())
|
||||
except Exception:
|
||||
continue
|
||||
r = DailyRevenue.query.filter_by(date=d).first()
|
||||
old = r.amount if r else None
|
||||
if r:
|
||||
r.amount = amt
|
||||
r.is_final = True
|
||||
r.source = 'import_csv'
|
||||
else:
|
||||
r = DailyRevenue(date=d, amount=amt, is_final=True, source='import_csv')
|
||||
db.session.add(r)
|
||||
db.session.add(AuditLog(date=d, old_amount=old, new_amount=amt, reason='import_csv', actor=actor, type='import_log'))
|
||||
push_feishu(d.isoformat(), amt, 'import_csv')
|
||||
imported += 1
|
||||
db.session.commit()
|
||||
return imported
|
||||
|
||||
@app.route('/api/admin/import', methods=['POST'])
|
||||
def admin_import():
|
||||
token = os.getenv('ADMIN_TOKEN')
|
||||
if token and request.headers.get('X-Admin-Token') != token:
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
raw = request.get_data(as_text=True)
|
||||
if not raw:
|
||||
return jsonify({"error": "empty"}), 400
|
||||
imported = import_csv_text(raw, actor='admin')
|
||||
return jsonify({"ok": True, "imported": imported})
|
||||
|
||||
@app.route('/api/admin/logs', methods=['GET'])
|
||||
def admin_logs():
|
||||
token = os.getenv('ADMIN_TOKEN')
|
||||
if token and request.headers.get('X-Admin-Token') != token:
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
n = request.args.get('lines', default=200, type=int)
|
||||
p = os.path.join(os.path.dirname(__file__), "..", "app.log")
|
||||
if not os.path.exists(p):
|
||||
return jsonify({"lines": []})
|
||||
try:
|
||||
with open(p, 'r', encoding='utf-8') as f:
|
||||
lines = f.readlines()
|
||||
tail = lines[-n:] if n > 0 else lines
|
||||
return jsonify({"lines": [line.rstrip('\n') for line in tail]})
|
||||
except Exception:
|
||||
return jsonify({"lines": []})
|
||||
|
||||
def auto_import_csv_on_start():
|
||||
with app.app_context():
|
||||
flag = os.getenv('AUTO_IMPORT_ON_START', '1')
|
||||
if str(flag) == '0':
|
||||
return
|
||||
p = os.path.join("/app", "data", "import.csv")
|
||||
if os.path.exists(p):
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
import_csv_text(text, actor='bootstrap')
|
||||
|
||||
def sync_log_to_db():
|
||||
"""启动时将 app.log 中缺失的数据同步到 DB(只同步今天之前)"""
|
||||
log_map = parse_app_log()
|
||||
today_local = datetime.now().date()
|
||||
local_tz = get_localzone()
|
||||
today_local = datetime.now(local_tz).date()
|
||||
for ds, amt in log_map.items():
|
||||
d = datetime.strptime(ds, '%Y-%m-%d').date()
|
||||
if d >= today_local:
|
||||
@@ -275,20 +632,43 @@ def sync_log_to_db():
|
||||
if __name__ == "__main__":
|
||||
local_tz = get_localzone()
|
||||
scheduler = BackgroundScheduler(timezone=local_tz)
|
||||
scheduler.add_job(daily_job, "cron", hour=23, minute=0)
|
||||
cfg = load_config()
|
||||
ct = cfg.get("cutoff_time")
|
||||
ch = cfg.get("cutoff_hour", 23)
|
||||
cm = 0
|
||||
if isinstance(ct, str) and re.match(r'^\d{1,2}:\d{2}$', ct):
|
||||
try:
|
||||
p = ct.split(':')
|
||||
ch = int(p[0]); cm = int(p[1])
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
ch = int(ch)
|
||||
except Exception:
|
||||
ch = 23
|
||||
if ch < 0 or ch > 23:
|
||||
ch = 23
|
||||
if cm < 0 or cm > 59:
|
||||
cm = 0
|
||||
scheduler.add_job(daily_job, "cron", hour=ch, minute=cm, id="daily", replace_existing=True)
|
||||
scheduler.start()
|
||||
with app.app_context():
|
||||
sync_log_to_db()
|
||||
auto_import_csv_on_start()
|
||||
settle_past_days()
|
||||
settle_today_if_due()
|
||||
app.run(host="0.0.0.0", port=int(os.getenv("PORT", "5000")))
|
||||
|
||||
@app.route('/api/events')
|
||||
def sse_events():
|
||||
def event_stream():
|
||||
while True:
|
||||
now = datetime.now()
|
||||
local_tz = get_localzone()
|
||||
now = datetime.now(local_tz)
|
||||
payload = {"type": "tick", "server_now": now.isoformat(timespec='seconds')}
|
||||
yield f"data: {json.dumps(payload)}\n\n"
|
||||
if now.hour == 23 and now.minute == 1:
|
||||
if now.minute in (0, 1):
|
||||
settle_today_if_due()
|
||||
yield "data: {\"type\": \"force_refresh\"}\n\n"
|
||||
time.sleep(30)
|
||||
return Response(stream_with_context(event_stream()), mimetype='text/event-stream')
|
||||
|
||||
@@ -5,3 +5,4 @@ APScheduler==3.10.4
|
||||
python-dotenv==1.0.0
|
||||
tzlocal==5.2
|
||||
tzdata==2025.1
|
||||
requests==2.32.3
|
||||
|
||||
+6
-2
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"webhook_url": "https://api.hiflow.tencent.com/engine/webhook/31/1869391857524076545",
|
||||
"feishu_webhook_url": "https://open.feishu.cn/open-apis/bot/v2/hook/2ee06084-a0ed-4c3f-9b0b-e264194f7ddd",
|
||||
"feishu_secret": "",
|
||||
"shop_name": "益选便利店",
|
||||
"weekday_range": [
|
||||
2900,
|
||||
@@ -8,5 +10,7 @@
|
||||
"weekend_range": [
|
||||
1600,
|
||||
2000
|
||||
]
|
||||
}
|
||||
],
|
||||
"cutoff_hour": 23,
|
||||
"cutoff_time": "23:00"
|
||||
}
|
||||
|
||||
Binary file not shown.
+4
-2
@@ -5,10 +5,12 @@ services:
|
||||
ports:
|
||||
- "57778:57778"
|
||||
environment:
|
||||
- DATABASE_URL=sqlite:///data/data.db
|
||||
- DATABASE_URL=sqlite:////app/data/data.db
|
||||
- TZ=Asia/Shanghai
|
||||
- ADMIN_TOKEN=${ADMIN_TOKEN}
|
||||
- PORT=57778
|
||||
- AUTO_IMPORT_ON_START=1
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./data:/app/data:Z
|
||||
- ./config.json:/app/config.json:ro
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>项目 API 文档(益选营业额系统)</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica, Arial, "Noto Sans", "Noto Sans CJK", "PingFang SC", "Microsoft YaHei", sans-serif; margin: 40px; line-height: 1.6; color: #222; }
|
||||
h1 { font-size: 24px; margin: 0 0 16px; }
|
||||
h2 { font-size: 18px; margin: 24px 0 8px; }
|
||||
code, pre { background: #f6f8fa; border-radius: 6px; }
|
||||
code { padding: 2px 4px; }
|
||||
pre { padding: 12px; overflow: auto; }
|
||||
.endpoint { border-left: 4px solid #3b82f6; padding-left: 12px; margin: 16px 0; }
|
||||
.small { color: #555; font-size: 13px; }
|
||||
ul { padding-left: 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>项目 API 文档(益选营业额系统)</h1>
|
||||
<p class="small">基础地址:<code>http://<服务器或域名>:<PORT></code>(默认 <code>57778</code>) | 本地时区:<code>Asia/Shanghai</code></p>
|
||||
<h2>认证</h2>
|
||||
<ul>
|
||||
<li>管理接口需在请求头携带 <code>X-Admin-Token: <ADMIN_TOKEN></code></li>
|
||||
</ul>
|
||||
<h2>公共字段</h2>
|
||||
<ul>
|
||||
<li><code>server_now</code>:服务器当前时间(含时区)</li>
|
||||
<li><code>cutoff_time</code>/<code>cutoff_hour</code>:截止时间;未到门槛时 <code>today.amount=null</code></li>
|
||||
<li><code>is_final</code>:某日期是否已定版</li>
|
||||
</ul>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2>GET /api/metrics</h2>
|
||||
<p>指标看板:今日/昨日/前日、本周、上周、本月;达到截止时间且今日已定版时显示金额。</p>
|
||||
<pre>{
|
||||
"shop_name": "益选便利店",
|
||||
"server_now": "2025-12-09T13:14:40+08:00",
|
||||
"cutoff_hour": 13,
|
||||
"cutoff_time": "13:18",
|
||||
"today": { "date": "2025-12-09", "weekday": "周二", "amount": null },
|
||||
"yesterday": { "date": "2025-12-08", "amount": 3629.76 },
|
||||
"day_before": { "date": "2025-12-07", "amount": 2408.70 },
|
||||
"this_week": { "start": "2025-12-08", "end": "2025-12-08", "total": 3629.76 },
|
||||
"last_week": { "start": "2025-12-01", "end": "2025-12-07", "total": 4211.79 },
|
||||
"this_month": { "start": "2025-12-01", "end": "2025-12-08", "total": 7841.55 }
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2>GET /api/series7</h2>
|
||||
<p>最近 N 天序列(默认 7,范围 7~90)。未到截止或今日未定版时,截止到昨日;未定版日期按区间估算并标记 <code>estimated=true</code>。</p>
|
||||
<pre>[ { "date": "2025-12-06", "amount": 1803.09, "estimated": false }, ... ]</pre>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2>GET /api/revenue</h2>
|
||||
<p>历史营业额查询(仅定版数据)。参数:<code>days</code> 默认 30。</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2>GET /api/audit</h2>
|
||||
<p>审计日志。参数:<code>days</code> 默认 30。</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2>GET /api/health</h2>
|
||||
<p>健康检查。</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2>GET /api/export</h2>
|
||||
<p>导出 CSV(仅定版)。</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2>GET /</h2>
|
||||
<p>看板入口(静态)。</p>
|
||||
<h2>GET /admin</h2>
|
||||
<p>管理入口(静态)。</p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2>PUT /api/admin/turnover</h2>
|
||||
<p>修正某日营业额(置为定版并推送)。头:<code>X-Admin-Token</code></p>
|
||||
<pre>{ "date":"YYYY-MM-DD", "amount":1234.56, "reason":"调整入账", "actor":"admin" }</pre>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2>POST /api/admin/test_push</h2>
|
||||
<p>试发飞书(卡片→帖子→文本)。头:<code>X-Admin-Token</code></p>
|
||||
<pre>{ "date":"YYYY-MM-DD", "amount":1234.56, "reason":"manual_test" }</pre>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2>POST /api/admin/import</h2>
|
||||
<p>批量导入 CSV(两列:<code>date,amount</code>)。头:<code>X-Admin-Token</code>,<code>Content-Type: text/csv</code></p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2>GET /api/admin/logs</h2>
|
||||
<p>读取 <code>app.log</code> 最近 N 行。参数:<code>lines</code>;头:<code>X-Admin-Token</code></p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2>POST /api/admin/reload_cutoff</h2>
|
||||
<p>在线重载截止时间(更新 APScheduler 的每日触发任务并立即执行一次结算检查)。头:<code>X-Admin-Token</code></p>
|
||||
</div>
|
||||
|
||||
<div class="endpoint">
|
||||
<h2>GET /api/events</h2>
|
||||
<p>SSE 心跳事件(每 30 秒;在每小时第 0/1 分触发强制刷新与结算检查)。</p>
|
||||
</div>
|
||||
|
||||
<h2>打印为 PDF</h2>
|
||||
<ul>
|
||||
<li>在浏览器中打开本文件,使用“打印”选择“另存为 PDF”。</li>
|
||||
<li>或将此文件纳入归档(与 <code>API.md</code> 同内容)。</li>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,151 @@
|
||||
# 项目 API 文档(益选营业额系统)
|
||||
|
||||
## 概述
|
||||
- 基础地址:`http://<服务器或域名>:<PORT>`(默认 `57778`,本地开发默认 `5000`)
|
||||
- 返回格式:`application/json`
|
||||
- 本地时区:`Asia/Shanghai`,时间字段按本地时区输出
|
||||
- 截止时间:支持分钟级配置(`config.json` 中 `cutoff_time`,例如 `13:18`;未设置时使用 `cutoff_hour`,分钟视为 `00`)
|
||||
|
||||
## 认证
|
||||
- 管理接口需在请求头携带 `X-Admin-Token: <ADMIN_TOKEN>`(容器环境变量或 `.env` 配置)
|
||||
|
||||
## 公共字段说明
|
||||
- `server_now`:服务器当前时间 ISO 字符串(含时区)
|
||||
- `cutoff_time` / `cutoff_hour`:当天营业额结算门槛;未到门槛时 `today.amount=null`
|
||||
- `is_final`:某日期是否已定版(仅定版数据会计入汇总)
|
||||
|
||||
## 接口列表
|
||||
|
||||
### 1. 指标看板
|
||||
- 路径:`GET /api/metrics`
|
||||
- 说明:返回今日/昨日/前日、本周、上周、本月汇总数据;`today.amount` 仅在达到截止时间且当日已定版时显示数值
|
||||
- 示例响应:
|
||||
```json
|
||||
{
|
||||
"shop_name": "益选便利店",
|
||||
"server_now": "2025-12-09T13:14:40+08:00",
|
||||
"cutoff_hour": 13,
|
||||
"cutoff_time": "13:18",
|
||||
"today": { "date": "2025-12-09", "weekday": "周二", "amount": null },
|
||||
"yesterday": { "date": "2025-12-08", "amount": 3629.76 },
|
||||
"day_before": { "date": "2025-12-07", "amount": 2408.70 },
|
||||
"this_week": { "start": "2025-12-08", "end": "2025-12-08", "total": 3629.76 },
|
||||
"last_week": { "start": "2025-12-01", "end": "2025-12-07", "total": 4211.79 },
|
||||
"this_month": { "start": "2025-12-01", "end": "2025-12-08", "total": 7841.55 }
|
||||
}
|
||||
```
|
||||
- 代码位置:`backend/app.py:225`
|
||||
|
||||
### 2. 最近 7 天序列
|
||||
- 路径:`GET /api/series7`
|
||||
- 参数:`days`(可选,默认 7,范围 7~90)
|
||||
- 说明:返回最近 N 天每日营业额;在未到截止或今日未定版时,序列截止到昨日;未定版日期按区间估算并标记 `estimated=true`
|
||||
- 示例响应:
|
||||
```json
|
||||
[
|
||||
{ "date": "2025-12-06", "amount": 1803.09, "estimated": false },
|
||||
{ "date": "2025-12-07", "amount": 2408.70, "estimated": false },
|
||||
{ "date": "2025-12-08", "amount": 3629.76, "estimated": false }
|
||||
]
|
||||
```
|
||||
- 代码位置:`backend/app.py:287`
|
||||
|
||||
### 3. 历史营业额查询
|
||||
- 路径:`GET /api/revenue`
|
||||
- 参数:`days`(可选,默认 30)
|
||||
- 说明:查询最近 N 天的历史营业额(仅定版数据)
|
||||
- 代码位置:`backend/app.py:365`
|
||||
|
||||
### 4. 审计日志
|
||||
- 路径:`GET /api/audit`
|
||||
- 参数:`days`(可选,默认 30)
|
||||
- 说明:返回最近 N 天的审计记录(生成、修正、导入等)
|
||||
- 代码位置:`backend/app.py:373`
|
||||
|
||||
### 5. 健康检查
|
||||
- 路径:`GET /api/health`
|
||||
- 说明:服务健康状态(数据库、调度器、时区)
|
||||
- 代码位置:`backend/app.py:389`
|
||||
|
||||
### 6. 导出 CSV
|
||||
- 路径:`GET /api/export`
|
||||
- 说明:导出已定版营业额为 CSV 文本(`date,amount`)
|
||||
- 代码位置:`backend/app.py:402`
|
||||
|
||||
### 7. 单页入口
|
||||
- 路径:`GET /`、`GET /admin`
|
||||
- 说明:前端看板与管理页入口(静态文件)
|
||||
- 代码位置:`backend/app.py:409`、`backend/app.py:414`
|
||||
|
||||
### 8. 管理:修正某日营业额
|
||||
- 路径:`PUT /api/admin/turnover`
|
||||
- 认证:`X-Admin-Token`
|
||||
- 请求体:`{"date":"YYYY-MM-DD","amount":1234.56,"reason":"调整入账","actor":"admin"}`
|
||||
- 说明:修正指定日期金额并置为定版;写入审计日志并推送飞书
|
||||
- 示例响应:`{"ok":true}`
|
||||
- 代码位置:`backend/app.py:418`
|
||||
|
||||
### 9. 管理:试发飞书
|
||||
- 路径:`POST /api/admin/test_push`
|
||||
- 认证:`X-Admin-Token`
|
||||
- 请求体(可选):`{"date":"YYYY-MM-DD","amount":1234.56,"reason":"manual_test"}`
|
||||
- 说明:向已配置的飞书机器人发送一条测试消息(卡片→帖子→文本兼容)
|
||||
- 示例响应:`{"ok":true,"pushed":{"date":"...","amount":1234.56,"reason":"..."}}`
|
||||
- 代码位置:`backend/app.py:443`
|
||||
|
||||
### 10. 管理:批量导入 CSV
|
||||
- 路径:`POST /api/admin/import`
|
||||
- 认证:`X-Admin-Token`
|
||||
- 请求头:`Content-Type: text/csv`
|
||||
- 请求体:CSV 内容(两列:`date,amount`)
|
||||
- 说明:批量导入历史数据,均置为定版并记录审计;逐条尝试推送飞书
|
||||
- 示例响应:`{"ok":true,"imported":N}`
|
||||
- 代码位置:`backend/app.py:487`
|
||||
|
||||
### 11. 管理:读取服务器日志
|
||||
- 路径:`GET /api/admin/logs?lines=200`
|
||||
- 认证:`X-Admin-Token`
|
||||
- 说明:读取 `app.log` 最近 N 行(含飞书推送状态与返回体片段)
|
||||
- 示例响应:`{"lines":["飞书推送卡片成功: status=200 body=...", ...]}`
|
||||
- 代码位置:`backend/app.py:498`
|
||||
|
||||
### 12. 管理:在线重载截止时间
|
||||
- 路径:`POST /api/admin/reload_cutoff`
|
||||
- 认证:`X-Admin-Token`
|
||||
- 说明:重新读取 `config.json` 的 `cutoff_time`/`cutoff_hour`,用新值更新 APScheduler 的每日触发任务,并立即执行一次结算检查
|
||||
- 示例响应:`{"ok":true,"cutoff_time":"13:18"}`
|
||||
- 代码位置:`backend/app.py:333`
|
||||
|
||||
### 13. 服务器事件(SSE)
|
||||
- 路径:`GET /api/events`
|
||||
- 说明:服务端推送事件(每 30 秒心跳一次;在每小时第 0/1 分会强制刷新并调用结算检查)
|
||||
- 事件示例:
|
||||
- `data: {"type":"tick","server_now":"2025-12-09T13:14:40+08:00"}`
|
||||
- `data: {"type":"force_refresh"}`(在 0/1 分触发)
|
||||
- 代码位置:`backend/app.py:571`
|
||||
|
||||
## 触发与结算逻辑(参考)
|
||||
- 结算判断:本地时区 `now >= cutoff_time` 时尝试 `daily_job()`(`backend/app.py:128-149`)
|
||||
- APScheduler:按 `hour:minute` 注册每日触发任务(`backend/app.py:490-511`)
|
||||
- 每日作业:
|
||||
- 若当天无记录:生成金额 → 写库 → 审计 → 推送飞书
|
||||
- 若当天已存在但未定版:置为定版 → 审计 → 推送飞书
|
||||
- 若当天已存在且已定版:推送飞书(保底通知)
|
||||
- 代码位置:`backend/app.py:101-126`
|
||||
|
||||
## 配置文件(关键字段)
|
||||
- 路径:`/app/config.json`(容器内;通过 `docker-compose.yml` 映射宿主机 `./config.json`)
|
||||
- 字段:
|
||||
- `feishu_webhook_url`:飞书机器人 Webhook(必填)
|
||||
- `feishu_secret`:签名密钥(启用签名时填写,否则留空)
|
||||
- `shop_name`:店名
|
||||
- `weekday_range` / `weekend_range`:金额生成区间
|
||||
- `cutoff_hour` / `cutoff_time`:截止时间(小时或分钟级)
|
||||
|
||||
## 环境变量
|
||||
- `ADMIN_TOKEN`:管理口令(用于管理接口)
|
||||
- `DATABASE_URL`:数据库连接,默认 `sqlite:////app/data/data.db`(容器),本地默认 `sqlite:///data/data.db`
|
||||
- `TZ`:容器时区(默认 `Asia/Shanghai`)
|
||||
- `PORT`:服务端口(示例 `57778`)
|
||||
- `AUTO_IMPORT_ON_START`:启动时自动导入 CSV(`/app/data/import.csv`)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"info": {
|
||||
"name": "PushToZhaoShang API",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "Metrics",
|
||||
"request": { "method": "GET", "url": "{{baseUrl}}/api/metrics" }
|
||||
},
|
||||
{
|
||||
"name": "Series7",
|
||||
"request": { "method": "GET", "url": "{{baseUrl}}/api/series7?days=7" }
|
||||
},
|
||||
{
|
||||
"name": "Revenue",
|
||||
"request": { "method": "GET", "url": "{{baseUrl}}/api/revenue?days=30" }
|
||||
},
|
||||
{
|
||||
"name": "Audit",
|
||||
"request": { "method": "GET", "url": "{{baseUrl}}/api/audit?days=30" }
|
||||
},
|
||||
{
|
||||
"name": "Health",
|
||||
"request": { "method": "GET", "url": "{{baseUrl}}/api/health" }
|
||||
},
|
||||
{
|
||||
"name": "Export CSV",
|
||||
"request": { "method": "GET", "url": "{{baseUrl}}/api/export" }
|
||||
},
|
||||
{
|
||||
"name": "Root",
|
||||
"request": { "method": "GET", "url": "{{baseUrl}}/" }
|
||||
},
|
||||
{
|
||||
"name": "Admin Page",
|
||||
"request": { "method": "GET", "url": "{{baseUrl}}/admin" }
|
||||
},
|
||||
{
|
||||
"name": "Admin Turnover",
|
||||
"request": {
|
||||
"method": "PUT",
|
||||
"header": [ { "key": "X-Admin-Token", "value": "{{adminToken}}" }, { "key": "Content-Type", "value": "application/json" } ],
|
||||
"body": { "mode": "raw", "raw": "{\n \"date\": \"2025-12-06\",\n \"amount\": 3123.45,\n \"reason\": \"调整入账\",\n \"actor\": \"admin\"\n}" },
|
||||
"url": "{{baseUrl}}/api/admin/turnover"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Admin Test Push",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [ { "key": "X-Admin-Token", "value": "{{adminToken}}" }, { "key": "Content-Type", "value": "application/json" } ],
|
||||
"body": { "mode": "raw", "raw": "{\n \"date\": \"2025-12-09\",\n \"amount\": 1234.56,\n \"reason\": \"manual_test\"\n}" },
|
||||
"url": "{{baseUrl}}/api/admin/test_push"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Admin Import CSV",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [ { "key": "X-Admin-Token", "value": "{{adminToken}}" }, { "key": "Content-Type", "value": "text/csv" } ],
|
||||
"body": { "mode": "raw", "raw": "date,amount\n2025-12-01,12345.67\n2025-12-02,11890.12\n" },
|
||||
"url": "{{baseUrl}}/api/admin/import"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Admin Logs",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [ { "key": "X-Admin-Token", "value": "{{adminToken}}" } ],
|
||||
"url": "{{baseUrl}}/api/admin/logs?lines=200"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Admin Reload Cutoff",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [ { "key": "X-Admin-Token", "value": "{{adminToken}}" } ],
|
||||
"url": "{{baseUrl}}/api/admin/reload_cutoff"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"id": "f6c3b3a1-3c2d-4b62-bc35-0c6ef873a001",
|
||||
"name": "PushToZhaoShang-Env",
|
||||
"values": [
|
||||
{ "key": "baseUrl", "value": "http://localhost:57778", "enabled": true },
|
||||
{ "key": "adminToken", "value": "", "enabled": true }
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
param(
|
||||
[string]$RepoUrl = "",
|
||||
[string]$Branch = "main",
|
||||
[string]$RepoDir = ".",
|
||||
[string]$ComposeFile = "docker-compose.yml",
|
||||
[string]$BaseUrl = "http://localhost:57778",
|
||||
[string]$AdminToken = ""
|
||||
)
|
||||
if (!(Test-Path $RepoDir)) { New-Item -ItemType Directory -Path $RepoDir | Out-Null }
|
||||
if (!(Test-Path (Join-Path $RepoDir ".git"))) {
|
||||
if ($RepoUrl -eq "") { throw "RepoUrl is required for clone" }
|
||||
git clone -b $Branch $RepoUrl $RepoDir
|
||||
}
|
||||
Set-Location $RepoDir
|
||||
git remote set-url origin $RepoUrl
|
||||
git fetch origin
|
||||
git checkout $Branch
|
||||
git pull origin $Branch
|
||||
if (!(Test-Path $ComposeFile)) { throw "compose not found: $ComposeFile" }
|
||||
docker compose -f $ComposeFile up -d --build
|
||||
$headers = @{}
|
||||
if ($AdminToken -ne "") { $headers["X-Admin-Token"] = $AdminToken }
|
||||
try { Invoke-RestMethod -Method Post -Uri "$BaseUrl/api/admin/reload_cutoff" -Headers $headers | Out-Null } catch { }
|
||||
try { Invoke-RestMethod -Method Get -Uri "$BaseUrl/api/metrics" | ConvertTo-Json -Depth 3 | Write-Output } catch { }
|
||||
try { Invoke-RestMethod -Method Post -Uri "$BaseUrl/api/admin/test_push" -Headers $headers | ConvertTo-Json -Depth 3 | Write-Output } catch { }
|
||||
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
REPO_URL=${1:-}
|
||||
BRANCH=${2:-main}
|
||||
REPO_DIR=${3:-.}
|
||||
COMPOSE_FILE=${4:-docker-compose.yml}
|
||||
BASE_URL=${5:-http://localhost:57778}
|
||||
ADMIN_TOKEN=${6:-}
|
||||
mkdir -p "$REPO_DIR"
|
||||
if [ ! -d "$REPO_DIR/.git" ]; then
|
||||
[ -z "$REPO_URL" ] && { echo "RepoUrl required"; exit 1; }
|
||||
git clone -b "$BRANCH" "$REPO_URL" "$REPO_DIR"
|
||||
fi
|
||||
cd "$REPO_DIR"
|
||||
git remote set-url origin "$REPO_URL"
|
||||
git fetch origin
|
||||
git checkout "$BRANCH"
|
||||
git pull origin "$BRANCH"
|
||||
[ -f "$COMPOSE_FILE" ] || { echo "compose not found: $COMPOSE_FILE"; exit 1; }
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build
|
||||
HDR=( )
|
||||
if [ -n "$ADMIN_TOKEN" ]; then HDR=( -H "X-Admin-Token: $ADMIN_TOKEN" ); fi
|
||||
curl -s -X POST "${BASE_URL}/api/admin/reload_cutoff" "${HDR[@]}" || true
|
||||
curl -s "${BASE_URL}/api/metrics" || true
|
||||
curl -s -X POST "${BASE_URL}/api/admin/test_push" "${HDR[@]}" || true
|
||||
@@ -0,0 +1,62 @@
|
||||
import os
|
||||
import sqlite3
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
def _resolve_sqlite_path(url: str) -> str:
|
||||
if not url.startswith('sqlite:///'):
|
||||
raise ValueError('Only sqlite URLs are supported')
|
||||
return url.replace('sqlite:///', '')
|
||||
|
||||
def ensure_tables(conn: sqlite3.Connection) -> None:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS daily_revenue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date DATE NOT NULL UNIQUE,
|
||||
amount REAL NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
is_final INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
source TEXT,
|
||||
note TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date DATE NOT NULL,
|
||||
old_amount REAL,
|
||||
new_amount REAL,
|
||||
reason TEXT,
|
||||
actor TEXT,
|
||||
type TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def set_yesterday(amount: float) -> None:
|
||||
url = os.getenv('DATABASE_URL', 'sqlite:///data.db')
|
||||
path = _resolve_sqlite_path(url)
|
||||
os.makedirs(os.path.dirname(path) or '.', exist_ok=True)
|
||||
conn = sqlite3.connect(path)
|
||||
ensure_tables(conn)
|
||||
cur = conn.cursor()
|
||||
yday = (datetime.now().date() - timedelta(days=1)).isoformat()
|
||||
cur.execute('SELECT amount FROM daily_revenue WHERE date=?', (yday,))
|
||||
row = cur.fetchone()
|
||||
old = row[0] if row else None
|
||||
if row:
|
||||
cur.execute('UPDATE daily_revenue SET amount=?, is_final=1, source=? , updated_at=CURRENT_TIMESTAMP WHERE date=?', (amount, 'correct', yday))
|
||||
else:
|
||||
cur.execute('INSERT INTO daily_revenue(date, amount, is_final, source) VALUES(?, ?, 1, ?)', (yday, amount, 'correct'))
|
||||
cur.execute('INSERT INTO audit_log(date, old_amount, new_amount, reason, actor, type) VALUES(?, ?, ?, ?, ?, ?)', (yday, old, amount, 'manual_correct', 'admin', 'correct'))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if __name__ == '__main__':
|
||||
set_yesterday(3629.76)
|
||||
Reference in New Issue
Block a user