This commit is contained in:
zuowei1216
2025-12-22 21:06:29 +08:00
parent 8ea58fe480
commit 1b19ff1b92
179 changed files with 21895 additions and 3774 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<ExtensionManifest xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" ExtensionBundleId="com.cep-super-edition.body" ExtensionBundleVersion="1.0" Version="6.0"> <!-- MAJOR-VERSION-UPDATE-MARKER -->
<ExtensionList>
<Extension Id="com.cep-super-edition" Version="6.1.0"/>
</ExtensionList>
<ExecutionEnvironment>
<HostList>
<Host Name="AEFT" Version="[0.0,99.9]"/>
<Host Name="PPRO" Version="[0.0,99.9]"/>
<Host Name="ILST" Version="[0.0,99.9]"/>
<Host Name="PHXS" Version="[0.0,99.9]"/>
<Host Name="FLPR" Version="[0.0,99.9]"/>
</HostList>
<LocaleList>
<Locale Code="All"/>
</LocaleList>
<RequiredRuntimeList>
<RequiredRuntime Name="CSXS" Version="8.0"/> <!-- MAJOR-VERSION-UPDATE-MARKER -->
</RequiredRuntimeList>
</ExecutionEnvironment>
<DispatchInfoList>
<Extension Id="com.cep-super-edition">
<DispatchInfo>
<Resources>
<MainPath>./index.html</MainPath>
<!-- <ScriptPath>./jsx/core.jsx</ScriptPath> -->
<CEFCommandLine>
<Parameter>--enable-nodejs</Parameter>
</CEFCommandLine>
</Resources>
<Lifecycle>
<AutoVisible>true</AutoVisible>
</Lifecycle>
<UI>
<Type>Panel</Type>
<Menu>超级套版</Menu>
<Geometry>
<Size>
<Height>600</Height>
<Width>250</Width>
</Size>
<MaxSize>
<Height>3000</Height>
<Width>4000</Width>
</MaxSize>
<MinSize>
<Height>600</Height>
<Width>250</Width>
</MinSize>
</Geometry>
<Icons>
<Icon Type="Normal">./img/highlight.png</Icon>
<Icon Type="RollOver">./img/dark.png</Icon>
<Icon Type="DarkNormal">./img/highlight.png</Icon>
<Icon Type="DarkRollOver">./img/dark.png</Icon>
</Icons>
</UI>
</DispatchInfo>
</Extension>
</DispatchInfoList>
</ExtensionManifest>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 846 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./vite.svg" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Designer Launcher</title>
<script src="CSInterface.js"></script>
<script type="module" crossorigin src="./assets/index-477ad47d.js"></script>
<link rel="stylesheet" href="./assets/index-5c0de67a.css">
<script type="module">import.meta.url;import("_").catch(()=>1);async function* g(){};if(location.protocol!="file:"){window.__vite_is_modern_browser=true}</script>
<script type="module">!function(){if(window.__vite_is_modern_browser)return;console.warn("vite: loading legacy chunks, syntax error above and the same error below should be ignored");var e=document.getElementById("vite-legacy-polyfill"),n=document.createElement("script");n.src=e.src,n.onload=function(){System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'))},document.body.appendChild(n)}();</script>
</head>
<body>
<div id="app"></div>
<script nomodule>!function(){var e=document,t=e.createElement("script");if(!("noModule"in t)&&"onbeforeload"in t){var n=!1;e.addEventListener("beforeload",(function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()}),!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove()}}();</script>
<script nomodule crossorigin id="vite-legacy-polyfill" src="./assets/polyfills-legacy-e054d5d3.js"></script>
<script nomodule crossorigin id="vite-legacy-entry" data-src="./assets/index-legacy-fd2a7686.js">System.import(document.getElementById('vite-legacy-entry').getAttribute('data-src'))</script>
</body>
</html>

View File

@@ -1 +0,0 @@
*.js linguist-detectable=false

View File

@@ -1 +0,0 @@
"object"!=typeof JSON&&(JSON={}),function(){"use strict";var rx_one=/^[\],:{}\s]*$/,rx_two=/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g,rx_three=/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g,rx_four=/(?:^|:|,)(?:\s*\[)+/g,rx_escapable=/[\\\"\u0000-\u001f\u007f-\u009f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,rx_dangerous=/[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,gap,indent,meta,rep;function f(t){return t<10?"0"+t:t}function this_value(){return this.valueOf()}function quote(t){return rx_escapable.lastIndex=0,rx_escapable.test(t)?'"'+t.replace(rx_escapable,function(t){var e=meta[t];return"string"==typeof e?e:"\\u"+("0000"+t.charCodeAt(0).toString(16)).slice(-4)})+'"':'"'+t+'"'}function str(t,e){var r,n,o,u,f,a=gap,i=e[t];switch(i&&"object"==typeof i&&"function"==typeof i.toJSON&&(i=i.toJSON(t)),"function"==typeof rep&&(i=rep.call(e,t,i)),typeof i){case"string":return quote(i);case"number":return isFinite(i)?String(i):"null";case"boolean":case"null":return String(i);case"object":if(!i)return"null";if(gap+=indent,f=[],"[object Array]"===Object.prototype.toString.apply(i)){for(u=i.length,r=0;r<u;r+=1)f[r]=str(r,i)||"null";return o=0===f.length?"[]":gap?"[\n"+gap+f.join(",\n"+gap)+"\n"+a+"]":"["+f.join(",")+"]",gap=a,o}if(rep&&"object"==typeof rep)for(u=rep.length,r=0;r<u;r+=1)"string"==typeof rep[r]&&(o=str(n=rep[r],i))&&f.push(quote(n)+(gap?": ":":")+o);else for(n in i)Object.prototype.hasOwnProperty.call(i,n)&&(o=str(n,i))&&f.push(quote(n)+(gap?": ":":")+o);return o=0===f.length?"{}":gap?"{\n"+gap+f.join(",\n"+gap)+"\n"+a+"}":"{"+f.join(",")+"}",gap=a,o}}"function"!=typeof Date.prototype.toJSON&&(Date.prototype.toJSON=function(){return isFinite(this.valueOf())?this.getUTCFullYear()+"-"+f(this.getUTCMonth()+1)+"-"+f(this.getUTCDate())+"T"+f(this.getUTCHours())+":"+f(this.getUTCMinutes())+":"+f(this.getUTCSeconds())+"Z":null},Boolean.prototype.toJSON=this_value,Number.prototype.toJSON=this_value,String.prototype.toJSON=this_value),"function"!=typeof JSON.stringify&&(meta={"\b":"\\b","\t":"\\t","\n":"\\n","\f":"\\f","\r":"\\r",'"':'\\"',"\\":"\\\\"},JSON.stringify=function(t,e,r){var n;if(gap="",indent="","number"==typeof r)for(n=0;n<r;n+=1)indent+=" ";else"string"==typeof r&&(indent=r);if(rep=e,e&&"function"!=typeof e&&("object"!=typeof e||"number"!=typeof e.length))throw new Error("JSON.stringify");return str("",{"":t})}),"function"!=typeof JSON.parse&&(JSON.parse=function(text,reviver){var j;function walk(t,e){var r,n,o=t[e];if(o&&"object"==typeof o)for(r in o)Object.prototype.hasOwnProperty.call(o,r)&&(void 0!==(n=walk(o,r))?o[r]=n:delete o[r]);return reviver.call(t,e,o)}if(text=String(text),rx_dangerous.lastIndex=0,rx_dangerous.test(text)&&(text=text.replace(rx_dangerous,function(t){return"\\u"+("0000"+t.charCodeAt(0).toString(16)).slice(-4)})),rx_one.test(text.replace(rx_two,"@").replace(rx_three,"]").replace(rx_four,"")))return j=eval("("+text+")"),"function"==typeof reviver?walk({"":j},""):j;throw new SyntaxError("JSON.parse")})}();

View File

@@ -28,6 +28,9 @@ RUN mkdir -p archives
# 暴露端口
EXPOSE 8000
# 复制启动脚本并设置权限
COPY start.sh .
RUN chmod +x start.sh
# 启动命令
# 生产环境推荐使用 gunicorn 管理 uvicorn workers或者直接用 uvicorn
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["./start.sh"]

109
Server/README.md Normal file
View File

@@ -0,0 +1,109 @@
# 📁 Server 目录说明
## 目录结构
```
Server/
├── app/ ← 后端代码FastAPI
│ ├── api/ ← API 路由
│ ├── core/ ← 核心配置
│ ├── models/ ← 数据库模型
│ ├── schemas/ ← 数据验证
│ ├── services/ ← 业务逻辑
│ ├── main.py ← 后端入口
│ └── db.py ← 数据库连接
├── static/ ← ⭐ 前端静态文件(重要!)
│ └── app/ ← 前端构建产物放这里
│ ├── index.html ← 前端入口
│ ├── assets/ ← JS/CSS/图片
│ └── ...
├── archives/ ← Core 版本压缩包(历史版本)
│ ├── core-v1.0.0.zip
│ ├── core-v1.0.1.zip
│ └── ...
├── tests/ ← 测试代码
├── routers/ ← 额外的路由(如果有)
├── docker-compose.yml ← Docker 编排配置
├── Dockerfile ← 后端镜像构建
├── requirements.txt ← Python 依赖
├── mysql.cnf ← MySQL 配置
├── .dockerignore ← Docker 忽略文件
└── designercep.db ← SQLite 数据库(本地开发用)
```
---
## ⭐ 前端文件应该放在这里
### 构建前端后,复制到这里:
```
Server/static/app/
├── index.html ← 前端入口(重要!)
├── assets/ ← JS/CSS 等资源
│ ├── index-xxx.js
│ ├── index-xxx.css
│ └── ...
├── CSInterface.js ← CEP 桥接文件
└── vite.svg ← 图标等
```
### 操作步骤:
```bash
# 1. 构建前端(在 Designer 目录)
cd Designer
npm run build:core
# 2. 复制到 Server/static/app/
# Windows:
xcopy /E /Y dist_core\* ..\Server\static\app\
# Linux/Mac:
cp -r dist_core/* ../Server/static/app/
```
---
## 🐳 Docker 部署
### 启动服务
```bash
cd Server
docker-compose up -d
```
### 查看日志
```bash
docker-compose logs -f
```
### 停止服务
```bash
docker-compose down
```
---
## 📋 部署清单
- [ ] 前端文件已复制到 `static/app/`
- [ ] `.env` 文件已配置
- [ ] MySQL 密码已修改
- [ ] SECRET_KEY 已修改
- [ ] ALLOWED_ORIGINS 已配置
- [ ] Docker 服务已启动
- [ ] 访问 https://app.aidg168.uk/ 测试
---
**重要**:前端文件必须放在 `Server/static/app/` 目录!

116
Server/alembic.ini Normal file
View File

@@ -0,0 +1,116 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
Server/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

93
Server/alembic/env.py Normal file
View File

@@ -0,0 +1,93 @@
import os
import sys
from logging.config import fileConfig
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# 将当前目录添加到 sys.path以便能够导入 app 模块
sys.path.append(os.getcwd())
# 导入配置和模型
from app.core.config import settings
from app.db import Base
# 导入所有模型以确保它们被注册到 Base.metadata
from app.models import user, group, business, session
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
target_metadata = Base.metadata
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def get_url():
return settings.DATABASE_URL
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = get_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
configuration = config.get_section(config.config_ini_section, {})
configuration["sqlalchemy.url"] = get_url()
connectable = engine_from_config(
configuration,
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,251 @@
# -*- coding: utf-8 -*-
"""
管理员配置接口
功能管理功能配置、VIP配置、签到配置
"""
from fastapi import APIRouter, Header, HTTPException, Depends
from typing import List, Optional
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.business import FeatureConfig, VipConfig, CheckInConfig
router = APIRouter()
# ==================== 数据模型 ====================
class FeatureConfigCreate(BaseModel):
feature_key: str
feature_name: str
category: str = "general"
points_cost: int
vip_points_cost: int = 0
svip_points_cost: int = 0
description: Optional[str] = None
class FeatureConfigUpdate(BaseModel):
points_cost: Optional[int] = None
vip_points_cost: Optional[int] = None
svip_points_cost: Optional[int] = None
enabled: Optional[bool] = None
description: Optional[str] = None
class VIPConfigUpdate(BaseModel):
price: Optional[float] = None
daily_quota: Optional[int] = None
points_multiplier: Optional[float] = None
class CheckInConfigCreate(BaseModel):
consecutive_days: int
base_points: int
bonus_points: int
total_points: int
class CheckInConfigUpdate(BaseModel):
base_points: Optional[int] = None
bonus_points: Optional[int] = None
total_points: Optional[int] = None
# ==================== 辅助函数 ====================
def verify_admin_token(token: str):
"""验证管理员Token"""
expected_token = "admin-secret-token"
if token != expected_token:
raise HTTPException(status_code=401, detail="管理员Token无效")
# ==================== 功能配置管理 ====================
@router.get("/admin/config/features")
async def get_features_config(token: str = Header(..., alias="x-admin-token"), db: Session = Depends(get_db)):
"""获取所有功能配置"""
verify_admin_token(token)
features = db.query(FeatureConfig).order_by(FeatureConfig.category, FeatureConfig.feature_key).all()
return features
@router.post("/admin/config/features")
async def create_feature_config(
data: FeatureConfigCreate,
token: str = Header(..., alias="x-admin-token"),
db: Session = Depends(get_db)
):
"""新增功能配置"""
verify_admin_token(token)
existing = db.query(FeatureConfig).filter(FeatureConfig.feature_key == data.feature_key).first()
if existing:
raise HTTPException(status_code=400, detail="功能标识已存在")
new_feature = FeatureConfig(
feature_key=data.feature_key,
feature_name=data.feature_name,
category=data.category,
points_cost=data.points_cost,
vip_points_cost=data.vip_points_cost,
svip_points_cost=data.svip_points_cost,
description=data.description
)
db.add(new_feature)
db.commit()
return {"code": 200, "message": "创建成功"}
@router.put("/admin/config/features/{feature_key}")
async def update_feature_config(
feature_key: str,
data: FeatureConfigUpdate,
token: str = Header(..., alias="x-admin-token"),
db: Session = Depends(get_db)
):
"""更新功能配置"""
verify_admin_token(token)
feature = db.query(FeatureConfig).filter(FeatureConfig.feature_key == feature_key).first()
if not feature:
raise HTTPException(status_code=404, detail="功能配置不存在")
if data.points_cost is not None:
feature.points_cost = data.points_cost
if data.vip_points_cost is not None:
feature.vip_points_cost = data.vip_points_cost
if data.svip_points_cost is not None:
feature.svip_points_cost = data.svip_points_cost
if data.enabled is not None:
feature.enabled = data.enabled
if data.description is not None:
feature.description = data.description
db.commit()
return {"code": 200, "message": "更新成功"}
@router.delete("/admin/config/features/{feature_key}")
async def delete_feature_config(
feature_key: str,
token: str = Header(..., alias="x-admin-token"),
db: Session = Depends(get_db)
):
"""删除功能配置"""
verify_admin_token(token)
feature = db.query(FeatureConfig).filter(FeatureConfig.feature_key == feature_key).first()
if not feature:
raise HTTPException(status_code=404, detail="功能配置不存在")
db.delete(feature)
db.commit()
return {"code": 200, "message": "删除成功"}
# ==================== VIP配置管理 ====================
@router.get("/admin/config/vip")
async def get_vip_config(token: str = Header(..., alias="x-admin-token"), db: Session = Depends(get_db)):
"""获取VIP配置"""
verify_admin_token(token)
# Sort order: vip, svip
# We can fetch all and sort in python or rely on insertion order/id
configs = db.query(VipConfig).all()
# Simple sort
configs.sort(key=lambda x: 0 if x.vip_type == 'vip' else 1)
return configs
@router.put("/admin/config/vip/{vip_type}")
async def update_vip_config(
vip_type: str,
data: VIPConfigUpdate,
token: str = Header(..., alias="x-admin-token"),
db: Session = Depends(get_db)
):
"""更新VIP配置"""
verify_admin_token(token)
if vip_type not in ['vip', 'svip']:
raise HTTPException(status_code=400, detail="VIP类型必须是vip或svip")
config = db.query(VipConfig).filter(VipConfig.vip_type == vip_type).first()
if not config:
raise HTTPException(status_code=404, detail="VIP配置不存在")
if data.price is not None:
config.price = data.price
if data.daily_quota is not None:
config.daily_quota = data.daily_quota
if data.points_multiplier is not None:
config.points_multiplier = data.points_multiplier
db.commit()
return {"code": 200, "message": "更新成功"}
# ==================== 签到配置管理 ====================
@router.get("/admin/config/checkin")
async def get_checkin_config(token: str = Header(..., alias="x-admin-token"), db: Session = Depends(get_db)):
"""获取签到配置"""
verify_admin_token(token)
configs = db.query(CheckInConfig).filter(CheckInConfig.enabled == True).order_by(CheckInConfig.consecutive_days).all()
return configs
@router.post("/admin/config/checkin")
async def create_checkin_config(
data: CheckInConfigCreate,
token: str = Header(..., alias="x-admin-token"),
db: Session = Depends(get_db)
):
"""新增签到档位"""
verify_admin_token(token)
existing = db.query(CheckInConfig).filter(CheckInConfig.consecutive_days == data.consecutive_days).first()
if existing:
raise HTTPException(status_code=400, detail="该连续天数配置已存在")
new_config = CheckInConfig(
consecutive_days=data.consecutive_days,
base_points=data.base_points,
bonus_points=data.bonus_points,
total_points=data.total_points
)
db.add(new_config)
db.commit()
return {"code": 200, "message": "创建成功"}
@router.put("/admin/config/checkin/{consecutive_days}")
async def update_checkin_config(
consecutive_days: int,
data: CheckInConfigUpdate,
token: str = Header(..., alias="x-admin-token"),
db: Session = Depends(get_db)
):
"""更新签到档位"""
verify_admin_token(token)
config = db.query(CheckInConfig).filter(CheckInConfig.consecutive_days == consecutive_days).first()
if not config:
raise HTTPException(status_code=404, detail="签到配置不存在")
if data.base_points is not None:
config.base_points = data.base_points
if data.bonus_points is not None:
config.bonus_points = data.bonus_points
if data.total_points is not None:
config.total_points = data.total_points
db.commit()
return {"code": 200, "message": "更新成功"}
@router.delete("/admin/config/checkin/{consecutive_days}")
async def delete_checkin_config(
consecutive_days: int,
token: str = Header(..., alias="x-admin-token"),
db: Session = Depends(get_db)
):
"""删除签到档位"""
verify_admin_token(token)
config = db.query(CheckInConfig).filter(CheckInConfig.consecutive_days == consecutive_days).first()
if not config:
raise HTTPException(status_code=404, detail="签到配置不存在")
db.delete(config)
db.commit()
return {"code": 200, "message": "删除成功"}

View File

@@ -14,6 +14,16 @@ from datetime import datetime, timezone
router = APIRouter()
@router.post("/verify", response_model=VerifyResponse)
async def verify(
verify_data: VerifyRequest,
db: Session = Depends(get_db),
current_username: str = Depends(get_current_user)
):
"""验证用户授权状态"""
# 许可证验证接口:验证 token 是否有效,检查账户过期,更新活跃时间
return auth_service.verify_license(db, verify_data, current_username)
@router.post("/send-verification-code")
async def send_verification_code(body: SendVerificationCodeRequest, db: Session = Depends(get_db)):
# 发送注册验证码
@@ -97,3 +107,5 @@ async def get_online_time(username: str, db: Session = Depends(get_db)):
async def heartbeat(body: UserHeartbeat, db: Session = Depends(get_db)):
# 心跳接口:更新会话的最近在线时间
return auth_service.heartbeat(db, body.username, body.device_id)

View File

@@ -0,0 +1,254 @@
# -*- coding: utf-8 -*-
"""
签到接口
功能:每日签到、签到状态查询、签到日历
"""
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from datetime import date, datetime, timedelta
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.db import get_db
from app.models.user import User
from app.models.business import CheckInConfig, VipConfig, CheckInRecord, PointsHistory
router = APIRouter()
# ==================== 数据模型 ====================
class CheckInRequest(BaseModel):
username: str
# ==================== 签到功能 ====================
@router.post("/checkin/daily")
async def daily_checkin(data: CheckInRequest, db: Session = Depends(get_db)):
"""每日签到"""
# 1. 获取用户信息
user = db.query(User).filter(User.username == data.username).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 2. 检查今日是否已签到
today = date.today()
if user.last_check_in_date == today:
raise HTTPException(status_code=400, detail="今日已签到,明天再来吧")
# 3. 计算连续天数
consecutive_days = user.consecutive_check_in if user.consecutive_check_in else 0
total_days = user.total_check_in_days if user.total_check_in_days else 0
yesterday = today - timedelta(days=1)
if user.last_check_in_date == yesterday:
# 连续签到
consecutive_days += 1
else:
# 中断了,重新开始
consecutive_days = 1
total_days += 1
# 4. 从配置表获取奖励
reward_config = db.query(CheckInConfig)\
.filter(CheckInConfig.consecutive_days <= consecutive_days, CheckInConfig.enabled == True)\
.order_by(CheckInConfig.consecutive_days.desc())\
.first()
if not reward_config:
# 如果没有配置默认给10积分
base_points = 10
bonus_points = 0
total_points = 10
else:
base_points = reward_config.base_points
bonus_points = reward_config.bonus_points
total_points = reward_config.total_points
# 5. 应用VIP倍数
vip_multiplier = 1.0
if user.vip_type and user.vip_type in ['vip', 'svip']:
vip_config = db.query(VipConfig).filter(VipConfig.vip_type == user.vip_type).first()
if vip_config:
vip_multiplier = float(vip_config.points_multiplier)
points_earned = int(total_points * vip_multiplier)
current_points = user.points if user.points else 0
new_balance = current_points + points_earned
# 6. 更新用户数据
user.points = new_balance
user.total_check_in_days = total_days
user.consecutive_check_in = consecutive_days
user.last_check_in_date = today
# 7. 记录签到记录
checkin_record = CheckInRecord(
user_id=user.id,
username=data.username,
check_in_date=today,
points_earned=points_earned,
consecutive_days=consecutive_days,
vip_multiplier=vip_multiplier
)
db.add(checkin_record)
# 8. 记录积分历史
points_history = PointsHistory(
user_id=user.id,
username=data.username,
type='checkin',
amount=points_earned,
balance=new_balance,
description=f"每日签到奖励(连续{consecutive_days}天)"
)
db.add(points_history)
db.commit()
# 9. 返回结果
return {
"code": 200,
"data": {
"success": True,
"points_earned": points_earned,
"base_points": base_points,
"bonus_points": bonus_points,
"vip_multiplier": vip_multiplier,
"consecutive_days": consecutive_days,
"total_check_in_days": total_days,
"total_points": new_balance,
"message": f"签到成功!连续签到{consecutive_days}天,获得{points_earned}积分"
}
}
@router.get("/checkin/config")
async def get_checkin_config(db: Session = Depends(get_db)):
"""获取签到奖励配置(公开)"""
configs = db.query(CheckInConfig)\
.filter(CheckInConfig.enabled == True)\
.order_by(CheckInConfig.consecutive_days)\
.all()
result = []
for config in configs:
result.append({
"consecutive_days": config.consecutive_days,
"base_points": config.base_points,
"bonus_points": config.bonus_points,
"total_points": config.total_points
})
return {
"code": 200,
"data": result
}
@router.get("/checkin/status")
async def get_checkin_status(username: str, db: Session = Depends(get_db)):
"""获取签到状态"""
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
today = date.today()
today_checked = (user.last_check_in_date == today)
return {
"code": 200,
"data": {
"today_checked": today_checked,
"consecutive_days": user.consecutive_check_in if user.consecutive_check_in else 0,
"total_days": user.total_check_in_days if user.total_check_in_days else 0,
"last_check_in_date": user.last_check_in_date.isoformat() if user.last_check_in_date else None
}
}
@router.get("/checkin/calendar/{year}/{month}")
async def get_checkin_calendar(username: str, year: int, month: int, db: Session = Depends(get_db)):
"""获取签到日历"""
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 使用 SQLAlchemy 提取日期部分
# 注意SQLite 和 MySQL 的日期函数不同
# 为了兼容性,这里我们查询该月范围内的所有记录,然后在 Python 中处理
import calendar
_, last_day = calendar.monthrange(year, month)
start_date = date(year, month, 1)
end_date = date(year, month, last_day)
records = db.query(CheckInRecord)\
.filter(
CheckInRecord.user_id == user.id,
CheckInRecord.check_in_date >= start_date,
CheckInRecord.check_in_date <= end_date
).all()
checked_dates = [record.check_in_date.day for record in records]
return {
"code": 200,
"data": {
"year": year,
"month": month,
"checked_dates": checked_dates
}
}
@router.get("/checkin/history")
async def get_checkin_history(username: str, page: int = 1, limit: int = 10, db: Session = Depends(get_db)):
"""签到记录"""
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
offset = (page - 1) * limit
total = db.query(func.count(CheckInRecord.id)).filter(CheckInRecord.user_id == user.id).scalar()
records = db.query(CheckInRecord)\
.filter(CheckInRecord.user_id == user.id)\
.order_by(CheckInRecord.check_in_date.desc())\
.offset(offset)\
.limit(limit)\
.all()
result_records = []
for record in records:
result_records.append({
"check_in_date": record.check_in_date.isoformat(),
"points_earned": record.points_earned,
"consecutive_days": record.consecutive_days,
"created_at": record.created_at.isoformat() if record.created_at else None
})
return {
"code": 200,
"data": {
"total": total,
"records": result_records
}
}
@router.get("/checkin/config")
async def get_checkin_config(db: Session = Depends(get_db)):
"""获取签到奖励规则 (公开接口)"""
configs = db.query(CheckInConfig).filter(CheckInConfig.enabled == True).order_by(CheckInConfig.consecutive_days.asc()).all()
data = []
for c in configs:
data.append({
"consecutive_days": c.consecutive_days,
"base_points": c.base_points,
"bonus_points": c.bonus_points,
"total_points": c.total_points
})
return {
"code": 200,
"data": data
}

View File

@@ -0,0 +1,133 @@
# -*- coding: utf-8 -*-
"""
通用功能使用接口
核心动态扣费逻辑SVIP免费、VIP配额、普通积分
"""
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from datetime import date
from sqlalchemy.orm import Session
from app.db import get_db
from app.models.user import User
from app.models.business import FeatureConfig, VipConfig, PointsHistory
router = APIRouter()
# ==================== 数据模型 ====================
class UseFeatureRequest(BaseModel):
username: str
feature_key: str
device_id: str
# ==================== 通用功能使用 ====================
@router.post("/feature/use")
async def use_feature(data: UseFeatureRequest, db: Session = Depends(get_db)):
"""
通用功能使用接口
逻辑:
1. SVIP用户免费使用
2. VIP用户优先使用配额配额用完后扣积分
3. 普通用户:扣除积分
"""
# 1. 获取功能配置
feature = db.query(FeatureConfig).filter(FeatureConfig.feature_key == data.feature_key).first()
if not feature or not feature.enabled:
raise HTTPException(status_code=400, detail="功能不存在或已禁用")
# 2. 获取用户信息
user = db.query(User).filter(User.username == data.username).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
current_points = user.points if user.points else 0
vip_type = user.vip_type
vip_quota = user.vip_daily_quota if user.vip_daily_quota else 0
quota_reset_date = user.vip_quota_reset_date
# 3. 检查并重置VIP配额
today = date.today()
if vip_type in ['vip', 'svip'] and quota_reset_date != today:
# 重置配额
vip_config = db.query(VipConfig).filter(VipConfig.vip_type == vip_type).first()
if vip_config:
vip_quota = vip_config.daily_quota
user.vip_daily_quota = vip_quota
user.vip_quota_reset_date = today
db.commit()
# 4. 判断消耗类型
cost_type = "points"
points_cost = 0
remaining_quota = vip_quota
# SVIP免费
if vip_type == 'svip' and feature.svip_points_cost == 0:
cost_type = "free"
message = "SVIP用户免费使用"
# VIP配额
elif vip_type == 'vip' and vip_quota > 0 and feature.vip_points_cost == 0:
cost_type = "vip_quota"
remaining_quota = vip_quota - 1
user.vip_daily_quota = remaining_quota
db.commit()
message = f"VIP配额使用剩余{remaining_quota}"
# 扣积分
else:
# 计算实际消耗
if vip_type == 'vip' and feature.vip_points_cost > 0:
points_cost = feature.vip_points_cost
elif vip_type == 'svip' and feature.svip_points_cost > 0:
points_cost = feature.svip_points_cost
else:
points_cost = feature.points_cost
# 检查余额
if current_points < points_cost:
raise HTTPException(
status_code=402,
detail=f"积分不足。当前: {current_points},需要: {points_cost}。请签到获取积分或开通VIP"
)
# 扣除积分
new_balance = current_points - points_cost
user.points = new_balance
# 记录积分历史
points_history = PointsHistory(
user_id=user.id,
username=data.username,
type='consume',
amount=-points_cost,
balance=new_balance,
description=f"使用{feature.feature_name}"
)
db.add(points_history)
db.commit()
message = f"消耗{points_cost}积分,剩余{new_balance}积分"
# 5. 记录使用日志 (TODO: Add FeatureUsageLogs model if needed, currently skipping or adding to PointsHistory if consumed)
# For now, we only log if points consumed. If we need separate usage log table, we need to create it.
# The SQL version inserted into feature_usage_logs.
# Let's assume PointsHistory covers financial aspect.
# If we need analytics, we have analytics API.
# 6. 返回结果
return {
"code": 200,
"data": {
"success": True,
"feature_name": feature.feature_name,
"cost_type": cost_type,
"points_cost": points_cost,
"vip_remaining_quota": remaining_quota if vip_type in ['vip', 'svip'] else None,
"points_remaining": user.points,
"message": message
}
}

142
Server/app/api/v1/stats.py Normal file
View File

@@ -0,0 +1,142 @@
# -*- coding: utf-8 -*-
"""
统计接口
功能:数据统计和分析
"""
from fastapi import APIRouter, Header, HTTPException
from datetime import date, timedelta
import pymysql
from app.core.database import get_db_connection
router = APIRouter()
# ==================== 辅助函数 ====================
def verify_admin_token(token: str):
"""验证管理员Token"""
expected_token = "admin-secret-token"
if token != expected_token:
raise HTTPException(status_code=401, detail="管理员Token无效")
# ==================== 统计功能 ====================
@router.get("/admin/stats/today")
async def get_today_stats(token: str = Header(..., alias="x-admin-token")):
"""今日统计"""
verify_admin_token(token)
conn = get_db_connection()
try:
with conn.cursor(pymysql.cursors.DictCursor) as cursor:
today = date.today()
# 总用户数
cursor.execute("SELECT COUNT(*) as total FROM users")
total_users = cursor.fetchone()['total']
# 今日签到数
cursor.execute("""
SELECT COUNT(DISTINCT user_id) as count
FROM check_in_records
WHERE check_in_date = %s
""", (today,))
checkin_count = cursor.fetchone()['count']
# 今日功能使用次数
cursor.execute("""
SELECT COUNT(*) as count
FROM feature_usage_logs
WHERE DATE(created_at) = %s
""", (today,))
feature_usage_count = cursor.fetchone()['count']
# VIP用户数
cursor.execute("""
SELECT COUNT(*) as count
FROM users
WHERE vip_type IN ('vip', 'svip')
AND (vip_expire IS NULL OR vip_expire > NOW())
""")
vip_count = cursor.fetchone()['count']
return {
"code": 200,
"data": {
"total_users": total_users,
"checkin_count": checkin_count,
"feature_usage_count": feature_usage_count,
"vip_count": vip_count
}
}
finally:
conn.close()
@router.get("/admin/stats/feature-usage")
async def get_feature_usage_stats(
days: int = 7,
token: str = Header(..., alias="x-admin-token")
):
"""功能使用排行"""
verify_admin_token(token)
conn = get_db_connection()
try:
with conn.cursor(pymysql.cursors.DictCursor) as cursor:
start_date = date.today() - timedelta(days=days-1)
cursor.execute("""
SELECT
l.feature_key,
f.feature_name,
COUNT(*) as usage_count
FROM feature_usage_logs l
LEFT JOIN features_config f ON l.feature_key = f.feature_key
WHERE DATE(l.created_at) >= %s
GROUP BY l.feature_key, f.feature_name
ORDER BY usage_count DESC
LIMIT 10
""", (start_date,))
return {
"code": 200,
"data": cursor.fetchall()
}
finally:
conn.close()
@router.get("/admin/stats/points-trend")
async def get_points_trend(
days: int = 7,
token: str = Header(..., alias="x-admin-token")
):
"""积分趋势统计"""
verify_admin_token(token)
conn = get_db_connection()
try:
with conn.cursor(pymysql.cursors.DictCursor) as cursor:
start_date = date.today() - timedelta(days=days-1)
cursor.execute("""
SELECT
DATE(created_at) as date,
SUM(CASE WHEN amount > 0 THEN amount ELSE 0 END) as earned,
SUM(CASE WHEN amount < 0 THEN -amount ELSE 0 END) as consumed
FROM points_history
WHERE DATE(created_at) >= %s
GROUP BY DATE(created_at)
ORDER BY date
""", (start_date,))
records = cursor.fetchall()
for record in records:
record['date'] = record['date'].isoformat()
return {
"code": 200,
"data": records
}
finally:
conn.close()

View File

@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
"""
用户资料接口
功能:获取和更新用户资料
"""
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional
from sqlalchemy.orm import Session
from sqlalchemy import func
from app.db import get_db
from app.models.user import User
from app.models.business import PointsHistory
router = APIRouter()
# ==================== 数据模型 ====================
class UserProfileUpdate(BaseModel):
username: str
nickname: Optional[str] = None
avatar: Optional[str] = None
email: Optional[str] = None
# ==================== 用户资料管理 ====================
@router.get("/user/profile")
async def get_user_profile(username: str, db: Session = Depends(get_db)):
"""获取用户资料"""
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
# 转换为字典以便序列化
user_data = {
"username": user.username,
"nickname": user.nickname,
"avatar": user.avatar,
"email": user.email,
"points": user.points,
"level": user.level,
"vip_type": user.vip_type,
"vip_expire": user.vip_expire.isoformat() if user.vip_expire else None,
"vip_daily_quota": user.vip_daily_quota,
"total_check_in_days": user.total_check_in_days,
"consecutive_check_in": user.consecutive_check_in,
"created_at": user.created_at.isoformat() if user.created_at else None
}
return {
"code": 200,
"data": user_data
}
@router.put("/user/profile")
async def update_user_profile(data: UserProfileUpdate, db: Session = Depends(get_db)):
"""更新用户资料"""
user = db.query(User).filter(User.username == data.username).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if data.nickname is not None:
user.nickname = data.nickname
if data.avatar is not None:
user.avatar = data.avatar
if data.email is not None:
user.email = data.email
db.commit()
return {
"code": 200,
"message": "更新成功"
}
# ==================== 积分历史 ====================
@router.get("/points/history")
async def get_points_history(
username: str,
type: Optional[str] = None,
page: int = 1,
limit: int = 20,
db: Session = Depends(get_db)
):
"""获取积分历史"""
user = db.query(User).filter(User.username == username).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
offset = (page - 1) * limit
query = db.query(PointsHistory).filter(PointsHistory.user_id == user.id)
if type:
query = query.filter(PointsHistory.type == type)
total = query.count()
records = query.order_by(PointsHistory.created_at.desc()).offset(offset).limit(limit).all()
result_records = []
for record in records:
result_records.append({
"type": record.type,
"amount": record.amount,
"balance": record.balance,
"description": record.description,
"created_at": record.created_at.isoformat() if record.created_at else None
})
return {
"code": 200,
"data": {
"total": total,
"current_balance": user.points,
"records": result_records
}
}

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
"""
数据库连接管理
"""
import pymysql
import os
def get_db_connection():
"""获取数据库连接"""
return pymysql.connect(
host=os.getenv('DB_HOST', 'localhost'),
port=int(os.getenv('DB_PORT', 3306)),
user=os.getenv('DB_USER', 'root'),
password=os.getenv('DB_PASSWORD', ''),
database=os.getenv('DB_NAME', 'designercep'),
charset='utf8mb4',
cursorclass=pymysql.cursors.Cursor
)

View File

@@ -28,6 +28,7 @@ def init_db():
from app.models.user import User
from app.models.group import PluginGroup
from app.models.session import UserSession
from app.models.business import FeatureConfig, VipConfig, CheckInConfig, CheckInRecord, PointsHistory
Base.metadata.create_all(bind=engine)
ensure_migrations()
seed_data()
@@ -35,15 +36,35 @@ def init_db():
def seed_data():
"""Ensure default data exists"""
from app.models.group import PluginGroup
from app.models.business import FeatureConfig, VipConfig, CheckInConfig
db = SessionLocal()
try:
# Seed Groups
default_group = db.query(PluginGroup).filter(PluginGroup.name == "default").first()
if not default_group:
print("Creating 'default' group...")
new_group = PluginGroup(name="default", comment="Default User Group")
db.add(new_group)
db.commit()
print("Default group created.")
# Seed VIP Config
if db.query(VipConfig).count() == 0:
print("Seeding VIP Config...")
db.add(VipConfig(vip_type="vip", name="VIP会员", price=30.0, daily_quota=20, points_multiplier=1.5))
db.add(VipConfig(vip_type="svip", name="SVIP会员", price=88.0, daily_quota=-1, points_multiplier=2.0))
# Seed Checkin Config
if db.query(CheckInConfig).count() == 0:
print("Seeding Checkin Config...")
db.add(CheckInConfig(consecutive_days=1, base_points=10, bonus_points=0, total_points=10))
db.add(CheckInConfig(consecutive_days=3, base_points=10, bonus_points=5, total_points=15))
db.add(CheckInConfig(consecutive_days=7, base_points=10, bonus_points=20, total_points=30))
# Seed Features
if db.query(FeatureConfig).count() == 0:
print("Seeding Features...")
db.add(FeatureConfig(feature_key="ai_remove_bg", feature_name="智能抠图", points_cost=10, category="ai"))
db.commit()
except Exception as e:
print(f"Error seeding data: {e}")
finally:
@@ -97,3 +118,27 @@ def ensure_migrations():
add_col("users", "reset_token", "VARCHAR(128) NULL")
if not has_column("users", "reset_token_expire"):
add_col("users", "reset_token_expire", "TIMESTAMP NULL")
# users profile & vip columns
if not has_column("users", "nickname"):
add_col("users", "nickname", "VARCHAR(50) NULL")
if not has_column("users", "avatar"):
add_col("users", "avatar", "VARCHAR(500) NULL")
if not has_column("users", "points"):
add_col("users", "points", "INTEGER DEFAULT 0")
if not has_column("users", "level"):
add_col("users", "level", "INTEGER DEFAULT 1")
if not has_column("users", "vip_type"):
add_col("users", "vip_type", "VARCHAR(20) DEFAULT 'none'")
if not has_column("users", "vip_expire"):
add_col("users", "vip_expire", "TIMESTAMP NULL")
if not has_column("users", "vip_daily_quota"):
add_col("users", "vip_daily_quota", "INTEGER DEFAULT 0")
if not has_column("users", "vip_quota_reset_date"):
add_col("users", "vip_quota_reset_date", "DATE NULL")
if not has_column("users", "total_check_in_days"):
add_col("users", "total_check_in_days", "INTEGER DEFAULT 0")
if not has_column("users", "consecutive_check_in"):
add_col("users", "consecutive_check_in", "INTEGER DEFAULT 0")
if not has_column("users", "last_check_in_date"):
add_col("users", "last_check_in_date", "DATE NULL")

View File

@@ -4,7 +4,7 @@ from fastapi.staticfiles import StaticFiles
import os
from pathlib import Path
from app.core.config import settings
from app.api.v1 import auth, client, admin, analytics, jsx_demo
from app.api.v1 import auth, client, admin, analytics, jsx_demo, admin_config, feature, checkin, user_profile, stats
from app.db import init_db
from datetime import datetime
@@ -61,6 +61,13 @@ app.include_router(admin.router, prefix=f"{settings.API_V1_STR}/admin", tags=["a
app.include_router(analytics.router, prefix=f"{settings.API_V1_STR}/analytics", tags=["analytics"])
app.include_router(jsx_demo.router, prefix=f"{settings.API_V1_STR}/jsx_demo", tags=["jsx_demo"])
# 新增路由
app.include_router(admin_config.router, prefix=settings.API_V1_STR, tags=["admin-config"])
app.include_router(feature.router, prefix=settings.API_V1_STR, tags=["feature"])
app.include_router(checkin.router, prefix=settings.API_V1_STR, tags=["checkin"])
app.include_router(user_profile.router, prefix=settings.API_V1_STR, tags=["user-profile"])
app.include_router(stats.router, prefix=settings.API_V1_STR, tags=["stats"])
# Health Check
@app.get("/health")
def health_check():

View File

@@ -0,0 +1,87 @@
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, Float, Date, Enum, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from app.db import Base
import enum
# Enums
class VipType(str, enum.Enum):
none = "none"
vip = "vip"
svip = "svip"
class FeatureCategory(str, enum.Enum):
general = "general"
ai = "ai"
export = "export"
layer = "layer"
other = "other"
# ========== Config Models ==========
class FeatureConfig(Base):
__tablename__ = "features_config"
id = Column(Integer, primary_key=True, index=True)
feature_key = Column(String(50), unique=True, index=True, nullable=False)
feature_name = Column(String(100), nullable=False)
category = Column(String(50), default="general")
points_cost = Column(Integer, default=0)
vip_points_cost = Column(Integer, default=0)
svip_points_cost = Column(Integer, default=0)
enabled = Column(Boolean, default=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
class VipConfig(Base):
__tablename__ = "vip_config"
id = Column(Integer, primary_key=True, index=True)
vip_type = Column(String(20), unique=True, nullable=False) # 'vip', 'svip'
name = Column(String(50), nullable=False)
price = Column(Float, nullable=False)
daily_quota = Column(Integer, nullable=False) # -1 for infinite
points_multiplier = Column(Float, default=1.0)
enabled = Column(Boolean, default=True)
description = Column(Text, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
class CheckInConfig(Base):
__tablename__ = "check_in_config"
id = Column(Integer, primary_key=True, index=True)
consecutive_days = Column(Integer, unique=True, nullable=False)
base_points = Column(Integer, nullable=False)
bonus_points = Column(Integer, nullable=False)
total_points = Column(Integer, nullable=False)
enabled = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# ========== Record Models ==========
class CheckInRecord(Base):
__tablename__ = "check_in_records"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, index=True, nullable=False)
username = Column(String(50), nullable=False)
check_in_date = Column(Date, nullable=False, index=True)
points_earned = Column(Integer, nullable=False)
consecutive_days = Column(Integer, nullable=False)
vip_multiplier = Column(Float, default=1.0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class PointsHistory(Base):
__tablename__ = "points_history"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, index=True, nullable=False)
username = Column(String(50), nullable=False)
type = Column(String(20), nullable=False) # checkin, consume, refund, admin
amount = Column(Integer, nullable=False)
balance = Column(Integer, nullable=False)
description = Column(String(255), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, Integer, String, DateTime, func, ForeignKey, Text, Boolean
from sqlalchemy import Column, Integer, String, DateTime, func, ForeignKey, Text, Boolean, Date
from sqlalchemy.orm import relationship
from app.db import Base
@@ -23,4 +23,18 @@ class User(Base):
reset_token = Column(String(128), nullable=True)
reset_token_expire = Column(DateTime(timezone=True), nullable=True)
# Profile & VIP & Check-in
nickname = Column(String(50), nullable=True)
avatar = Column(String(500), nullable=True)
points = Column(Integer, default=0)
level = Column(Integer, default=1)
vip_type = Column(String(20), default='none') # 'none', 'vip', 'svip'
vip_expire = Column(DateTime(timezone=True), nullable=True)
vip_daily_quota = Column(Integer, default=0)
vip_quota_reset_date = Column(Date, nullable=True)
total_check_in_days = Column(Integer, default=0)
consecutive_check_in = Column(Integer, default=0)
last_check_in_date = Column(Date, nullable=True)
group = relationship("PluginGroup", back_populates="users")

77
Server/app/recreate_db.py Normal file
View File

@@ -0,0 +1,77 @@
# -*- coding: utf-8 -*-
"""
数据库重建脚本
功能:
1. 连接到 MySQL 服务器
2. 删除现有数据库(如果存在)
3. 创建新数据库
4. 根据模型定义创建所有表
使用方法:
在 Server 目录下运行:
docker-compose exec backend python -m app.recreate_db
"""
import logging
import os
from sqlalchemy import create_engine, text
from sqlalchemy.engine.url import make_url
from app.core.config import settings
from app.db import Base
# 导入所有模型以确保它们被注册到 Base.metadata
from app.models import user, group, business, session
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def recreate_database():
logger.info("🚀 开始重建数据库...")
# 1. 获取数据库配置
db_url = settings.DATABASE_URL
if not db_url.startswith("mysql"):
logger.error("❌ 此脚本仅支持 MySQL 数据库重建")
return
try:
# 解析 URL
url = make_url(db_url)
db_name = url.database
# 构建连接到 MySQL 系统库的 URL (不指定具体数据库,以便执行 DROP/CREATE)
# 我们连接到 'mysql' 库或者不指定库
# 注意:需要有足够的权限
root_url = url.set(database='mysql')
logger.info(f"🔌 连接到数据库服务器: {url.host}")
# 使用 AUTOCOMMIT 隔离级别,因为 CREATE/DROP DATABASE 不能在事务中运行
engine = create_engine(root_url, isolation_level="AUTOCOMMIT")
with engine.connect() as conn:
# 删除旧数据库
logger.info(f"🗑️ 删除数据库: {db_name}")
conn.execute(text(f"DROP DATABASE IF EXISTS {db_name}"))
# 创建新数据库
logger.info(f"✨ 创建数据库: {db_name}")
conn.execute(text(f"CREATE DATABASE {db_name} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"))
# 2. 创建表结构
logger.info("🏗️ 创建表结构...")
# 连接到新创建的数据库
target_engine = create_engine(db_url)
Base.metadata.create_all(bind=target_engine)
logger.info("✅ 数据库重建完成!所有表已创建。")
logger.info("📋 包含的表: " + ", ".join(Base.metadata.tables.keys()))
except Exception as e:
logger.error(f"❌ 重建数据库失败: {str(e)}")
raise
if __name__ == "__main__":
recreate_database()

View File

@@ -29,17 +29,34 @@ class GroupService:
if not group:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户所属组不存在")
if not group.current_version_file:
# 允许无版本的情况,或者使用默认版本
version_file = group.current_version_file
if not version_file or version_file == '-':
# 尝试查找任意一个版本,或者返回空
# 根据用户要求 "现在默认只有一个了"
# 这里可以列出 archives 目录下的第一个文件
import os
try:
archives_dir = "archives"
files = [f for f in os.listdir(archives_dir) if f.endswith('.zip')]
if files:
files.sort(reverse=True) # Assuming newer versions sort higher
version_file = files[0]
except:
pass
if not version_file or version_file == '-':
# 如果还是没有,则抛出异常,或者返回空 update
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="当前组无可用版本")
# Construct download URL (assuming base URL or relative path)
# In a real scenario, this might be from config
download_url = f"/download/{group.current_version_file}"
download_url = f"/download/{version_file}"
# Extract version from filename roughly or use file metadata if available
# Assuming filename format: plugin_v1.0.2.zip
version = "unknown"
filename = group.current_version_file
filename = version_file
if "v" in filename:
try:
# Simple extraction logic, can be improved

69
Server/create_user.py Normal file
View File

@@ -0,0 +1,69 @@
import sys
import os
# Add current directory to sys.path
sys.path.append(os.getcwd())
from app.db import SessionLocal
from app.services.auth_service import auth_service
from app.schemas.auth import UserRegister
from app.core.security import get_password_hash
from app.models.user import User
def create_user(username, password, email=None):
db = SessionLocal()
try:
# Check if user exists
existing = db.query(User).filter(User.username == username).first()
if existing:
print(f"❌ 用户名 '{username}' 已存在")
return
# Prepare registration data
# We bypass the code verification by not providing code,
# but we set email if provided.
# However, auth_service.register sets is_verified=False by default if code is missing.
# We might want to manually set is_verified=True after registration for convenience.
register_data = UserRegister(
username=username,
password=password,
confirm_password=password,
email=email,
device_id="local_script"
)
# Call register service
try:
token = auth_service.register(db, register_data)
print(f"✅ 用户 '{username}' 注册成功!")
# Manually verify the user for local convenience
user = db.query(User).filter(User.username == username).first()
if user:
user.is_verified = True
user.permissions = "admin" # Grant admin permissions for local test user
db.commit()
print(f"✅ 已自动验证邮箱并赋予 admin 权限")
except Exception as e:
print(f"❌ 注册失败: {e}")
finally:
db.close()
if __name__ == "__main__":
if len(sys.path) < 2:
print("Usage: python create_user.py [username] [password]")
username = "admin"
password = "password123"
email = "admin@example.com"
if len(sys.argv) > 1:
username = sys.argv[1]
if len(sys.argv) > 2:
password = sys.argv[2]
print(f"正在创建用户: {username} ...")
create_user(username, password, email)

View File

@@ -1,56 +1,103 @@
services:
server:
# ==================== 后端 API (FastAPI) ====================
backend:
build: .
container_name: designercep_server
container_name: designercep_backend
restart: unless-stopped
ports:
- "8000:8000"
- "8000:8000" # 映射端口到宿主机,让 Caddy 能访问
volumes:
# 挂载上传目录,持久化上传的文件
# 挂载应用代码 (确保热更新)
- ./app:/app/app
# 挂载归档目录
- ./archives:/app/archives
# 数据库文件(开发用)
- ./designercep.db:/app/designercep.db
environment:
# 从 .env 文件读取配置
- ENV=production
- PROJECT_NAME=DesignerCEP Backend
- PROJECT_NAME=DesignerCEP
- API_V1_STR=/api/v1
# 使用 MySQL 连接字符串
# 数据库
- DATABASE_URL=mysql+pymysql://designer_user:DesignerPass123!@db:3306/designer_db
- SECRET_KEY=change-me-in-production
# PyMySQL 连接配置 (用于 checkin/admin_config 等模块)
- DB_HOST=db
- DB_PORT=3306
- DB_USER=designer_user
- DB_PASSWORD=DesignerPass123!
- DB_NAME=designer_db
# 安全配置
- SECRET_KEY=${SECRET_KEY:-your-super-secret-key-change-this}
- ACCESS_TOKEN_EXPIRE_MINUTES=10080
# 允许的跨域来源
- ALLOWED_ORIGINS=https://backend.aidg168.uk,https://www.aidg168.uk,http://localhost:5173
# 邮箱配置
- SMTP_HOST=smtp.gmail.com
- SMTP_PORT=587
- SMTP_USER=ly1104803132@gmail.com
- SMTP_PASSWORD=wsfrpnmkojpsqdkk
- EMAILS_FROM_EMAIL=ly1104803132@gmail.com
# CORS
- ALLOWED_ORIGINS=https://app.aidg168.uk,https://backend.aidg168.uk,https://aidg168.uk
# 邮箱
- SMTP_HOST=${SMTP_HOST:-smtp.gmail.com}
- SMTP_PORT=${SMTP_PORT:-587}
- SMTP_USER=${SMTP_USER}
- SMTP_PASSWORD=${SMTP_PASSWORD}
- EMAILS_FROM_EMAIL=${EMAILS_FROM_EMAIL}
- EMAILS_FROM_NAME=Designer
depends_on:
db:
condition: service_healthy
restart: unless-stopped
networks:
- designer_net
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
interval: 30s
timeout: 10s
retries: 3
# ==================== MySQL 数据库 ====================
db:
image: mysql:8.0
container_name: designercep_db
restart: unless-stopped
ports:
- "3306:3306" # 允许从宿主机访问数据库
environment:
- MYSQL_ROOT_PASSWORD=RootSecretPass123!
- MYSQL_DATABASE=designer_db
- MYSQL_USER=designer_user
- MYSQL_PASSWORD=DesignerPass123!
volumes:
- db_data:/var/lib/mysql
restart: unless-stopped
- db_data2:/var/lib/mysql
- ./mysql.cnf:/etc/mysql/conf.d/custom.cnf:ro
networks:
- designer_net
healthcheck:
test: ["CMD", "mysqladmin" ,"ping", "-h", "localhost"]
timeout: 20s
retries: 10
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 10s
timeout: 5s
retries: 5
# ==================== phpMyAdmin ====================
phpmyadmin:
image: phpmyadmin/phpmyadmin
container_name: designercep_pma
restart: unless-stopped
ports:
- "3388:80"
environment:
- PMA_HOST=db
- PMA_PORT=3306
- MYSQL_ROOT_PASSWORD=zuowei1216! # 可选,用于自动登录
- UPLOAD_LIMIT=64M
depends_on:
- db
networks:
- designer_net
volumes:
db_data:
db_data2:
driver: local
networks:
designer_net:
driver: bridge

120
Server/init_db.py Normal file
View File

@@ -0,0 +1,120 @@
# -*- coding: utf-8 -*-
"""
数据库初始化脚本
功能:
1. 检查数据库连接
2. 创建所有定义的表(如果不存在)
3. 检查现有表的字段,如果缺失则自动添加
"""
import os
import sys
import logging
from sqlalchemy import create_engine, inspect, text
from app.core.config import settings
from app.db import Base
# 导入所有模型以确保它们被注册到 Base.metadata
from app.models import user, group, business, session
# 配置日志
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
def get_engine():
"""获取数据库引擎"""
# 优先使用环境变量中的配置,如果没有则构建
db_url = settings.DATABASE_URL
if not db_url:
host = os.getenv('DB_HOST', 'localhost')
port = os.getenv('DB_PORT', '3306')
user = os.getenv('DB_USER', 'root')
password = os.getenv('DB_PASSWORD', '')
db_name = os.getenv('DB_NAME', 'designer_db')
db_url = f"mysql+pymysql://{user}:{password}@{host}:{port}/{db_name}"
return create_engine(db_url)
def map_python_type_to_sql(col_type):
"""将 SQLAlchemy 类型映射为 MySQL 类型"""
type_str = str(col_type).lower()
if 'varchar' in type_str:
return type_str
if 'string' in type_str:
length = getattr(col_type, 'length', 255)
return f"varchar({length})"
if 'integer' in type_str or 'int' in type_str:
return "int"
if 'boolean' in type_str:
return "tinyint(1)"
if 'datetime' in type_str:
return "datetime"
if 'date' in type_str:
return "date"
if 'float' in type_str:
return "float"
if 'text' in type_str:
return "text"
return "varchar(255)" # 默认
def init_db():
logger.info("🔄 开始数据库初始化检查...")
try:
engine = get_engine()
inspector = inspect(engine)
# 1. 创建缺失的表
logger.info("📊 检查表结构...")
Base.metadata.create_all(bind=engine)
logger.info("✅ 基础表结构检查完成")
# 2. 检查并补充缺失的列
logger.info("🔍 检查缺失字段...")
existing_tables = inspector.get_table_names()
with engine.connect() as conn:
for table_name, table in Base.metadata.tables.items():
if table_name not in existing_tables:
continue
# 获取数据库中现有的列
existing_columns = [col['name'] for col in inspector.get_columns(table_name)]
# 检查模型定义的列
for column in table.columns:
if column.name not in existing_columns:
logger.info(f" 发现缺失字段: {table_name}.{column.name}")
# 构建 ALTER TABLE 语句
col_type = map_python_type_to_sql(column.type)
default_val = ""
# 处理默认值 (简化处理,只处理常见类型)
if column.default:
arg = column.default.arg
if isinstance(arg, (int, float, bool)):
if isinstance(arg, bool):
arg = 1 if arg else 0
default_val = f" DEFAULT {arg}"
elif isinstance(arg, str):
default_val = f" DEFAULT '{arg}'"
nullable = "NULL" if column.nullable else "NOT NULL"
if column.nullable and not default_val:
default_val = " DEFAULT NULL"
sql = f"ALTER TABLE {table_name} ADD COLUMN {column.name} {col_type} {nullable}{default_val};"
logger.info(f" 🚀 执行: {sql}")
conn.execute(text(sql))
conn.commit()
logger.info("✅ 数据库同步完成!")
except Exception as e:
logger.error(f"❌ 数据库初始化失败: {e}")
# 不抛出异常,以免阻断容器启动(如果是网络波动等临时问题)
# 但在生产环境中可能需要抛出
if __name__ == "__main__":
init_db()

170
Server/init_db.sql Normal file
View File

@@ -0,0 +1,170 @@
-- ==========================================
-- Database Initialization Script
-- Generated at: 2025-12-22 17:54:23.463367
-- ==========================================
-- 1. Select Database and Cleanup Tables
USE designer_db;
SET FOREIGN_KEY_CHECKS = 0;
DROP TABLE IF EXISTS check_in_config;
DROP TABLE IF EXISTS check_in_records;
DROP TABLE IF EXISTS features_config;
DROP TABLE IF EXISTS plugin_groups;
DROP TABLE IF EXISTS points_history;
DROP TABLE IF EXISTS vip_config;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS user_sessions;
SET FOREIGN_KEY_CHECKS = 1;
-- 2. Create Tables
CREATE TABLE check_in_config (
id INTEGER NOT NULL AUTO_INCREMENT,
consecutive_days INTEGER NOT NULL,
base_points INTEGER NOT NULL,
bonus_points INTEGER NOT NULL,
total_points INTEGER NOT NULL,
enabled BOOL,
created_at DATETIME DEFAULT now(),
updated_at DATETIME,
PRIMARY KEY (id),
UNIQUE (consecutive_days)
);
CREATE TABLE check_in_records (
id INTEGER NOT NULL AUTO_INCREMENT,
user_id INTEGER NOT NULL,
username VARCHAR(50) NOT NULL,
check_in_date DATE NOT NULL,
points_earned INTEGER NOT NULL,
consecutive_days INTEGER NOT NULL,
vip_multiplier FLOAT,
created_at DATETIME DEFAULT now(),
PRIMARY KEY (id)
);
CREATE TABLE features_config (
id INTEGER NOT NULL AUTO_INCREMENT,
feature_key VARCHAR(50) NOT NULL,
feature_name VARCHAR(100) NOT NULL,
category VARCHAR(50),
points_cost INTEGER,
vip_points_cost INTEGER,
svip_points_cost INTEGER,
enabled BOOL,
description TEXT,
created_at DATETIME DEFAULT now(),
updated_at DATETIME,
PRIMARY KEY (id)
);
CREATE TABLE plugin_groups (
id INTEGER NOT NULL AUTO_INCREMENT,
name VARCHAR(64) NOT NULL,
current_version_file VARCHAR(255),
comment TEXT,
PRIMARY KEY (id)
);
CREATE TABLE points_history (
id INTEGER NOT NULL AUTO_INCREMENT,
user_id INTEGER NOT NULL,
username VARCHAR(50) NOT NULL,
type VARCHAR(20) NOT NULL,
amount INTEGER NOT NULL,
balance INTEGER NOT NULL,
description VARCHAR(255),
created_at DATETIME DEFAULT now(),
PRIMARY KEY (id)
);
CREATE TABLE vip_config (
id INTEGER NOT NULL AUTO_INCREMENT,
vip_type VARCHAR(20) NOT NULL,
name VARCHAR(50) NOT NULL,
price FLOAT NOT NULL,
daily_quota INTEGER NOT NULL,
points_multiplier FLOAT,
enabled BOOL,
description TEXT,
created_at DATETIME DEFAULT now(),
updated_at DATETIME,
PRIMARY KEY (id),
UNIQUE (vip_type)
);
CREATE TABLE users (
id INTEGER NOT NULL AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
hashed_password VARCHAR(128) NOT NULL,
created_at DATETIME NOT NULL DEFAULT now(),
group_id INTEGER,
permissions TEXT,
expire_date DATETIME,
email VARCHAR(255),
is_verified BOOL,
verification_code VARCHAR(6),
reset_token VARCHAR(128),
reset_token_expire DATETIME,
nickname VARCHAR(50),
avatar VARCHAR(500),
points INTEGER,
level INTEGER,
vip_type VARCHAR(20),
vip_expire DATETIME,
vip_daily_quota INTEGER,
vip_quota_reset_date DATE,
total_check_in_days INTEGER,
consecutive_check_in INTEGER,
last_check_in_date DATE,
PRIMARY KEY (id),
FOREIGN KEY(group_id) REFERENCES plugin_groups (id)
);
CREATE TABLE user_sessions (
id INTEGER NOT NULL AUTO_INCREMENT,
user_id INTEGER NOT NULL,
device_id VARCHAR(128) NOT NULL,
active BOOL NOT NULL,
expires_at DATETIME,
created_at DATETIME NOT NULL DEFAULT now(),
login_at DATETIME,
logout_at DATETIME,
duration_seconds INTEGER,
last_seen_at DATETIME,
PRIMARY KEY (id),
FOREIGN KEY(user_id) REFERENCES users (id)
);
-- 3. Insert Initial Data
-- Default User Group
INSERT INTO plugin_groups (name, comment) VALUES ('default', 'Default User Group');
-- VIP Config
INSERT INTO vip_config (vip_type, name, price, daily_quota, points_multiplier) VALUES ('vip', 'VIP会员', 30.0, 20, 1.5);
INSERT INTO vip_config (vip_type, name, price, daily_quota, points_multiplier) VALUES ('svip', 'SVIP会员', 88.0, -1, 2.0);
-- Check-in Config
INSERT INTO checkin_config (consecutive_days, base_points, bonus_points, total_points) VALUES (1, 10, 0, 10);
INSERT INTO checkin_config (consecutive_days, base_points, bonus_points, total_points) VALUES (3, 10, 5, 15);
INSERT INTO checkin_config (consecutive_days, base_points, bonus_points, total_points) VALUES (7, 10, 20, 30);
-- Feature Config
INSERT INTO feature_configs (feature_key, feature_name, points_cost, category) VALUES ('ai_remove_bg', '智能抠图', 10, 'ai');
-- 4. Create Admin User (admin / password123)
INSERT INTO users (
username, hashed_password, email, is_verified, permissions,
group_id, nickname, level, vip_type, vip_expire,
created_at, points, total_check_in_days, consecutive_check_in, vip_daily_quota
) VALUES (
'admin',
'$2b$12$UsFjs3Jwn5BG7u/RJ1efNuTF4zIsjT.pSm1mQEBXGWKR.3Kakhpmq',
'admin@example.com',
1,
'admin,vip,svip',
(SELECT id FROM plugin_groups WHERE name = 'default' LIMIT 1),
'Administrator',
999,
'svip',
'2099-12-31 23:59:59',
NOW(),
0, 0, 0, 0
);

292
Server/init_full_db.py Normal file
View File

@@ -0,0 +1,292 @@
# -*- coding: utf-8 -*-
"""
完整数据库初始化脚本 (Local Execution)
功能:
1. 重建数据库DROP & CREATE DATABASE
2. 创建所有表结构
3. 创建默认数据用户组、VIP配置、签到配置、功能配置
4. 创建默认管理员账户 (admin/password123)
注意:此脚本设计为在本地或 Docker 容器内运行,直接连接 MySQL。
它会读取环境变量或使用默认配置。
"""
import logging
import os
import sys
import argparse
from datetime import datetime
from sqlalchemy.schema import CreateTable
# 确保可以将当前目录添加到 sys.path
sys.path.append(os.getcwd())
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
from sqlalchemy.engine.url import make_url
# 尝试导入应用模块
try:
from app.core.config import settings
from app.db import Base
from app.models import user, group, business, session
from app.models.user import User
from app.models.group import PluginGroup
from app.models.business import FeatureConfig, VipConfig, CheckInConfig
from app.core.security import get_password_hash
except ImportError:
print("❌ 无法导入应用模块,请确保在 Server 目录下运行此脚本")
print("示例: python scripts/init_full_db.py")
sys.exit(1)
# 配置日志
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__)
def generate_sql_file():
"""生成完整的初始化 SQL 文件"""
logger.info("📝 正在生成 SQL 文件 (init_db.sql) ...")
# 获取数据库引擎 (用于编译 SQL)
# 我们使用一个 mock engine因为我们只想要 SQL 语句
engine = create_engine("mysql+pymysql://", strategy="mock", executor=lambda sql, *args, **kwargs: print(sql.compile(dialect=engine.dialect)))
# 真正的 engine 用于 dialect 编译
compile_engine = create_engine("mysql+pymysql://")
sql_content = []
# 1. 准备数据库
sql_content.append("-- ==========================================")
sql_content.append("-- Database Initialization Script")
sql_content.append(f"-- Generated at: {datetime.now()}")
sql_content.append("-- ==========================================\n")
# 既然 DROP DATABASE 被禁用,我们切换到目标数据库,并尝试删除所有表
sql_content.append("-- 1. Select Database and Cleanup Tables")
sql_content.append("USE designer_db;\n")
# 获取所有表名并生成 DROP TABLE 语句
# 注意:为了处理外键约束,我们先禁用外键检查
sql_content.append("SET FOREIGN_KEY_CHECKS = 0;")
for table in Base.metadata.sorted_tables:
sql_content.append(f"DROP TABLE IF EXISTS {table.name};")
sql_content.append("SET FOREIGN_KEY_CHECKS = 1;\n")
# 2. 创建表结构
sql_content.append("-- 2. Create Tables")
for table in Base.metadata.sorted_tables:
create_table_sql = CreateTable(table).compile(compile_engine)
sql_content.append(str(create_table_sql).strip() + ";\n")
# 3. 插入初始数据
sql_content.append("-- 3. Insert Initial Data")
# 用户组
sql_content.append("-- Default User Group")
sql_content.append("INSERT INTO plugin_groups (name, comment) VALUES ('default', 'Default User Group');")
# VIP 配置
sql_content.append("-- VIP Config")
sql_content.append("INSERT INTO vip_config (vip_type, name, price, daily_quota, points_multiplier) VALUES ('vip', 'VIP会员', 30.0, 20, 1.5);")
sql_content.append("INSERT INTO vip_config (vip_type, name, price, daily_quota, points_multiplier) VALUES ('svip', 'SVIP会员', 88.0, -1, 2.0);")
# 签到配置
sql_content.append("-- Check-in Config")
sql_content.append("INSERT INTO checkin_config (consecutive_days, base_points, bonus_points, total_points) VALUES (1, 10, 0, 10);")
sql_content.append("INSERT INTO checkin_config (consecutive_days, base_points, bonus_points, total_points) VALUES (3, 10, 5, 15);")
sql_content.append("INSERT INTO checkin_config (consecutive_days, base_points, bonus_points, total_points) VALUES (7, 10, 20, 30);")
# 功能配置
sql_content.append("-- Feature Config")
sql_content.append("INSERT INTO feature_configs (feature_key, feature_name, points_cost, category) VALUES ('ai_remove_bg', '智能抠图', 10, 'ai');")
# 4. 创建管理员
sql_content.append("-- 4. Create Admin User (admin / password123)")
# 计算密码哈希 (这里直接计算一次固定的,避免每次运行不一样)
# password123 的 bcrypt hash (示例)
# 为了准确,我们还是用 python 算一下
admin_hash = get_password_hash("password123")
# 获取 default group id (假设是 1因为刚刚插入且是第一个)
sql_content.append(f"""
INSERT INTO users (
username, hashed_password, email, is_verified, permissions,
group_id, nickname, level, vip_type, vip_expire,
created_at, points, total_check_in_days, consecutive_check_in, vip_daily_quota
) VALUES (
'admin',
'{admin_hash}',
'admin@example.com',
1,
'admin,vip,svip',
(SELECT id FROM plugin_groups WHERE name = 'default' LIMIT 1),
'Administrator',
999,
'svip',
'2099-12-31 23:59:59',
NOW(),
0, 0, 0, 0
);
""")
# 写入文件
output_file = "init_db.sql"
with open(output_file, "w", encoding="utf-8") as f:
f.write("\n".join(sql_content))
logger.info(f"✅ SQL 文件已生成: {output_file}")
print(f"\nSQL 文件已生成: {os.path.abspath(output_file)}")
print("您可以直接在 phpMyAdmin 中导入此文件来初始化数据库。")
def get_db_url():
"""获取数据库连接 URL"""
# 优先使用 settings 中的配置
# db_url = settings.DATABASE_URL
# 用户指定的远程数据库
# Host: 103.97.201.136
# Port: 3388 (映射到了容器内的 3306)
# User: designer_user
# Pass: DesignerPass123!
db_url = "mysql+pymysql://designer_user:DesignerPass123!@103.97.201.136:3388/designer_db"
# 如果 settings 是默认的 sqlite尝试构建 mysql 连接(用于本地开发时的强制覆盖)
# if db_url.startswith("sqlite"):
# # 默认开发环境 Docker MySQL 配置
# db_url = "mysql+pymysql://designer_user:DesignerPass123!@localhost:3306/designer_db"
# logger.info(f"⚠️ 检测到 SQLite 配置,切换为默认 MySQL 配置: {db_url}")
logger.info(f"🔌 使用数据库配置: {db_url}")
return db_url
def recreate_database(engine, db_name):
"""重建数据库"""
logger.info(f"🗑️ 正在重建数据库: {db_name}...")
# 获取 root 连接(连接到 mysql 系统库或不指定库)
url = make_url(engine.url)
root_url = url.set(database='mysql')
# 使用 AUTOCOMMIT 隔离级别
root_engine = create_engine(root_url, isolation_level="AUTOCOMMIT")
with root_engine.connect() as conn:
conn.execute(text(f"DROP DATABASE IF EXISTS {db_name}"))
conn.execute(text(f"CREATE DATABASE {db_name} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"))
logger.info(f"✅ 数据库 {db_name} 重建完成")
def seed_initial_data(session):
"""填充初始数据"""
logger.info("🌱 正在填充初始数据...")
# 1. 默认用户组
if session.query(PluginGroup).filter(PluginGroup.name == "default").count() == 0:
logger.info(" - 创建默认用户组 'default'")
default_group = PluginGroup(name="default", comment="Default User Group")
session.add(default_group)
# 2. VIP 配置
if session.query(VipConfig).count() == 0:
logger.info(" - 创建 VIP 配置")
session.add(VipConfig(vip_type="vip", name="VIP会员", price=30.0, daily_quota=20, points_multiplier=1.5))
session.add(VipConfig(vip_type="svip", name="SVIP会员", price=88.0, daily_quota=-1, points_multiplier=2.0))
# 3. 签到配置
if session.query(CheckInConfig).count() == 0:
logger.info(" - 创建签到配置")
session.add(CheckInConfig(consecutive_days=1, base_points=10, bonus_points=0, total_points=10))
session.add(CheckInConfig(consecutive_days=3, base_points=10, bonus_points=5, total_points=15))
session.add(CheckInConfig(consecutive_days=7, base_points=10, bonus_points=20, total_points=30))
# 4. 功能配置
if session.query(FeatureConfig).count() == 0:
logger.info(" - 创建功能配置")
session.add(FeatureConfig(feature_key="ai_remove_bg", feature_name="智能抠图", points_cost=10, category="ai"))
session.commit()
logger.info("✅ 初始数据填充完成")
def create_admin_user(session):
"""创建管理员用户"""
username = "admin"
password = "123456"
email = "admin@example.com"
existing = session.query(User).filter(User.username == username).first()
if existing:
logger.info(f" 管理员用户 '{username}' 已存在,跳过创建")
return
logger.info(f"👤 正在创建管理员用户: {username} ...")
# 获取默认组
default_group = session.query(PluginGroup).filter(PluginGroup.name == "default").first()
group_id = default_group.id if default_group else None
new_user = User(
username=username,
hashed_password=get_password_hash(password),
email=email,
is_verified=True,
permissions="admin,vip,svip", # 赋予所有权限
group_id=group_id,
nickname="Administrator",
level=999,
vip_type="svip",
vip_expire=datetime(2099, 12, 31)
)
session.add(new_user)
session.commit()
logger.info(f"✅ 管理员用户创建成功!(User: {username}, Pass: {password})")
def main():
parser = argparse.ArgumentParser(description='Initialize DesignerCEP Database')
parser.add_argument('--generate-sql', action='store_true', help='Generate init_db.sql file only')
args = parser.parse_args()
if args.generate_sql:
generate_sql_file()
return
logger.info("🚀 开始全量数据库初始化流程")
try:
db_url = get_db_url()
engine = create_engine(db_url)
# 1. 重建数据库
# 注意:这会删除现有数据!
db_name = make_url(db_url).database
recreate_database(engine, db_name)
# 重新连接到新创建的数据库
target_engine = create_engine(db_url)
# 2. 创建表结构
logger.info("🏗️ 正在创建表结构...")
Base.metadata.create_all(bind=target_engine)
logger.info("✅ 表结构创建完成")
# 3. 数据填充
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=target_engine)
session = SessionLocal()
try:
seed_initial_data(session)
create_admin_user(session)
finally:
session.close()
logger.info("✨✨✨ 数据库初始化全部完成! ✨✨✨")
except Exception as e:
logger.error(f"❌ 初始化失败: {str(e)}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,139 @@
-- ============================================================
-- DesignerCEP 数据库迁移脚本
-- 版本: 001
-- 功能: 添加积分、VIP、签到系统
-- ============================================================
-- ========== 1. 扩展 users 表 ==========
ALTER TABLE users
ADD COLUMN nickname VARCHAR(50) DEFAULT NULL COMMENT '昵称',
ADD COLUMN avatar VARCHAR(500) DEFAULT NULL COMMENT '头像URL',
ADD COLUMN points INT DEFAULT 0 COMMENT '积分余额',
ADD COLUMN level INT DEFAULT 1 COMMENT '用户等级',
ADD COLUMN vip_type ENUM('none', 'vip', 'svip') DEFAULT 'none' COMMENT 'VIP类型',
ADD COLUMN vip_expire DATETIME DEFAULT NULL COMMENT 'VIP过期时间',
ADD COLUMN vip_daily_quota INT DEFAULT 0 COMMENT 'VIP每日剩余配额',
ADD COLUMN vip_quota_reset_date DATE DEFAULT NULL COMMENT 'VIP配额重置日期',
ADD COLUMN total_check_in_days INT DEFAULT 0 COMMENT '累计签到天数',
ADD COLUMN consecutive_check_in INT DEFAULT 0 COMMENT '连续签到天数',
ADD COLUMN last_check_in_date DATE DEFAULT NULL COMMENT '最后签到日期';
-- ========== 2. 功能配置表 ==========
CREATE TABLE IF NOT EXISTS features_config (
id INT PRIMARY KEY AUTO_INCREMENT,
feature_key VARCHAR(50) UNIQUE NOT NULL COMMENT '功能唯一标识',
feature_name VARCHAR(100) NOT NULL COMMENT '功能显示名称',
category VARCHAR(50) DEFAULT 'general' COMMENT '功能分类',
points_cost INT NOT NULL DEFAULT 0 COMMENT '普通用户积分消耗',
vip_points_cost INT DEFAULT 0 COMMENT 'VIP用户积分消耗',
svip_points_cost INT DEFAULT 0 COMMENT 'SVIP用户积分消耗',
enabled TINYINT(1) DEFAULT 1 COMMENT '是否启用',
description TEXT COMMENT '功能描述',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_feature_key (feature_key),
INDEX idx_enabled (enabled),
INDEX idx_category (category)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='功能配置表';
-- 初始数据
INSERT INTO features_config (feature_key, feature_name, category, points_cost, vip_points_cost, svip_points_cost, description) VALUES
('ai_color_match', 'AI智能配色', 'ai', 50, 0, 0, '使用AI生成智能配色方案'),
('smart_layer_naming', '智能图层命名', 'layer', 30, 0, 0, '自动为图层生成语义化命名'),
('batch_export', '批量导出图层', 'export', 100, 0, 0, '批量导出多个图层为文件'),
('remove_background', '智能抠图', 'ai', 80, 0, 0, '使用AI自动去除背景'),
('style_transfer', '风格迁移', 'ai', 120, 0, 0, 'AI艺术风格转换');
-- ========== 3. VIP配置表 ==========
CREATE TABLE IF NOT EXISTS vip_config (
id INT PRIMARY KEY AUTO_INCREMENT,
vip_type ENUM('vip', 'svip') NOT NULL UNIQUE,
name VARCHAR(50) NOT NULL COMMENT '套餐名称',
price DECIMAL(10,2) NOT NULL COMMENT '价格(元/月)',
daily_quota INT NOT NULL COMMENT '每日免费配额(-1=无限)',
points_multiplier DECIMAL(3,2) DEFAULT 1.00 COMMENT '签到积分倍数',
enabled TINYINT(1) DEFAULT 1,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='VIP配置表';
INSERT INTO vip_config (vip_type, name, price, daily_quota, points_multiplier, description) VALUES
('vip', 'VIP会员', 30.00, 20, 1.50, '每日20次免费使用签到积分x1.5'),
('svip', 'SVIP会员', 88.00, -1, 2.00, '无限次免费使用签到积分x2');
-- ========== 4. 签到配置表 ==========
CREATE TABLE IF NOT EXISTS check_in_config (
id INT PRIMARY KEY AUTO_INCREMENT,
consecutive_days INT NOT NULL UNIQUE COMMENT '连续天数',
base_points INT NOT NULL COMMENT '基础积分',
bonus_points INT NOT NULL COMMENT '奖励积分',
total_points INT NOT NULL COMMENT '总积分',
enabled TINYINT(1) DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_consecutive_days (consecutive_days)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='签到奖励配置表';
INSERT INTO check_in_config (consecutive_days, base_points, bonus_points, total_points) VALUES
(1, 10, 0, 10),
(3, 10, 5, 15),
(7, 10, 20, 30),
(15, 10, 40, 50),
(30, 10, 90, 100);
-- ========== 5. 签到记录表 ==========
CREATE TABLE IF NOT EXISTS check_in_records (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
username VARCHAR(50) NOT NULL,
check_in_date DATE NOT NULL,
points_earned INT NOT NULL COMMENT '获得积分',
consecutive_days INT NOT NULL COMMENT '连续天数',
vip_multiplier DECIMAL(3,2) DEFAULT 1.00 COMMENT 'VIP倍数',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uk_user_date (username, check_in_date),
INDEX idx_user_id (user_id),
INDEX idx_date (check_in_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='签到记录表';
-- ========== 6. 积分历史表 ==========
CREATE TABLE IF NOT EXISTS points_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
username VARCHAR(50) NOT NULL,
type ENUM('checkin', 'consume', 'reward', 'refund', 'admin') NOT NULL,
amount INT NOT NULL COMMENT '积分变动(正数=获得,负数=消费)',
balance INT NOT NULL COMMENT '变动后余额',
feature_key VARCHAR(50) DEFAULT NULL COMMENT '关联功能key',
description VARCHAR(200),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_username (username),
INDEX idx_type (type),
INDEX idx_feature_key (feature_key),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='积分历史表';
-- ========== 7. 功能使用日志表 ==========
CREATE TABLE IF NOT EXISTS feature_usage_logs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
username VARCHAR(50) NOT NULL,
feature_key VARCHAR(50) NOT NULL,
cost_type ENUM('free', 'vip_quota', 'points') NOT NULL,
points_cost INT DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_username (username),
INDEX idx_feature_key (feature_key),
INDEX idx_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='功能使用日志表';
-- ========== 8. 创建迁移记录表 ==========
CREATE TABLE IF NOT EXISTS migrations (
id INT PRIMARY KEY AUTO_INCREMENT,
version VARCHAR(20) NOT NULL UNIQUE,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO migrations (version) VALUES ('001_add_points_vip_checkin');

21
Server/mysql.cnf Normal file
View File

@@ -0,0 +1,21 @@
# MySQL 自定义配置
[mysqld]
# 字符集配置
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
# 性能优化
max_connections=200
innodb_buffer_pool_size=256M
innodb_log_file_size=64M
# 时区配置
default-time-zone='+08:00'
[mysql]
default-character-set=utf8mb4
[client]
default-character-set=utf8mb4

View File

@@ -1,13 +1,14 @@
fastapi
uvicorn[standard]
uvicorn
sqlalchemy
pymysql
pydantic
pydantic-settings
python-multipart
SQLAlchemy>=1.4
python-jose[cryptography]
passlib[bcrypt]
bcrypt==4.0.1
PyJWT
pytest
httpx
pymysql
cryptography
bcrypt==3.2.0
email-validator
requests
alembic>=1.13.0
pyjwt

View File

@@ -1,84 +0,0 @@
"""
用户行为分析 API
记录和分析用户操作
"""
from fastapi import APIRouter, HTTPException, Depends
from pydantic import BaseModel
from typing import Optional, Any
from datetime import datetime
import json
router = APIRouter(prefix="/analytics", tags=["analytics"])
# 简单的内存存储(生产环境应该用数据库)
action_logs = []
class ActionLog(BaseModel):
username: str
device_id: str
action: str
details: Optional[Any] = None
timestamp: int
session_id: str
class ActionLogResponse(BaseModel):
success: bool
message: str
@router.post("/log", response_model=ActionLogResponse)
async def log_action(log: ActionLog):
"""
记录用户行为
"""
try:
# 添加服务器时间
log_entry = {
**log.dict(),
"server_time": datetime.now().isoformat(),
"ip": "unknown" # 可以从请求中获取
}
action_logs.append(log_entry)
# 只保留最近 10000 条
if len(action_logs) > 10000:
action_logs.pop(0)
# 可以在这里添加异常检测逻辑
# 例如:检测同一用户短时间内的大量操作
return ActionLogResponse(success=True, message="已记录")
except Exception as e:
return ActionLogResponse(success=False, message=str(e))
@router.get("/stats/{username}")
async def get_user_stats(username: str):
"""
获取用户统计信息
"""
user_logs = [log for log in action_logs if log.get("username") == username]
# 统计各操作类型的次数
action_counts = {}
for log in user_logs:
action = log.get("action", "unknown")
action_counts[action] = action_counts.get(action, 0) + 1
return {
"username": username,
"total_actions": len(user_logs),
"action_counts": action_counts,
"recent_actions": user_logs[-10:] # 最近 10 条
}
@router.get("/recent")
async def get_recent_logs(limit: int = 100):
"""
获取最近的操作日志(管理员用)
"""
return {
"total": len(action_logs),
"logs": action_logs[-limit:]
}

35
Server/start.sh Normal file
View File

@@ -0,0 +1,35 @@
#!/bin/bash
# 确保在脚本出错时退出
set -e
echo "🚀 Starting DesignerCEP Backend..."
# 0. 数据库迁移 (暂时禁用,避免与手动建表冲突)
echo "🔄 Skipping Alembic Migrations (Manual SQL setup assumed)..."
# if [ -z "$(ls -A alembic/versions 2>/dev/null)" ]; then
# echo "🆕 Initializing database migrations..."
# alembic revision --autogenerate -m "Initial migration"
# fi
# alembic upgrade head
# 1. 强制重建数据库 (测试环境专用 - 可选)
# 用户之前要求每次启动都重新初始化数据库,如果 Alembic 工作正常,可以考虑用 downgrade base 再 upgrade head
# 但为了保持现有行为,我们先保留 recreate_db或者根据用户意愿调整。
# 既然有了 Alembicrecreate_db 可能就太暴力了。
# 如果用户坚持每次都要“新”环境,可以:
# alembic downgrade base
# alembic upgrade head
# echo "💥 Force Recreating Database (Test Mode)..."
# python -m app.recreate_db
# 2. 创建默认管理员用户 (如果不存在)
# 既然已经通过 SQL 脚本初始化了管理员,这里可以注释掉,避免重复创建或报错
# echo "👤 Ensuring admin user exists..."
# python create_user.py admin password123 admin@example.com
# 3. 启动应用
echo "🌐 Starting FastAPI server..."
# 使用 uvicorn 启动host 0.0.0.0 允许外部访问,端口 8000
exec uvicorn app.main:app --host 0.0.0.0 --port 8000

View File

@@ -1,154 +0,0 @@
import os
import sys
import shutil
# 将 Server 目录加入 sys.path方便导入 app 包
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
os.environ["DATABASE_URL"] = "sqlite:///./test_api.db"
from fastapi.testclient import TestClient
from app.main import app
from app.db import init_db, Base, engine
from app.models.group import PluginGroup
from app.models.user import User
from app.core.security import get_password_hash
from sqlalchemy.orm import Session
from datetime import datetime, timedelta, timezone
client = TestClient(app)
ADMIN_TOKEN = "admin-secret-token"
def setup_db():
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
init_db()
def test_admin_create_group():
setup_db()
# 1. Create Group
response = client.post(
"/api/v1/admin/groups",
json={"name": "Dev Group", "comment": "For developers"},
headers={"x-admin-token": ADMIN_TOKEN} # Although my impl uses manual check, let's see if I need to adjust headers or form
)
# Note: My implementation of admin.py uses `token: str = Form(...)` for upload, but depends on logic for others?
# Let's check admin.py again.
# `create_group` does NOT have `token` dependency in signature explicitly in my code snippet!
# I should probably fix that security hole, but for now I test as implemented.
assert response.status_code == 200
data = response.json()
assert data["name"] == "Dev Group"
assert data["id"] is not None
return data["id"]
def test_admin_upload_and_assign_version():
setup_db()
# Create dummy zip file
os.makedirs("archives", exist_ok=True)
with open("test_plugin_v1.0.zip", "w") as f:
f.write("dummy content")
# 1. Upload
with open("test_plugin_v1.0.zip", "rb") as f:
response = client.post(
"/api/v1/admin/upload_version",
files={"file": ("plugin_v1.0.zip", f, "application/zip")},
data={"token": ADMIN_TOKEN} # This one requires token form
)
assert response.status_code == 200
assert response.json()["filename"] == "plugin_v1.0.zip"
# 2. Create Group
g_res = client.post("/api/v1/admin/groups", json={"name": "Stable"})
group_id = g_res.json()["id"]
# 3. Update Group with version
u_res = client.put(
f"/api/v1/admin/groups/{group_id}",
json={"current_version_file": "plugin_v1.0.zip"}
)
assert u_res.status_code == 200
assert u_res.json()["current_version_file"] == "plugin_v1.0.zip"
# Cleanup
if os.path.exists("test_plugin_v1.0.zip"):
os.remove("test_plugin_v1.0.zip")
if os.path.exists("archives/plugin_v1.0.zip"):
os.remove("archives/plugin_v1.0.zip")
def test_client_check_update_flow():
setup_db()
# Setup: Group, Version, User
# 1. Group
g_res = client.post("/api/v1/admin/groups", json={"name": "Beta", "current_version_file": "plugin_v2.0_beta.zip"})
group_id = g_res.json()["id"]
# 2. User (Manual DB insert or Register then Admin assign)
# Register
client.post("/api/v1/auth/register", json={"username": "tester", "password": "123", "confirm_password": "123"})
# Get User ID (hacky way via login or DB)
# Let's just use DB session for setup convenience
with Session(engine) as db:
user = db.query(User).filter(User.username == "tester").first()
user_id = user.id
# Assign Group
user.group_id = group_id
# Set expiry future
user.expire_date = datetime.now(timezone.utc) + timedelta(days=30)
db.commit()
# 3. Client Check Update
res = client.post("/api/v1/client/check_update", json={"username": "tester"})
assert res.status_code == 200
data = res.json()["data"]
assert data["version"] == "v2.0" # logic in service splits by 'v' and '_'
assert "plugin_v2.0_beta.zip" in data["download_url"]
assert data["is_expired"] == False
def test_client_login_returns_permissions():
setup_db()
# Setup User with permissions
with Session(engine) as db:
user = User(
username="vip_user",
hashed_password=get_password_hash("123"),
permissions="export,batch",
expire_date=datetime(2099, 1, 1)
)
db.add(user)
db.commit()
# Login
res = client.post("/api/v1/client/login", json={"username": "vip_user", "password": "123", "device_id": "d1"})
assert res.status_code == 200
data = res.json()["data"]
assert "export" in data["permissions"]
assert "batch" in data["permissions"]
assert data["expire_date"] == "2099-01-01"
def test_check_update_expired():
setup_db()
g_res = client.post("/api/v1/admin/groups", json={"name": "G1", "current_version_file": "f.zip"})
gid = g_res.json()["id"]
with Session(engine) as db:
user = User(
username="expired_user",
hashed_password=get_password_hash("123"),
group_id=gid,
expire_date=datetime(2020, 1, 1) # Past
)
db.add(user)
db.commit()
res = client.post("/api/v1/client/check_update", json={"username": "expired_user"})
assert res.status_code == 200
assert res.json()["data"]["is_expired"] == True

View File

@@ -1,102 +0,0 @@
import os
import sys
# 将 Server 目录加入 sys.path方便导入 app 包
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
os.environ["DATABASE_URL"] = "sqlite:///./test_auth.db"
from fastapi.testclient import TestClient
from app.main import app
from app.db import init_db, Base, engine
from app.models.session import UserSession
from sqlalchemy.orm import Session as OrmSession
import time
client = TestClient(app)
def test_register_and_login_single_device():
# 使用测试数据库,清理旧文件并初始化表结构
# 为避免 Windows 文件锁问题,不直接删除文件,改为重建表
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
init_db()
# 注册
r = client.post("/api/v1/auth/register", json={"username": "alice", "password": "secret123", "confirm_password": "secret123"})
assert r.status_code == 200
data = r.json()
assert "access_token" in data and data["token_type"] == "bearer" and data["username"] == "alice"
# 登录设备A
l = client.post("/api/v1/auth/login", json={"username": "alice", "password": "secret123", "device_id": "devA"})
assert l.status_code == 200
ldata = l.json()
assert "access_token" in ldata and ldata["username"] == "alice"
# 设备B尝试登录因设备A已在线应返回 403中文错误信息
l2 = client.post("/api/v1/auth/login", json={"username": "alice", "password": "secret123", "device_id": "devB"})
assert l2.status_code == 403
assert l2.json()["detail"] == "该账号已在其他设备在线"
# 设备A登出
out = client.post("/api/v1/auth/logout", json={"username": "alice", "device_id": "devA"})
assert out.status_code == 200
assert out.json()["detail"] == "已退出登录"
# 设备B再次登录应成功
l3 = client.post("/api/v1/auth/login", json={"username": "alice", "password": "secret123", "device_id": "devB"})
assert l3.status_code == 200
def test_login_wrong_password_returns_chinese_error():
# 初始化干净数据库(重建表避免文件锁)
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
init_db()
# 注册
r = client.post("/api/v1/auth/register", json={"username": "bob", "password": "secret123", "confirm_password": "secret123"})
assert r.status_code == 200
# 错误密码登录
l = client.post("/api/v1/auth/login", json={"username": "bob", "password": "wrong", "device_id": "devX"})
assert l.status_code == 401
assert l.json()["detail"] == "用户名或密码错误"
def test_online_time_endpoint_and_duration_record():
# 准备干净库
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
init_db()
# 注册并登录
r = client.post("/api/v1/auth/register", json={"username": "carol", "password": "p@ss", "confirm_password": "p@ss"})
assert r.status_code == 200
l = client.post("/api/v1/auth/login", json={"username": "carol", "password": "p@ss", "device_id": "D1"})
assert l.status_code == 200
# 查询在线时长(活跃会话应 >= 0
s1 = client.get("/api/v1/auth/online-time/carol")
assert s1.status_code == 200
body = s1.json()
assert body["username"] == "carol"
assert body["active_seconds"] >= 0
# 登出后,累计时长应 >= 0活跃时长为 0
out = client.post("/api/v1/auth/logout", json={"username": "carol", "device_id": "D1"})
assert out.status_code == 200
s2 = client.get("/api/v1/auth/online-time/carol")
assert s2.status_code == 200
body2 = s2.json()
assert body2["total_seconds"] >= 0
assert body2["active_seconds"] == 0
def test_heartbeat_updates_active_seconds_without_logout():
# 干净库
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
init_db()
# 登录
r = client.post("/api/v1/auth/register", json={"username": "dave", "password": "p@ss", "confirm_password": "p@ss"})
assert r.status_code == 200
l = client.post("/api/v1/auth/login", json={"username": "dave", "password": "p@ss", "device_id": "D1"})
assert l.status_code == 200
# 初次查询
s1 = client.get("/api/v1/auth/online-time/dave")
v1 = s1.json()["active_seconds"]
time.sleep(1)
# 心跳更新
hb = client.post("/api/v1/auth/heartbeat", json={"username": "dave", "device_id": "D1"})
assert hb.status_code == 200
# 再次查询,活跃时长应增加
s2 = client.get("/api/v1/auth/online-time/dave")
v2 = s2.json()["active_seconds"]
assert v2 >= v1 + 1

View File

@@ -1,158 +0,0 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.main import app
from app.db import Base, get_db
from app.models.group import PluginGroup
from app.services.email_service import email_service
from unittest.mock import MagicMock
# Mock Email Service to avoid sending real emails
email_service.send_email = MagicMock()
# In-memory SQLite database for testing
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def override_get_db():
try:
db = TestingSessionLocal()
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
@pytest.fixture(autouse=True)
def setup_db():
Base.metadata.create_all(bind=engine)
# Create default group
db = TestingSessionLocal()
if not db.query(PluginGroup).filter(PluginGroup.name == "default").first():
default_group = PluginGroup(name="default", comment="Default Group")
db.add(default_group)
db.commit()
db.close()
yield
Base.metadata.drop_all(bind=engine)
def test_register_with_email():
# 1. Register
response = client.post(
"/api/v1/auth/register",
json={
"username": "testuser_email",
"password": "password123",
"confirm_password": "password123",
"email": "test@example.com",
"device_id": "test_device"
},
)
assert response.status_code == 200
data = response.json()
assert "access_token" in data
assert data["username"] == "testuser_email"
# Check if verification email was "sent"
assert email_service.send_email.called
def test_verify_email():
# 1. Register first
client.post(
"/api/v1/auth/register",
json={
"username": "verify_user",
"password": "password123",
"confirm_password": "password123",
"email": "verify@example.com",
"device_id": "test_device"
},
)
# 2. Get code from DB (since we mocked email)
db = TestingSessionLocal()
from app.models.user import User
user = db.query(User).filter(User.username == "verify_user").first()
code = user.verification_code
assert code is not None
assert user.is_verified is False
db.close()
# 3. Verify
response = client.post(
"/api/v1/auth/verify-email",
json={
"username": "verify_user",
"code": code
}
)
assert response.status_code == 200
assert response.json()["detail"] == "验证成功"
# 4. Check DB status
db = TestingSessionLocal()
user = db.query(User).filter(User.username == "verify_user").first()
assert user.is_verified is True
db.close()
def test_forgot_password_flow():
# 1. Register
client.post(
"/api/v1/auth/register",
json={
"username": "reset_user",
"password": "old_password",
"confirm_password": "old_password",
"email": "reset@example.com",
"device_id": "test_device"
},
)
# 2. Request password reset
response = client.post(
"/api/v1/auth/forgot-password",
json={"email": "reset@example.com"}
)
assert response.status_code == 200
# 3. Get token from DB
db = TestingSessionLocal()
from app.models.user import User
user = db.query(User).filter(User.username == "reset_user").first()
token = user.reset_token
assert token is not None
db.close()
# 4. Reset password
response = client.post(
"/api/v1/auth/reset-password",
json={
"token": token,
"new_password": "new_password",
"confirm_password": "new_password"
}
)
assert response.status_code == 200
assert response.json()["detail"] == "密码重置成功"
# 5. Verify login with new password
response = client.post(
"/api/v1/auth/login",
json={
"username": "reset_user",
"password": "new_password",
"device_id": "test_device"
}
)
assert response.status_code == 200

View File

@@ -1,192 +0,0 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from unittest.mock import MagicMock
import sys
import os
# Add Server directory to path
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from app.main import app
from app.db import Base, get_db
from app.models.group import PluginGroup
from app.models.user import User
from app.services.email_service import email_service
# Mock Email Service
email_service.send_email = MagicMock()
# In-memory SQLite database
SQLALCHEMY_DATABASE_URL = "sqlite:///:memory:"
engine = create_engine(
SQLALCHEMY_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def override_get_db():
try:
db = TestingSessionLocal()
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_get_db
client = TestClient(app)
@pytest.fixture(autouse=True)
def setup_db():
Base.metadata.create_all(bind=engine)
# Create default group
db = TestingSessionLocal()
if not db.query(PluginGroup).filter(PluginGroup.name == "default").first():
default_group = PluginGroup(name="default", comment="Default Group")
db.add(default_group)
db.commit()
db.close()
yield
Base.metadata.drop_all(bind=engine)
# Reset mock
email_service.send_email.reset_mock()
def test_single_form_registration_flow():
email = "newuser@example.com"
password = "securepassword123"
username = "realusername"
# 1. Send verification code
response = client.post(
"/api/v1/auth/send-verification-code",
json={"email": email}
)
assert response.status_code == 200
assert response.json()["detail"] == "验证码已发送"
assert email_service.send_email.called
# 2. Retrieve code from DB
db = TestingSessionLocal()
temp_user = db.query(User).filter(User.email == email).first()
assert temp_user is not None
assert temp_user.verification_code is not None
assert len(temp_user.verification_code) == 6
code = temp_user.verification_code
db.close()
# 3. Try register with WRONG code
response = client.post(
"/api/v1/auth/register",
json={
"username": username,
"password": password,
"confirm_password": password,
"email": email,
"code": "000000",
"device_id": "test_device"
}
)
assert response.status_code == 400
assert response.json()["detail"] == "验证码错误"
# 4. Register with CORRECT code
response = client.post(
"/api/v1/auth/register",
json={
"username": username,
"password": password,
"confirm_password": password,
"email": email,
"code": code,
"device_id": "test_device"
}
)
assert response.status_code == 200
data = response.json()
assert data["username"] == username
assert "access_token" in data
# 5. Verify user status in DB
db = TestingSessionLocal()
user = db.query(User).filter(User.username == username).first()
assert user is not None
assert user.email == email
assert user.is_verified is True
# Ensure temp username is gone or updated (logic: update existing temp user)
# The logic in auth_service.register finds the user by email (which was the temp user)
# and updates username and password.
assert user.hashed_password != "temp_password_placeholder"
db.close()
def test_reset_password_flow():
# Setup: Create a verified user
db = TestingSessionLocal()
from app.core.security import get_password_hash
user = User(
username="resetuser",
email="reset@example.com",
hashed_password=get_password_hash("oldpassword"),
is_verified=True
)
db.add(user)
db.commit()
db.close()
email = "reset@example.com"
new_password = "newpassword123"
# 1. Request password reset (Forgot Password)
response = client.post(
"/api/v1/auth/forgot-password",
json={"email": email}
)
assert response.status_code == 200
assert email_service.send_email.called
# 2. Retrieve reset token (6-digit code) from DB
db = TestingSessionLocal()
user = db.query(User).filter(User.email == email).first()
assert user.reset_token is not None
assert len(user.reset_token) == 6
token = user.reset_token
db.close()
# 3. Reset password with code
response = client.post(
"/api/v1/auth/reset-password",
json={
"email": email,
"token": token,
"new_password": new_password,
"confirm_password": new_password
}
)
assert response.status_code == 200
assert response.json()["detail"] == "密码重置成功"
# 4. Login with new password
response = client.post(
"/api/v1/auth/login",
json={
"username": "resetuser",
"password": new_password,
"device_id": "test_device"
}
)
assert response.status_code == 200
assert "access_token" in response.json()
# 5. Login with old password should fail
response = client.post(
"/api/v1/auth/login",
json={
"username": "resetuser",
"password": "oldpassword",
"device_id": "test_device"
}
)
assert response.status_code == 401

View File

@@ -1,75 +0,0 @@
import os
import sys
import datetime
from datetime import timezone, timedelta
# 将 Server 目录加入 sys.path方便导入 app 包
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
os.environ["DATABASE_URL"] = "sqlite:///./test_verify.db"
from fastapi.testclient import TestClient
from app.main import app
from app.db import init_db, Base, engine, SessionLocal
from app.models.user import User
client = TestClient(app)
def test_verify_endpoint():
# Setup
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
init_db()
# 1. Register
client.post("/api/v1/auth/register", json={"username": "eve", "password": "pass", "confirm_password": "pass"})
# 2. Login
resp = client.post("/api/v1/auth/login", json={"username": "eve", "password": "pass", "device_id": "dev1"})
assert resp.status_code == 200
token = resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
# 3. Verify Success
verify_data = {
"username": "eve",
"device_id": "dev1",
"timestamp": 1234567890
}
resp = client.post("/api/v1/auth/verify", json=verify_data, headers=headers)
assert resp.status_code == 200
data = resp.json()
assert data["valid"] == True
assert data["username"] == "eve"
# 4. Verify Fail - Session not found (wrong device_id)
verify_data_bad_dev = {
"username": "eve",
"device_id": "dev2",
"timestamp": 1234567890
}
resp = client.post("/api/v1/auth/verify", json=verify_data_bad_dev, headers=headers)
assert resp.status_code == 404
assert "会话不存在" in resp.json()["detail"]
# 5. Verify Expiry
# Hack DB to set expire_date
db = SessionLocal()
user = db.query(User).filter(User.username == "eve").first()
# Expired yesterday
user.expire_date = datetime.datetime.now(timezone.utc) - timedelta(days=1)
db.commit()
db.close()
resp = client.post("/api/v1/auth/verify", json=verify_data, headers=headers)
assert resp.status_code == 200 # It returns 200 but valid=False per requirements
data = resp.json()
assert data["valid"] == False
assert data["expire_date"] is not None
# 6. Verify Token Invalid (401)
resp = client.post("/api/v1/auth/verify", json=verify_data, headers={"Authorization": "Bearer invalid_token"})
assert resp.status_code == 401
if __name__ == "__main__":
test_verify_endpoint()
print("All tests passed!")

View File

@@ -1,11 +0,0 @@
import sqlite3
conn = sqlite3.connect('designercep.db')
# Update plugin_groups table - column is current_version_file not version
conn.execute("UPDATE plugin_groups SET current_version_file = 'core-v1.3.1.zip' WHERE id=1")
conn.commit()
print('Updated Default group to core-v1.0.5.zip')
cursor = conn.execute('SELECT * FROM plugin_groups')
print(list(cursor))
conn.close()