v1.19 全面改版:VIP会员系统、我的收益、创业老板排行、阅读量排序

- 后端: users表新增VIP字段, 4个VIP API (purchase/status/profile/members)
- 后端: hot接口改按user_tracks阅读量排序
- 后端: orders表支持vip产品类型, migrate新增vip_fields迁移
- 小程序「我的」: 推广中心改为我的收益, 头像VIP标识, VIP入口卡片
- 小程序「我的」: 最近阅读显示真实章节名称
- 小程序首页: 去掉内容概览, 新增创业老板排行(4列网格)
- 小程序首页: 精选推荐从hot接口获取, goToRead增加track记录
- 新增页面: VIP详情页, 会员详情页
- 开发文档精简为10个标准目录, 创建SKILL.md, 需求日志规范化

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
卡若
2026-02-23 14:07:41 +08:00
parent e91a5d9f7a
commit afc2376e96
49 changed files with 1898 additions and 561 deletions

View File

@@ -0,0 +1,4 @@
# 飞书应用凭证,用于上传海报图片到飞书群
# 复制为 .env.feishu 并填写(需与 webhook 同租户的应用)
FEISHU_APP_ID=your_app_id
FEISHU_APP_SECRET=your_app_secret

View File

@@ -21,6 +21,14 @@ mkdir -p "$EXT_DIR"
API_CONF="$EXT_DIR/api-proxy.conf"
cat > "$API_CONF" << 'NGX'
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, DELETE, OPTIONS";
add_header Access-Control-Allow-Headers "Authorization, Content-Type";
add_header Access-Control-Allow-Credentials "true";
add_header Content-Length 0;
return 204;
}
proxy_pass https://souldev.quwanzhi.com/api/;
proxy_ssl_server_name on;
proxy_http_version 1.1;

View File

