Files
server-core/app/config.py
T
Pine 4dedb43d7f 算力多级折扣与企业余额体系 - 服务端核心改造
数据模型:
- 新增 tenant_discounts(运营端载体折扣)
- 新增 tenant_user_discounts(载体端账号折扣)
- 新增 compute_recharges(算力充值记录)
- 新增 compute_balance_allocations(余额分配记录)
- 新增 compute_usage_records(算力使用记录)
- 扩展 park_tenants(default_company_discount, default_user_discount)
- 扩展 park_companies(compute_balance, compute_balance_used)
- 扩展 company_members(role, compute_balance, compute_balance_used, left_at, left_reason)

核心服务:
- compute_pricing_service.py:四级折扣价格计算、余额扣费、企业余额管理

API接口(/api/compute):
- 运营端:载体折扣管理、标准价管理
- 载体端:企业折扣设置、账号折扣设置、默认折扣设置
- 企业端:余额充值、成员余额分配/回收、充值/分配记录
- 个人端:我的余额、使用记录、退出企业
- 内部:价格查询、算力扣费

数据库迁移:0064_compute_discount_balance.py
2026-09-06 17:59:56 +08:00

213 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""全局配置:端口、数据目录、令牌有效期、认证开关。
所有路径/开关均可通过环境变量覆盖,便于部署和测试:
PINEAGENTS_DEMO_HOST / PINEAGENTS_DEMO_PORT 监听地址(默认 127.0.0.1:8090
PINEAGENTS_DEMO_DATA_DIR JSON 数据目录(默认 <项目根>/data
PINEAGENTS_DEMO_AUTH_ENABLED 认证开关(默认 true)
"""
from __future__ import annotations
import os
from pathlib import Path
# 启动第一步即加载项目根 .env(含 PINEAGENTS_INTERNAL_TOKEN / DATABASE_URL 等),
# 否则后续 os.environ.get 读到空串(.env 后续才会被 app.park/app.training 各自加载)。
# 必须在下方任何 os.environ.get 之前执行。
try: # python-dotenv 为可选依赖;缺失时仍可用系统环境变量
from dotenv import load_dotenv
load_dotenv(Path(__file__).resolve().parent.parent / ".env")
except Exception: # noqa: BLE001
pass
# 项目根目录 = 本文件的上上级(app/config.py -> <根>
BASE_DIR = Path(__file__).resolve().parent.parent
# ---------------------------------------------------------------------------
# 数据目录(serverdata/ 统一承载所有资源:data/logs/files/keys/prompts/uploads
# ---------------------------------------------------------------------------
SERVERDATA_DIR = Path(
os.environ.get("PINEAGENTS_SERVERDATA_DIR", str(BASE_DIR / "serverdata")),
).resolve()
# 数据子目录(缺失自动创建,start.sh 亦负责初始化)
DATA_DIR = SERVERDATA_DIR / "data"
LOG_DIR = SERVERDATA_DIR / "logs"
FILES_DIR = SERVERDATA_DIR / "files"
KEYS_DIR = SERVERDATA_DIR / "keys"
PROMPTS_DIR = SERVERDATA_DIR / "prompts"
UPLOADS_DIR = SERVERDATA_DIR / "uploads"
for _d in (DATA_DIR, LOG_DIR, FILES_DIR, KEYS_DIR, PROMPTS_DIR, UPLOADS_DIR):
_d.mkdir(parents=True, exist_ok=True)
# 数据库连接(异步)。开发默认 SQLite+aiosqlite;生产用 MySQL+asyncmy(环境变量覆盖)。
# 示例 MySQLPINEAGENTS_DEMO_DATABASE_URL=mysql+asyncmy://user:pass@127.0.0.1:3306/opc?charset=utf8mb4
DATABASE_URL = os.environ.get(
"PINEAGENTS_DEMO_DATABASE_URL",
f"sqlite+aiosqlite:///{DATA_DIR / 'app.db'}",
)
# Redis(缓存/会话/限流/分布式锁),空则降级内存。
# 支持两种配置方式:
# 1. REDIS_URL 完整连接串(优先级最高)
# 2. PINEAGENTS_REDIS_HOST/PORT/PASSWORD/DB 分字段配置(与 .env 对齐)
_redis_host = os.environ.get("PINEAGENTS_REDIS_HOST", "")
_redis_port = os.environ.get("PINEAGENTS_REDIS_PORT", "6379")
_redis_password = os.environ.get("PINEAGENTS_REDIS_PASSWORD", "")
_redis_db = os.environ.get("PINEAGENTS_REDIS_DB", "0")
if os.environ.get("REDIS_URL"):
REDIS_URL = os.environ["REDIS_URL"]
elif _redis_host:
_auth = f":{_redis_password}@" if _redis_password else ""
REDIS_URL = f"redis://{_auth}{_redis_host}:{_redis_port}/{_redis_db}"
else:
REDIS_URL = "redis://127.0.0.1:6379/0"
REDIS_ENABLED = bool(_redis_host or os.environ.get("REDIS_URL"))
# OSS 对象存储(aioboto3,兼容阿里云 OSS / MinIO);空则文件落 serverdata/uploads。
OSS_ENDPOINT = os.environ.get("OSS_ENDPOINT", "")
OSS_ACCESS_KEY = os.environ.get("OSS_ACCESS_KEY", "")
OSS_SECRET_KEY = os.environ.get("OSS_SECRET_KEY", "")
OSS_BUCKET = os.environ.get("OSS_BUCKET", "opc-files")
OSS_REGION = os.environ.get("OSS_REGION", "cn-heyuan")
# CDN 加速域名(如 https://opcfiles.pinesound.cn);配置后下载统一走 CDN,空则退回预签名直链
OSS_CDN_BASE_URL = os.environ.get("OSS_CDN_BASE_URL", "").rstrip("/")
# CDN URL 鉴权密钥(鉴权方式C;须与阿里云 CDN 控制台主密钥一致),空则生成不带鉴权的 CDN 链接
OSS_CDN_AUTH_KEY = os.environ.get("OSS_CDN_AUTH_KEY", "")
# 桌面端更新源(/desktop-updates/*):对外绝对地址前缀(如 https://opc.pinesound.cn),
# 空则回退请求的 Host;用于生成 Tauri updater manifest 里的绝对下载 URL。
PUBLIC_BASE_URL = os.environ.get("PUBLIC_BASE_URL", "").rstrip("/")
# 桌面端更新签名公钥(minisign,与桌面端 tauri.conf.json plugins.updater.pubkey 一致)。
# 配置后,上传 .sig 时校验签名 key id 是否匹配,防止混入非本机私钥签名的产物。
DESKTOP_UPDATER_PUBKEY = os.environ.get("DESKTOP_UPDATER_PUBKEY", "").strip()
USERS_FILE = DATA_DIR / "users.json"
TOKENS_FILE = DATA_DIR / "tokens.json"
AGENTS_FILE = DATA_DIR / "agents.json"
# 智能体初始化 md 模板(镜像本地 md_files 结构;qa 已统一为 PineAgents 品牌)。
# 目录:<PROMPTS_DIR>/<lang>/*.md(通用)、<PROMPTS_DIR>/qa/<lang>/*.md、
# <PROMPTS_DIR>/local/<lang>/*.md
TEMPLATES_DIR = PROMPTS_DIR
# 首次启动时用于初始化 data/ 的种子数据(提交到仓库,数据可读)。
SEED_DIR = BASE_DIR / "data" / "seed"
SEED_USERS_FILE = SEED_DIR / "demo_users.json"
# ---------------------------------------------------------------------------
# 服务监听
# ---------------------------------------------------------------------------
HOST = os.environ.get("PINEAGENTS_DEMO_HOST", "127.0.0.1")
PORT = int(os.environ.get("PINEAGENTS_DEMO_PORT", "8090"))
# ---------------------------------------------------------------------------
# 认证与令牌
# ---------------------------------------------------------------------------
# 演示服务默认开启认证(PineAgents 主后端把 /auth/* 转发到本服务)。
AUTH_ENABLED = (
os.environ.get("PINEAGENTS_DEMO_AUTH_ENABLED", "true").strip().lower()
in ("true", "1", "yes")
)
# 令牌有效期(秒):默认 7 天;-1/0 表示永久(按 100 年封顶)。
DEFAULT_TOKEN_EXPIRY_SECONDS = 7 * 24 * 3600
PERMANENT_TOKEN_SECONDS = 100 * 365 * 24 * 3600
TOKEN_EXPIRY_MAX = PERMANENT_TOKEN_SECONDS
# ---------------------------------------------------------------------------
# JWT
# ---------------------------------------------------------------------------
JWT_SECRET = os.environ.get(
"PINEAGENTS_DEMO_JWT_SECRET",
"dev-insecure-change-me-0123456789abcdef",
)
JWT_ALGORITHM = os.environ.get("PINEAGENTS_DEMO_JWT_ALGORITHM", "HS256")
JWT_ACCESS_EXPIRY_SECONDS = DEFAULT_TOKEN_EXPIRY_SECONDS
# ---------------------------------------------------------------------------
# 算力引擎(compute-engine / new-api)管理接入(loopback,仅服务)
# 模型调用与额度/令牌/流水均经此引擎计量;前端不直连引擎。
# ---------------------------------------------------------------------------
COMPUTE_BASE_URL = os.environ.get(
"PINEAGENTS_COMPUTE_BASE_URL", "http://127.0.0.1:3000",
)
# 受信任内部令牌:server-core 作为唯一入口,无权限限制访问引擎管理/root 端点。
# 与引擎侧 PINEAGENTS_INTERNAL_TOKEN 保持一致;回退兼容旧名 PINEAGENTS_COMPUTE_ADMIN_TOKEN。
COMPUTE_ADMIN_TOKEN = os.environ.get("PINEAGENTS_INTERNAL_TOKEN") or os.environ.get("PINEAGENTS_COMPUTE_ADMIN_TOKEN", "")
# /v1 模型中继转发(relay.py)用的引擎消费令牌:需是引擎 /v1 能识别的真实用户/渠道令牌,
# 与聚合 root 的 COMPUTE_ADMIN_TOKEN 区分开(后者仅用于管理/root 端点)。回退 COMPUTE_ADMIN_TOKEN。
COMPUTE_RELAY_TOKEN = os.environ.get("PINEAGENTS_COMPUTE_RELAY_TOKEN", "")
# 用户端算力中心「基础 API 地址」:对外公网地址(如 https://opc.pinesound.cn),供用户其它工具接入。
# 未设置时回落请求的 base_url。经代理时 request.base_url 可能为内部地址,故用此配置给公网地址。
COMPUTE_PUBLIC_BASE = os.environ.get("PINEAGENTS_COMPUTE_PUBLIC_BASE", "").rstrip("/")
COMPUTE_TIMEOUT = float(os.environ.get("PINEAGENTS_COMPUTE_TIMEOUT", "10"))
COMPUTE_RETRIES = int(os.environ.get("PINEAGENTS_COMPUTE_RETRIES", "2"))
# ---------------------------------------------------------------------------
# IM 微服务(opc-im-serviceloopback,仅服务)
# 聊天 REST/WS 统一经 server-core 对外:/im/* → IM_BASE_URL/api/v1/*/im/ws → IM_BASE_URL/ws。
# 任务群等内部同步用 IM_INTERNAL_TOKEN(与 im-service .env 一致)。
# ---------------------------------------------------------------------------
IM_BASE_URL = os.environ.get("PINEAGENTS_IM_BASE_URL", "http://127.0.0.1:8101").rstrip("/")
IM_WS_URL = os.environ.get("PINEAGENTS_IM_WS_URL", "ws://127.0.0.1:8101/ws")
IM_INTERNAL_TOKEN = os.environ.get("IM_INTERNAL_TOKEN") or os.environ.get("PINEAGENTS_INTERNAL_TOKEN", "")
IM_TIMEOUT = float(os.environ.get("PINEAGENTS_IM_TIMEOUT", "10"))
IM_RETRIES = int(os.environ.get("PINEAGENTS_IM_RETRIES", "1"))
# ---------------------------------------------------------------------------
# 短信验证码(统一登录)
# ---------------------------------------------------------------------------
# stub = 仅打印验证码不真正发送(默认,便于联调);生产接阿里云/腾讯云短信。
SMS_PROVIDER = os.environ.get("PINEAGENTS_SMS_PROVIDER", "stub").strip().lower()
SMS_CODE_TTL_SECONDS = int(os.environ.get("PINEAGENTS_SMS_CODE_TTL_SECONDS", "300"))
SMS_RATE_LIMIT = int(os.environ.get("PINEAGENTS_SMS_RATE_LIMIT", "5"))
# ---------------------------------------------------------------------------
# 微信登录(小程序 code2session + 开放平台网站应用扫码登录)
# 未配置 appid/secret 时走「测试直通」:扫码登录把 code 当 openid 返回,便于无真实凭据联调。
# WECHAT_APPID/SECRET 为小程序;WECHAT_OPEN_APPID/SECRET 为开放平台「网站应用」(扫码登录),缺省回退前者。
# ---------------------------------------------------------------------------
WECHAT_APPID = os.environ.get("PINEAGENTS_WECHAT_APPID", "")
WECHAT_SECRET = os.environ.get("PINEAGENTS_WECHAT_SECRET", "")
WECHAT_OPEN_APPID = os.environ.get("PINEAGENTS_WECHAT_OPEN_APPID", "") or WECHAT_APPID
WECHAT_OPEN_SECRET = os.environ.get("PINEAGENTS_WECHAT_OPEN_SECRET", "") or WECHAT_SECRET
# 扫码登录授权后的回跳地址(须在开放平台后台配置为该 URL):默认服务端 /auth/wx-callback。
# 未设置时由前端 request.base_url 推导。
WECHAT_QR_REDIRECT_URI = os.environ.get("PINEAGENTS_WECHAT_QR_REDIRECT_URI", "")
# 微信开放平台「扫码登录」(网页/桌面微信扫码 OAuth):**默认关闭** —— 只保留小程序登录。
# true 才返回可用的 /auth/wx-qr/startfalse 时前端隐藏「微信扫码」入口。
AUTH_WECHAT_QR = os.environ.get("PINEAGENTS_WECHAT_QR", "false").strip().lower() in ("true", "1", "yes")
# 小程序登录(wx.login code2session + 小程序码 mp-qr):**默认开启** —— 唯一保留的微信登录方式。
AUTH_WECHAT_LOGIN = os.environ.get("PINEAGENTS_WECHAT_LOGIN", "true").strip().lower() in ("true", "1", "yes")
# ---------------------------------------------------------------------------
# 运行环境(生产/开发)—— 供业务判断与小程序码版本选择,亦在 /auth/status 回显以便确认
# ---------------------------------------------------------------------------
# APP_ENV: prod | dev(默认 prod)。dev 时小程序码默认指向「体验版」(trial)prod 指向正式版(release)。
APP_ENV = os.environ.get("PINEAGENTS_ENV", "prod").strip().lower()
APP_ENV = "prod" if APP_ENV not in ("prod", "dev", "development", "test") else ("prod" if APP_ENV == "prod" else "dev")
# 小程序码(wxacode) 的版本类型:release(正式版) | trial(体验版) | develop(开发版)
# 默认按 APP_ENVprod→releasedev→trial;可显式 PINEAGENTS_WXACODE_ENV 覆盖。
_WXACODE_DEFAULT = "release" if APP_ENV == "prod" else "trial"
WECHAT_WXACODE_ENV = os.environ.get("PINEAGENTS_WXACODE_ENV", "").strip().lower() or _WXACODE_DEFAULT
if WECHAT_WXACODE_ENV not in ("release", "trial", "develop"):
WECHAT_WXACODE_ENV = _WXACODE_DEFAULT
def resolve_token_expiry(expires_in: int | None) -> int:
"""把前端传来的 ``expires_in`` 归一化为秒数。
- None → 默认 7 天
- <= 0(含 -1/0)→ 永久(100 年)
- 其他 → 按最大值封顶
"""
if expires_in is None:
return DEFAULT_TOKEN_EXPIRY_SECONDS
if expires_in <= 0:
return PERMANENT_TOKEN_SECONDS
return min(expires_in, TOKEN_EXPIRY_MAX)