@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""
Soul 文章海报发飞书:生成海报图片(含小程序码)并发送,不发链接
流程:解析文章 → 调 Soul API 获取小程序码 → 合成海报图 → 上传飞书 → 以图片形式发送
用法:
python3 scripts/send_poster_to_feishu.py <文章md路径>
python3 scripts/send_poster_to_feishu.py --id 9.15
环境: pip install Pillow requests
飞书图片上传需配置: FEISHU_APP_ID, FEISHU_APP_SECRET与 webhook 同租户的飞书应用)
"""
import argparse
import base64
import io
import json
import os
from pathlib import Path
# 可选:从脚本同目录 .env.feishu 加载 FEISHU_APP_ID, FEISHU_APP_SECRET
_env = Path(__file__).resolve().parent / ".env.feishu"
if _env.exists():
for line in _env.read_text().strip().split("\n"):
if "=" in line and not line.strip().startswith("#"):
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip().strip('"').strip("'"))
import os
import re
import sys
import tempfile
from pathlib import Path
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
FEISHU_WEBHOOK = "https://open.feishu.cn/open-apis/bot/v2/hook/8b7f996e-2892-4075-989f-aa5593ea4fbc"
SOUL_API = "https://soul.quwanzhi.com/api/miniprogram/qrcode"
POSTER_W, POSTER_H = 600, 900 # 2x 小程序 300x450
def parse_article(filepath: Path) -> dict:
"""从 md 解析标题、金句、日期、section id"""
text = filepath.read_text(encoding="utf-8")
lines = [l.strip() for l in text.split("\n")]
title = ""
date_line = ""
quote = ""
section_id = ""
for line in lines:
if line.startswith("# "):
title = line.lstrip("# ").strip()
m = re.match(r"^(\d+\.\d+)\s", title)
if m:
section_id = m.group(1)
elif re.match(r"^\d{4}\d{1,2}月\d{1,2}日", line):
date_line = line
elif title and not quote and line and not line.startswith("-") and "---" not in line:
if 10 <= len(line) <= 120:
quote = line
if not section_id and filepath.stem:
m = re.match(r"^(\d+\.\d+)", filepath.stem)
if m:
section_id = m.group(1)
return {
"title": title or filepath.stem,
"quote": quote or title or "来自 Soul 创业派对的真实故事",
"date": date_line,
"section_id": section_id or "9.1",
}
def fetch_qrcode(section_id: str) -> bytes | None:
"""从 Soul 后端获取小程序码图片"""
scene = f"id={section_id}"
body = json.dumps({"scene": scene, "page": "pages/read/read", "width": 280}).encode()
req = Request(SOUL_API, data=body, headers={"Content-Type": "application/json"}, method="POST")
try:
with urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode())
if not data.get("success") or not data.get("image"):
return None
b64 = data["image"].split(",", 1)[-1] if "," in data["image"] else data["image"]
return base64.b64decode(b64)
except Exception as e:
print("获取小程序码失败:", e)
return None
def draw_poster(data: dict, qr_bytes: bytes | None) -> bytes:
"""合成海报图,返回 PNG bytes"""
try:
from PIL import Image, ImageDraw, ImageFont
except ImportError:
print("需要安装 Pillow: pip install Pillow")
sys.exit(1)
img = Image.new("RGB", (POSTER_W, POSTER_H), color=(26, 26, 46))
draw = ImageDraw.Draw(img)
# 顶部装饰条
draw.rectangle([0, 0, POSTER_W, 8], fill=(0, 206, 209))
# 字体(系统 fallback
try:
font_sm = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 24)
font_md = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 32)
font_lg = ImageFont.truetype("/System/Library/Fonts/PingFang.ttc", 40)
except OSError:
font_sm = font_md = font_lg = ImageFont.load_default()
y = 50
draw.text((40, y), "Soul创业派对", fill=(255, 255, 255), font=font_sm)
y += 60
# 标题(多行)
title = data["title"]
for i in range(0, len(title), 18):
draw.text((40, y), title[i : i + 18], fill=(255, 255, 255), font=font_lg)
y += 50
y += 20
draw.line([(40, y), (POSTER_W - 40, y)], fill=(255, 255, 255, 40), width=1)
y += 40
# 金句
quote = data["quote"]
for i in range(0, min(len(quote), 80), 20):
draw.text((40, y), quote[i : i + 20], fill=(255, 255, 255, 200), font=font_md)
y += 40
if data["date"]:
draw.text((40, y), data["date"], fill=(255, 255, 255, 180), font=font_sm)
y += 40
y += 20
# 底部提示 + 小程序码
draw.text((40, POSTER_H - 120), "长按识别小程序码", fill=(255, 255, 255), font=font_sm)
draw.text((40, POSTER_H - 90), "阅读全文", fill=(255, 255, 255, 180), font=font_sm)
if qr_bytes:
qr_img = Image.open(io.BytesIO(qr_bytes))
qr_img = qr_img.resize((160, 160))
img.paste(qr_img, (POSTER_W - 200, POSTER_H - 180))
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def get_feishu_token() -> str | None:
app_id = os.environ.get("FEISHU_APP_ID")
app_secret = os.environ.get("FEISHU_APP_SECRET")
if not app_id or not app_secret:
return None
req = Request(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
data=json.dumps({"app_id": app_id, "app_secret": app_secret}).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(req, timeout=10) as resp:
data = json.loads(resp.read().decode())
return data.get("tenant_access_token")
except Exception:
return None
def upload_image_to_feishu(png_bytes: bytes, token: str) -> str | None:
"""上传图片到飞书,返回 image_key"""
try:
import requests
except ImportError:
print("需要安装: pip install requests")
return None
headers = {"Authorization": f"Bearer {token}"}
files = {"image": ("poster.png", png_bytes, "image/png")}
data = {"image_type": "message"}
try:
r = requests.post(
"https://open.feishu.cn/open-apis/im/v1/images",
headers=headers,
data=data,
files=files,
timeout=15,
)
j = r.json()
if j.get("code") == 0 and j.get("data", {}).get("image_key"):
return j["data"]["image_key"]
except Exception as e:
print("上传飞书失败:", e)
return None
def send_image_to_feishu(image_key: str) -> bool:
payload = {"msg_type": "image", "content": {"image_key": image_key}}
req = Request(
FEISHU_WEBHOOK,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urlopen(req, timeout=10) as resp:
r = json.loads(resp.read().decode())
return r.get("code") == 0
except Exception as e:
print("发送失败:", e)
return False
def main():
parser = argparse.ArgumentParser()
parser.add_argument("file", nargs="?", help="文章 md 路径")
parser.add_argument("--id", help="章节 id")
parser.add_argument("--save", help="仅保存海报到本地路径,不发飞书")
args = parser.parse_args()
base = Path("/Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/第四篇|真实的赚钱/第9章我在Soul上亲访的赚钱案例")
if args.id:
candidates = list(base.glob(f"{args.id}*.md"))
if not candidates:
print(f"未找到 id={args.id} 的文章")
sys.exit(1)
filepath = candidates[0]
elif args.file:
filepath = Path(args.file)
if not filepath.exists():
print("文件不存在:", filepath)
sys.exit(1)
else:
parser.print_help()
sys.exit(1)
data = parse_article(filepath)
print("生成海报:", data["title"])
qr_bytes = fetch_qrcode(data["section_id"])
png_bytes = draw_poster(data, qr_bytes)
if args.save:
Path(args.save).write_bytes(png_bytes)
print("已保存:", args.save)
return
token = get_feishu_token()
if not token:
out = Path(tempfile.gettempdir()) / f"soul_poster_{data['section_id']}.png"
out.write_bytes(png_bytes)
print("未配置 FEISHU_APP_ID / FEISHU_APP_SECRET无法上传。海报已保存到:", out)
sys.exit(1)
image_key = upload_image_to_feishu(png_bytes, token)
if not image_key:
print("上传图片失败")
sys.exit(1)
if send_image_to_feishu(image_key):
print("已发送到飞书(图片)")
else:
sys.exit(1)
if __name__ == "__main__":
main()

42
scripts/upload_soul_article.sh Executable file
View File

@@ -0,0 +1,42 @@
#!/bin/bash
# Soul 第9章文章上传脚本写好文章后直接上传id 已存在则更新(不重复)
# 用法: ./scripts/upload_soul_article.sh <文章md文件路径>
# 例: ./scripts/upload_soul_article.sh "/Users/karuo/Documents/个人/2、我写的书/《一场soul的创业实验》/第四篇|真实的赚钱/第9章我在Soul上亲访的赚钱案例/9.18 第105场创业社群、直播带货与程序员.md"
set -e
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
FILE="${1:?请提供文章 md 文件路径}"
if [[ ! -f "$FILE" ]]; then
echo "错误: 文件不存在: $FILE"
exit 1
fi
# 从文件名提取 id如 9.18 第105场xxx.md -> 9.18
BASENAME=$(basename "$FILE" .md)
ID=$(echo "$BASENAME" | sed -E 's/^([0-9]+\.[0-9]+).*/\1/')
if [[ ! "$ID" =~ ^[0-9]+\.[0-9]+$ ]]; then
echo "错误: 无法从文件名提取 id格式应为: 9.xx 第X场标题.md"
exit 1
fi
# 从第一行 # 9.xx 第X场标题 提取 title
TITLE=$(head -1 "$FILE" | sed 's/^# [[:space:]]*//')
if [[ -z "$TITLE" ]]; then
TITLE="$BASENAME"
fi
echo "上传: id=$ID title=$TITLE"
python3 "$ROOT/content_upload.py" \
--id "$ID" \
--title "$TITLE" \
--content-file "$FILE" \
--part part-4 \
--chapter chapter-9 \
--price 1.0
# 上传成功后,按海报格式发到飞书群
if [[ $? -eq 0 ]]; then
echo "发海报到飞书..."
python3 "$ROOT/scripts/send_poster_to_feishu.py" "$FILE" 2>/dev/null || true
fi