Browse Source

完成登录鉴权

qyl 1 year ago
parent
commit
20ff604e22
18 changed files with 737 additions and 102 deletions
  1. 2 1
      .gitignore
  2. 34 0
      api/jwt.py
  3. 78 0
      api/login.py
  4. 36 0
      api/readme.md
  5. 28 0
      api/redis.py
  6. 109 0
      api/swl.http
  7. 25 2
      config.py
  8. 6 0
      db/common.py
  9. 19 5
      db/readme.md
  10. 127 10
      db/user.py
  11. 80 0
      douyin/access_token.py
  12. 43 0
      douyin/user_info.py
  13. 0 8
      douyin_openapi_web.http
  14. 36 69
      main.py
  15. 40 0
      readme.md
  16. 50 0
      test/account.py
  17. 13 0
      test/config.py
  18. 11 7
      待办事项.md

+ 2 - 1
.gitignore

@@ -1,2 +1,3 @@
 __pycache__
 __pycache__
-demo-release
+demo-release
+log

+ 34 - 0
api/jwt.py

@@ -0,0 +1,34 @@
+from fastapi import Depends, HTTPException, status, Header, Security  
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials  
+import jwt  
+from config import JWT_SECRET_KEY
+  
+async def get_token_from_header(authorization: str = Header(None)):  
+    if not authorization:  
+        raise HTTPException(  
+            status_code=status.HTTP_403_FORBIDDEN,  
+            detail="Not authenticated",  
+        )  
+    # 去掉 "Bearer " 前缀  
+    if not authorization.startswith("Bearer "):  
+        raise HTTPException(  
+            status_code=status.HTTP_403_FORBIDDEN,  
+            detail="Invalid authentication scheme",  
+        )  
+    return authorization.replace("Bearer ", "")  
+  
+async def verify_jwt_token(token: str = Security(get_token_from_header)):  
+    try:  
+        payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=["HS256"])  
+        return {"sub": payload.get("sub")}  
+    except jwt.ExpiredSignatureError:  
+        raise HTTPException(  
+            status_code=status.HTTP_403_FORBIDDEN,  
+            detail="Token is expired",  
+        )  
+    except jwt.InvalidTokenError:  
+        raise HTTPException(  
+            status_code=status.HTTP_403_FORBIDDEN,  
+            detail="Invalid token",  
+        )  
+  

+ 78 - 0
api/login.py

@@ -0,0 +1,78 @@
+import datetime
+import os
+import sys
+sys.path.append(os.path.dirname(os.path.dirname(__file__)))
+import jwt
+from fastapi import FastAPI,APIRouter, HTTPException, Depends, Request,Header
+from fastapi import Depends, FastAPI, HTTPException, status
+from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
+from pydantic import BaseModel
+from fastapi.responses import JSONResponse
+from config import *
+from douyin.access_token import get_access_token
+from douyin.user_info import get_user_info
+from db.user import UserOAuthRepository,UserOAuthToken
+from api.jwt import verify_jwt_token
+
+login_router = APIRouter()  
+
+class ScanCode(BaseModel):
+    code: str
+    scopes: str
+
+class User(BaseModel):
+    nickname: str
+    avatar: str
+
+        
+# 登录端点
+@login_router.post("/login")
+async def login(data: ScanCode):
+    if PRODUCE_ENV:
+        data = await get_access_token(data.code)
+    else:
+        # 测试环境使用。因为每次 get_access_token 的 code 只能使用一次就过期了,为了避免频繁扫码,直接模拟返回请求结果
+        data = {'access_token': 'act.3.UCzqnMwbL7uUTH0PkWbvDvIHcpy417HnfMqymbvBSpo9b1MJ3jOdwCxw-UPstOOjsGDWIdNwTGev4oEp8eUR-vHbU24XU5K4BkhPeOKJW1CLrEUS3XFxpG6SHqoQtvL6qhEgINcvt4V3KQX6C2qTeTkgQ-KwPO6jWi5uoin3YXo5DqwuGk3bbQ9dZoY=', 'captcha': '', 'desc_url': '', 'description': '', 'error_code': 0, 'expires_in': 1296000, 'log_id': '2024012915260549B5ED1A675515CD573C', 'open_id': '_000QadFMhmU1jNCI3JdPnyVDL6XavC70dFy', 'refresh_expires_in': 2592000, 'refresh_token': 'rft.c29d64456ea3d5e4c932247ee93dd735aq5OhtcYNXNFAD70XHKrdntpE6U0', 'scope': 'user_info,trial.whitelist'}
+    
+    if data.get("error_code") != 0:
+        return data
+    db_manager = UserOAuthRepository()
+    logger.debug(data)
+    db_manager.add_token(data)
+    
+    # 计算过期时间戳(基于北京时间)  
+    expires_in = data.get("expires_in", 0)  # 如果没有 expires_in 键,则默认过期时间为 0  
+    expires_in = 15
+    expiration_time_utc = datetime.datetime.utcnow() + datetime.timedelta(seconds=expires_in)  
+    beijing_timezone_delta = datetime.timedelta(hours=8)  # 北京时间是UTC+8  
+    expiration_time_beijing = expiration_time_utc + beijing_timezone_delta  
+    exp = int(expiration_time_beijing.timestamp())  
+    
+    # 生成并返回 token,包含过期时间  
+    payload = {  
+        "aud": data["open_id"],
+        "exp": exp  # 添加过期时间戳(北京时间)到 payload  
+    }  
+    account_token = jwt.encode(payload, JWT_SECRET_KEY, algorithm="HS256")  
+    logger.info(f"login success, expires_time:{datetime.datetime.fromtimestamp(exp).strftime('%Y-%m-%d %H:%M:%S') }, token:{account_token}")
+    return {"token": account_token}
+
+
+# 受保护资源示例
+@login_router.get("/account")
+async def read_account(user: dict = Depends(verify_jwt_token)): 
+    open_id = user.get("aud")
+    UserOAuthRepository().display_all_records()
+    logger.info(user.get("aud"))
+    return {"message": "Account information", "open_id": user.get("aud")}
+    # 在这里返回当前用户的信息
+    return {"nickname": current_user.username, "avatar": "https://p26.douyinpic.com/aweme/100x100/aweme-avatar/tos-cn-i-0813_66c4e34ae8834399bbf967c3d3c919db.jpeg?from=4010531038"}
+
+# 其他受保护的资源...
+
+# 启动应用
+def main():
+    pass
+
+if __name__ == "__main__":
+    main()

+ 36 - 0
api/readme.md

@@ -0,0 +1,36 @@
+# 获取抖音用户信息
+https://swl-8l9.pages.dev/ ,扫码登录后,FastAPi 接受到前端发来的请求:
+POST /login
+{  
+  "code": "936c3671e073703cnzV93iYzyWbdLIZmFPQJ",  
+  "scopes": "user_info,trial.whitelist"  
+}
+后端根据 code:
+- [获取抖音access_token](https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/account-permission/get-access-token) ,
+- [获取用户信息](https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/account-management/get-account-open-info)
+- 然后将得到的用户信息,存到数据库,同时返回给目标前端:
+```json
+{
+  "data": {
+    "avatar": "https://p26.douyinpic.com/aweme/100x100/aweme-avatar/tos-cn-i-0813_66c4e34ae8834399bbf967c3d3c919db.jpeg?from=4010531038",
+    "avatar_larger": "https://p3.douyinpic.com/aweme/1080x1080/aweme-avatar/tos-cn-i-0813_66c4e34ae8834399bbf967c3d3c919db.jpeg?from=4010531038",
+    "captcha": "",
+    "city": "",
+    "client_key": "aw6aipmfdtplwtyq",
+    "country": "",
+    "desc_url": "",
+    "description": "",
+    "district": "",
+    "e_account_role": "",
+    "error_code": 0,
+    "gender": 0,
+    "log_id": "202401261424326FE877A6CAB03910C553",
+    "nickname": "程序员马工",
+    "open_id": "_000QadFMhmU1jNCI3JdPnyVDL6XavC70dFy",
+    "province": "",
+    "union_id": "b138db97-01ae-59bd-978a-1de8566186a8"
+  },
+  "message": "success"
+}
+
+```

+ 28 - 0
api/redis.py

@@ -0,0 +1,28 @@
+import aioredis  
+from typing import Any, Optional  
+import time  
+  
+class RedisSession:  
+    def __init__(self, redis_url: str, session_expiry: int = 3600):  
+        self.redis_url = redis_url  
+        self.session_expiry = session_expiry  
+        self.redis = None  
+  
+    async def connect(self):  
+        self.redis = await aioredis.create_redis_pool(self.redis_url)  
+  
+    async def disconnect(self):  
+        if self.redis:  
+            self.redis.close()  
+            await self.redis.wait_closed()  
+  
+    async def get(self, key: str) -> Optional[Any]:  
+        value = await self.redis.get(key)  
+        return value.decode("utf-8") if value else None  
+  
+    async def set(self, key: str, value: Any, expiry: int = None):  
+        expiry = expiry or self.session_expiry  
+        await self.redis.set(key, value, ex=expiry)  
+  
+    async def delete(self, key: str):  
+        await self.redis.delete(key)

+ 109 - 0
api/swl.http

@@ -0,0 +1,109 @@
+# WEB扫码接入 参考 https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/sdk/web-app/web/permission
+# 打开链接,扫码登录 
+GET https://open.douyin.com/platform/oauth/connect/?client_key=aw6aipmfdtplwtyq&response_type=code&scope=user_info,renew_refresh_token,trial.whitelist&redirect_uri=https://open-douyin-cf.magong.site/verify_callback HTTP/1.1  
+# 前端收到扫码结果的回调信息
+GET /verify_callback?code=936c3671e073703ctIP3PeCLboHDFJnLn0u1&state=&scopes=user_info,trial.whitelist HTTP/1.1
+
+# 前端将扫码结果发送给后端
+POST http://192.168.1.32:8600/login
+OPTIONS https://open-douyin-cf.magong.site/login
+content-type: application/json
+
+{  
+  "code": "936c3671e073703cnzV93iYzyWbdLIZmFPQJ",  
+  "scopes": "user_info,trial.whitelist"  
+}
+
+# 后端向抖音请求 access_token
+POST https://open.douyin.com/oauth/access_token/ HTTP/1.1
+content-type: application/json
+
+{
+    "grant_type": "authorization_code",
+    "client_key": "aw6aipmfdtplwtyq",
+    "client_secret": "53cf3dcd2663629e8a773ab59df0968b",
+    "code": "936c3671e073703ctIP3PeCLboHDFJnLn0u1"
+}
+
+# 抖音正常返回
+{
+  "data": {
+    "access_token": "act.3.OL1oVB-gZEvJNrV4wNgL6zpfHXkuHDlibrebxKjOR2v8wDugsnriGocYam_dOT6xSTQUiI-G9b36OfJu24F0dvDoTc8Ctg2I_xb4vgV76G3Vl_KkyM41-I_kOjmg99qtBFOgtvcuTtERdNJ4VVcO8AebUCx3OhZa-YzGMX5xUZvQFCEAjnA-6Zm_3gQ=",
+    "captcha": "",
+    "desc_url": "",
+    "description": "",
+    "error_code": 0,
+    "expires_in": 1296000,
+    "log_id": "20240126140750AB750DD9D3CA8A0F2AA1",
+    "open_id": "_000QadFMhmU1jNCI3JdPnyVDL6XavC70dFy",
+    "refresh_expires_in": 2592000,
+    "refresh_token": "rft.c29d64456ea3d5e4c932247ee93dd735aq5OhtcYNXNFAD70XHKrdntpE6U0",
+    "scope": "user_info,trial.whitelist"
+  },
+  "message": "success"
+}
+
+# 抖音错误返回
+{
+  "data": {
+    "captcha": "",
+    "desc_url": "",
+    "description": "code已失效",
+    "error_code": 10007
+  },
+  "message": "error"
+}
+
+
+# 后端向抖音获取用户公开信息
+POST https://open.douyin.com/oauth/userinfo/
+content-type: application/x-www-form-urlencoded
+
+open_id=_000QadFMhmU1jNCI3JdPnyVDL6XavC70dFy
+&access_token=act.3.OL1oVB-gZEvJNrV4wNgL6zpfHXkuHDlibrebxKjOR2v8wDugsnriGocYam_dOT6xSTQUiI-G9b36OfJu24F0dvDoTc8Ctg2I_xb4vgV76G3Vl_KkyM41-I_kOjmg99qtBFOgtvcuTtERdNJ4VVcO8AebUCx3OhZa-YzGMX5xUZvQFCEAjnA-6Zm_3gQ=
+
+# 抖音公开信息返回给后端
+{
+  "data": {
+    "avatar": "https://p26.douyinpic.com/aweme/100x100/aweme-avatar/tos-cn-i-0813_66c4e34ae8834399bbf967c3d3c919db.jpeg?from=4010531038",
+    "avatar_larger": "https://p3.douyinpic.com/aweme/1080x1080/aweme-avatar/tos-cn-i-0813_66c4e34ae8834399bbf967c3d3c919db.jpeg?from=4010531038",
+    "captcha": "",
+    "city": "",
+    "client_key": "aw6aipmfdtplwtyq",
+    "country": "",
+    "desc_url": "",
+    "description": "",
+    "district": "",
+    "e_account_role": "",
+    "error_code": 0,
+    "gender": 0,
+    "log_id": "202401261424326FE877A6CAB03910C553",
+    "nickname": "程序员马工",
+    "open_id": "_000QadFMhmU1jNCI3JdPnyVDL6XavC70dFy",
+    "province": "",
+    "union_id": "b138db97-01ae-59bd-978a-1de8566186a8"
+  },
+  "message": "success"
+}
+
+curl --location --request POST 'https://open.douyin.com/oauth/userinfo/' \
+--header 'Content-Type: application/x-www-form-urlencoded' \
+--data-urlencode 'open_id=_000QadFMhmU1jNCI3JdPnyVDL6XavC70dFy' \
+--data-urlencode 'access_token=act.3.OL1oVB-gZEvJNrV4wNgL6zpfHXkuHDlibrebxKjOR2v8wDugsnriGocYam_dOT6xSTQUiI-G9b36OfJu24F0dvDoTc8Ctg2I_xb4vgV76G3Vl_KkyM41-I_kOjmg99qtBFOgtvcuTtERdNJ4VVcO8AebUCx3OhZa-YzGMX5xUZvQFCEAjnA-6Zm_3gQ='
+# 抖音公开信息返回给后端
+{"data":{"avatar":"https://p11.douyinpic.com/aweme/100x100/aweme-avatar/tos-cn-i-0813_66c4e34ae8834399bbf967c3d3c919db.jpeg?from=4010531038","avatar_larger":"https://p6.douyinpic.com/aweme/1080x1080/aweme-avatar/tos-cn-i-0813_66c4e34ae8834399bbf967c3d3c919db.jpeg?from=4010531038","captcha":"","city":"","client_key":"aw6aipmfdtplwtyq","country":"","desc_url":"","description":"","district":"","e_account_role":"","error_code":0,"gender":0,"log_id":"202401261422512B0A2C90ED713C0F7A35","nickname":"程序员马工","open_id":"_000QadFMhmU1jNCI3JdPnyVDL6XavC70dFy","province":"","union_id":"b138db97-01ae-59bd-978a-1de8566186a8"},"message":"success"}
+
+
+# 服务器收到信息
+POST /login
+
+
+GET /verify_callback?code=936c3671e073703cnzV93iYzyWbdLIZmFPQJ&state=&scopes=user_info,trial.whitelist HTTP/1.1
+
+{
+  "data": {
+    "avatar": "https://example.com/x.jpeg",
+    "nickname": "TestAccount",
+  },
+  "message": "success"
+}

+ 25 - 2
config.py

@@ -1,12 +1,35 @@
 
 
 import os
 import os
 import socket
 import socket
-
+import sys
+from loguru import logger
+WORK_DIR = os.path.dirname(__file__)
+# 是否为生产环境, None 则是调试环境(开发环境)
+PRODUCE_ENV = os.environ.get("PRODUCE_ENV", None)
+os.environ["JWT_SECRET_KEY"]="123"
+os.environ["DB_URL"]="postgresql://pg:pg@sv-v:5432/douyin"
 os.environ["CLIENT_KEY"] = 'aw6aipmfdtplwtyq'
 os.environ["CLIENT_KEY"] = 'aw6aipmfdtplwtyq'
 os.environ["CLIENT_SECRET"] = '53cf3dcd2663629e8a773ab59df0968b'
 os.environ["CLIENT_SECRET"] = '53cf3dcd2663629e8a773ab59df0968b'
+DOUYIN_OPEN_API="https://open.douyin.com"
+
 
 
 # HOST = socket.gethostbyname(socket.gethostname())
 # HOST = socket.gethostbyname(socket.gethostname())
 # 这个网址 https://open-douyin.magong.site 对应这台服务器的 192.168.1.32:8600 端口,因为这台服务器没有公网ip,所以在本地计算机无法通过  http://192.168.1.32:8600/ 访问到 fastapi 接口,只能通过 https://open-douyin.magong.site/ 访问
 # 这个网址 https://open-douyin.magong.site 对应这台服务器的 192.168.1.32:8600 端口,因为这台服务器没有公网ip,所以在本地计算机无法通过  http://192.168.1.32:8600/ 访问到 fastapi 接口,只能通过 https://open-douyin.magong.site/ 访问
 HOST = '::'
 HOST = '::'
 PORT = 8600
 PORT = 8600
-# print(HOST)
+JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]
+
+DB_URL=os.environ["DB_URL"]
+
+
+
+
+LOG_FILE = os.path.join(WORK_DIR,"log", "1.log")
+FORMAT = '<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{file}</cyan>:<cyan>{line}</cyan> :<cyan>{function}</cyan> - {message}'
+LOG_LEVEL = "DEBUG"
+logger.remove()
+# logger.add(sys.stderr, format='<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>')
+logger.add(sys.stderr, format=FORMAT)
+logger.add(LOG_FILE, format=FORMAT)
+
+logger.info("load config:", __file__)

+ 6 - 0
db/common.py

@@ -0,0 +1,6 @@
+from sqlmodel import SQLModel,create_engine
+from config import DB_URL
+
+# 创建引擎和仓储类实例  
+engine = create_engine(DB_URL)  # 替换成你的 DB_URL  
+SQLModel.metadata.create_all(engine)  

+ 19 - 5
db/readme.md

@@ -8,12 +8,9 @@ docker run -d --name citus -p 5432:5432 -e POSTGRES_USER=pg -e POSTGRES_PASSWORD
 ## sqlmodle 操作数据库
 ## sqlmodle 操作数据库
 see python code: ./db/
 see python code: ./db/
 
 
+## vscode 插件操作数据库
+点击侧边栏 - 图标名 database (如果没有安装需要先在插件市场安装) - 在上方菜单栏点击 “+” Add connection - Server Type: PostgreSQL - Host 192.168.1.31  - Port 5432 - Username pg - Password pg - Database douyin - Save
 
 
-## 界面操作数据库
-- 下载并安装  https://github.com/dbeaver/dbeaver/releases/tag/23.3.3
-- 左上角小图标 “新建数据库连接”  -  PostgreSQL - 主机:sv-v.magong.site (仅支持ipv6) 端口:5432 - 数据库: douyin - 用户名: pg  - 密码: pg 
-- (可选)显示所有数据库: 上方切换标签 PostgreSQL - 显示所有数据库连接 
-- 完成
 
 
 ## 命令行操作数据库
 ## 命令行操作数据库
 ```shell
 ```shell
@@ -43,6 +40,23 @@ export PGPASSWORD=pg
 pgcli -h sv-v -p 5432 -U pg -l
 pgcli -h sv-v -p 5432 -U pg -l
 ```
 ```
 
 
+## 界面操作数据库
+- 下载并安装  https://github.com/dbeaver/dbeaver/releases/tag/23.3.3
+- 左上角小图标 “新建数据库连接”  -  PostgreSQL - 主机:sv-v.magong.site (仅支持ipv6) 端口:5432 - 数据库: douyin - 用户名: pg  - 密码: pg 
+- (可选)显示所有数据库: 上方切换标签 PostgreSQL - 显示所有数据库连接 
+- 完成
+
+
+## sqlmodel连接PostgreSQL服务器方法
+```python
+db_params = {
+    'database_url': "postgresql://your_database_user:your_database_password@your_database_host:your_database_port/your_database_name"
+}
+
+engine = create_engine(db_params['database_url'])
+SQLModel.metadata.create_all(engine)
+```
+
 ## 参考文档
 ## 参考文档
 PostgreSQL 官网: https://github.com/postgres/postgres ,github 地址:https://github.com/postgres/postgres
 PostgreSQL 官网: https://github.com/postgres/postgres ,github 地址:https://github.com/postgres/postgres
 
 

+ 127 - 10
db/user.py

@@ -1,8 +1,16 @@
+from datetime import datetime
 from typing import Optional
 from typing import Optional
+import os
+import sys
+sys.path.append(os.path.dirname(os.path.dirname(__file__)))
 
 
-from sqlmodel import Field, SQLModel,create_engine,Session
-
-
+from sqlmodel import Field, SQLModel,create_engine,Session,select,func
+import psycopg2
+from config import DB_URL,logger
+from douyin.access_token import get_access_token
+# from db.common import engine
+from sqlalchemy import UniqueConstraint, Index
+from sqlalchemy.dialects.postgresql import insert
 
 
 # 定义数据库模型  
 # 定义数据库模型  
 class UserOAuthToken(SQLModel, table=True):  
 class UserOAuthToken(SQLModel, table=True):  
@@ -12,15 +20,124 @@ class UserOAuthToken(SQLModel, table=True):
     open_id:str
     open_id:str
     refresh_expires_in: Optional[int] = None
     refresh_expires_in: Optional[int] = None
     refresh_token:str
     refresh_token:str
+    scope: str
+    update_time: datetime = Field(default_factory=datetime.now)  # 添加时间戳字段  
+    __table_args__ = (UniqueConstraint('open_id'),) 
+
+class UserInfo(SQLModel, table=True):  
+    id: Optional[int] = Field(default=None, primary_key=True)  
+    avatar: str  
+    avatar_larger: str  
+    client_key: str  
+    e_account_role: str = Field(default="")  
+    nickname: str  
+    open_id: str  
+    union_id: str  
+    update_time: datetime = Field(default_factory=datetime.now)  
+    __table_args__ = (UniqueConstraint('open_id'),) 
+    
+    
+engine = create_engine(DB_URL)  # 替换成你的 DB_URL  
+SQLModel.metadata.create_all(engine)  
+
+class UserInfoRepository:  
+    def __init__(self, engine=engine):  
+        self.engine = engine  
+  
+    def create_user_info(self, user_info_data):  
+        # 剔除不需要的字段  
+        cleaned_data = {k: v for k, v in user_info_data.items() if k not in ["log_id", "error_code"]}  
+          
+        # 添加或更新时间戳  
+        cleaned_data['update_time'] = func.now()  
+  
+        with Session(self.engine) as session:  
+            # 使用 on_conflict_do_update 处理 open_id 的冲突  
+            insert_stmt = insert(UserInfo).values(**cleaned_data)  
+            update_stmt = insert_stmt.on_conflict_do_update(  
+                constraint="open_id",  # 使用 open_id 作为冲突约束  
+                set_={**{k: cleaned_data[k] for k in cleaned_data if k != "open_id"}, "update_time": func.now()}  # 更新其他字段,包括时间戳  
+            )  
+            result = session.exec(update_stmt)  
+            session.commit() 
+  
+    def get_user_info_by_open_id(self, open_id):  
+        with Session(self.engine) as session:  
+            statement = select(UserInfo).where(UserInfo.open_id == open_id)  
+            result = session.exec(statement)  
+            return result.first()  
+  
+    def update_user_info(self, user_id, user_info_data):  
+        with Session(self.engine) as session:  
+            update_user_info = session.get(UserInfo, user_id)  
+            if update_user_info:  
+                for key, value in user_info_data.items():  
+                    setattr(update_user_info, key, value)  
+                session.commit()  
+                return update_user_info  
+  
+    def delete_user_info(self, user_id):  
+        with Session(self.engine) as session:  
+            delete_user_info = session.get(UserInfo, user_id)  
+            if delete_user_info:  
+                session.delete(delete_user_info)  
+                session.commit()  
+        
+# Database manager class
+class UserOAuthRepository:
+    def __init__(self, engine=engine):
+        self.engine = engine
+
+    def add_token(self, data: dict):  
+        # 剔除不需要的字段  
+        cleaned_data = {  
+            k: v for k, v in data.items()  
+            if k not in ["log_id", "error_code", "captcha", "desc_url", "description"]  
+        }  
+          
+        # 添加或更新时间戳  
+        cleaned_data['update_time'] = func.now()  
+          
+        # 构造插入语句  
+        insert_stmt = insert(UserOAuthToken).values(**cleaned_data)  
+        update_stmt = insert_stmt.on_conflict_do_update(  
+            index_elements=['open_id'],  # 使用 open_id 作为冲突的目标列  
+            set_={  
+                **{k: insert_stmt.excluded[k] for k in cleaned_data if k != "open_id"},  
+                "update_time": func.now()  # 更新时间戳  
+            }  
+        )  
+          
+        # 执行插入/更新操作  
+        with Session(self.engine) as session:  
+            result = session.exec(update_stmt)  # 注意:这里应该是 execute 而不是 exec  
+            session.commit()  
+            logger.debug(f"Record added/updated: Access Token, Open ID - {cleaned_data['open_id']}")
+
 
 
+    def delete_token(self, token_id: int):
+        with Session(self.engine) as session:
+            token = session.get(UserOAuthToken, token_id)
+            if token:
+                session.delete(token)
+                session.commit()
+                print(f"Record deleted: ID - {token_id}")
+            else:
+                print(f"Record with ID {token_id} not found")
 
 
-test=UserOAuthToken(access_token="dfgfaha",open_id="dasfga",refresh_token="fagsdgas");
-DATABASE_URL = "postgresql:///test.db"
-engine = create_engine(DATABASE_URL)
-SQLModel.metadata.create_all(engine)
+    def display_all_records(self):
+        with Session(self.engine) as session:
+            statement = select(UserOAuthToken)
+            user_tokens = session.exec(statement).all()
+            return user_tokens
 
 
 
 
-with Session(engine) as session:
-    session.add(test)
-    session.commit()
+def main():
+    db_manager = UserOAuthRepository()
+    data = {'access_token': 'act.3.wl8L3DFQ3sj3uKYzQShOSs8HbOgKh0FVvjxKeaTum0ZOEXoyBI8D1N7gTBqGbrY32KP-Pm41EAvcobSheOBi8tvRdhj7m5-5ZVoprZZu_GN5J2KnH2fZ_X9_l7Q6iFyvpPoMkX3Zyom3PCkeRZp4Jg9sE2ZiwuvZVdnvft0A25uBWXvj2IEbWW_0Bf8=', 'captcha': '', 'desc_url': '', 'description': '', 'error_code': 0, 'expires_in': 1296000, 'log_id': '20240129123749239735B0529965BC6D93', 'open_id': '_000QadFMhmU1jNCI3JdPnyVDL6XavC70dFy', 'refresh_expires_in': 2592000, 'refresh_token': 'rft.c29d64456ea3d5e4c932247ee93dd735aq5OhtcYNXNFAD70XHKrdntpE6U0', 'scope': 'user_info,trial.whitelist'}
+    db_manager.add_token(data)
+    res = db_manager.display_all_records()
+    logger.debug(res)
 
 
+if __name__ == "__main__":
+    main()

+ 80 - 0
douyin/access_token.py

@@ -0,0 +1,80 @@
+import os
+import sys
+sys.path.append(os.path.dirname(os.path.dirname(__file__)))
+import httpx
+from config import logger
+from typing import Optional
+from pydantic import BaseModel
+
+class DouyinAccessTokenResponse(BaseModel):
+    error_code: int
+    data: dict
+    message: str
+
+async def check_access_token_response(response_json: dict):
+    model_data = DouyinAccessTokenResponse(**response_json)
+    if model_data.error_code != 0:
+        raise Exception(f"获取 access token 失败,错误码:{model_data.error_code}")
+    return model_data.data
+
+async def get_access_token(code):
+    client_key = os.environ.get("CLIENT_KEY")  # 从环境变量中获取 client_key  
+    client_secret = os.environ.get("CLIENT_SECRET")  # 从环境变量中获取 client_secret  
+    async with httpx.AsyncClient() as client:  
+        response = await client.post(  
+            "https://open.douyin.com/oauth/access_token/",  
+            headers={"Content-Type": "application/json"},  
+            json={  
+                "grant_type": "authorization_code",  
+                "client_key": client_key,  
+                "client_secret": client_secret,  
+                "code": code,  
+            },  
+        )  
+  
+    ''' response success:
+    {
+    "data": {
+        "access_token": "act.f7094fbffab2ecbfc45e9af9c32bc241oYdckvBKe82BPx8T******",
+        "captcha": "",
+        "desc_url": "",
+        "description": "",
+        "error_code": 0,
+        "expires_in": 1296000,
+        "log_id": "20230525105733ED3ED7AC56A******",
+        "open_id": "b9b71865-7fea-44cc-******",
+        "refresh_expires_in": 2592000,
+        "refresh_token": "rft.713900b74edde9f30ec4e246b706da30t******",
+        "scope": "user_info"
+        },
+        "message": "success"
+    }
+    
+    response error:
+    {
+        "data": {
+            "description": "Parameter error",
+            "error_code": 2100005
+        },
+        "extra": {
+            "logid": "2020070614111601022506808001045D59",
+            "now": 1594015876138
+        }
+    }
+    '''
+    logger.debug(response.json()) 
+    return response.json().get("data")
+
+# 单元测试
+def main():
+    # 访问: https://swl-8l9.pages.dev/  点击立即体验
+    # 浏览器打开调试模式F12,扫码登录
+    # 在浏览器调试窗口 - 网络 - 名称 - 第一条 /verify?code=936c3671e073703c25Ze7zlgwxcOjvsYJ6Iz&state=&scope - 获得 code
+    import asyncio
+    res = asyncio.run(get_access_token("936c3671e073703c25Ze7zlgwxcOjvsYJ6Iz"))
+    print("res:",res)
+    '''
+    {'access_token': 'act.3.wl8L3DFQ3sj3uKYzQShOSs8HbOgKh0FVvjxKeaTum0ZOEXoyBI8D1N7gTBqGbrY32KP-Pm41EAvcobSheOBi8tvRdhj7m5-5ZVoprZZu_GN5J2KnH2fZ_X9_l7Q6iFyvpPoMkX3Zyom3PCkeRZp4Jg9sE2ZiwuvZVdnvft0A25uBWXvj2IEbWW_0Bf8=', 'captcha': '', 'desc_url': '', 'description': '', 'error_code': 0, 'expires_in': 1296000, 'log_id': '20240129123749239735B0529965BC6D93', 'open_id': '_000QadFMhmU1jNCI3JdPnyVDL6XavC70dFy', 'refresh_expires_in': 2592000, 'refresh_token': 'rft.c29d64456ea3d5e4c932247ee93dd735aq5OhtcYNXNFAD70XHKrdntpE6U0', 'scope': 'user_info,trial.whitelist'}
+    '''
+if __name__ == "__main__":
+    main()

+ 43 - 0
douyin/user_info.py

@@ -0,0 +1,43 @@
+import os
+import sys
+sys.path.append(os.path.dirname(os.path.dirname(__file__)))
+import httpx
+import aiohttp
+from config import logger
+from pydantic import BaseModel
+
+class DouyinUserInfoResponse(BaseModel):
+    error_code: int
+    data: dict
+    
+    
+async def get_user_info(open_id, access_token):
+    url = "https://open.douyin.com/oauth/userinfo/"
+    headers = {
+        "Content-Type": "application/x-www-form-urlencoded"
+    }
+    data = {
+        "open_id": open_id,
+        "access_token": access_token
+    }
+    res = None
+    async with httpx.AsyncClient() as client:
+        response = await client.post(url, headers=headers, params=data)
+        res = response.json().get("data")
+    logger.debug(res)
+    return res
+    '''return 
+    {'data': {'avatar': 'https://p6.douyinpic.com/aweme/100x100/aweme-avatar/tos-cn-i-0813_66c4e34ae8834399bbf967c3d3c919db.jpeg?from=4010531038', 'avatar_larger': 'https://p11.douyinpic.com/aweme/1080x1080/aweme-avatar/tos-cn-i-0813_66c4e34ae8834399bbf967c3d3c919db.jpeg?from=4010531038', 'captcha': '', 'city': '', 'client_key': 'aw6aipmfdtplwtyq', 'country': '', 'desc_url': '', 'description': '', 'district': '', 'e_account_role': '', 'error_code': 0, 'gender': 0, 'log_id': '20240129142818189D643B12E3055CE271', 'nickname': '程序员马工', 'open_id': '_000QadFMhmU1jNCI3JdPnyVDL6XavC70dFy', 'province': '', 'union_id': 'b138db97-01ae-59bd-978a-1de8566186a8'}, 'message': 'success'}
+    '''
+    
+async def main():
+    open_id = "_000QadFMhmU1jNCI3JdPnyVDL6XavC70dFy"
+    access_token = "act.3.wl8L3DFQ3sj3uKYzQShOSs8HbOgKh0FVvjxKeaTum0ZOEXoyBI8D1N7gTBqGbrY32KP-Pm41EAvcobSheOBi8tvRdhj7m5-5ZVoprZZu_GN5J2KnH2fZ_X9_l7Q6iFyvpPoMkX3Zyom3PCkeRZp4Jg9sE2ZiwuvZVdnvft0A25uBWXvj2IEbWW_0Bf8="
+    
+    result = await get_user_info(open_id, access_token)
+    logger.debug(result)
+
+
+if __name__ == "__main__":
+    import asyncio
+    asyncio.run(main())

+ 0 - 8
douyin_openapi_web.http

@@ -1,8 +0,0 @@
-# WEB扫码接入 参考 https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/sdk/web-app/web/permission
-# 打开链接,扫码登录 
-GET https://open.douyin.com/platform/oauth/connect/?client_key=aw6aipmfdtplwtyq&response_type=code&scope=user_info,renew_refresh_token,trial.whitelist&redirect_uri=https://open-douyin-cf.magong.site/verify_callback HTTP/1.1  
-Host: open.douyin.com
-
-# 服务器收到返回信息
-GET /verify_callback?code=936c3671e073703cnzV93iYzyWbdLIZmFPQJ&state=&scopes=user_info,trial.whitelist HTTP/1.1
-

+ 36 - 69
main.py

@@ -1,94 +1,61 @@
 import socket
 import socket
+import time
 from fastapi import FastAPI, Request  
 from fastapi import FastAPI, Request  
 from fastapi.responses import HTMLResponse  
 from fastapi.responses import HTMLResponse  
 from fastapi.staticfiles import StaticFiles  
 from fastapi.staticfiles import StaticFiles  
 from fastapi.templating import Jinja2Templates
 from fastapi.templating import Jinja2Templates
 import requests  
 import requests  
 import uvicorn
 import uvicorn
-from fastapi.responses import FileResponse  
+from fastapi.responses import FileResponse,JSONResponse
 from fastapi import FastAPI, Depends, HTTPException, Form  
 from fastapi import FastAPI, Depends, HTTPException, Form  
 import httpx
 import httpx
 import os
 import os
 # from db.user import UserOAuthToken
 # from db.user import UserOAuthToken
-from config import HOST, PORT
+from config import *
+from fastapi.middleware.cors import CORSMiddleware 
+from db.user import DatabaseManager
+from api.login import login_router
+from api.redis import RedisSession
+from starlette.middleware.sessions import SessionMiddleware
+from contextlib import asynccontextmanager
 
 
 app = FastAPI()  
 app = FastAPI()  
-  
-@app.get("/")  
-async def read_root(request: Request):  
-    return FileResponse(os.path.join("static", "index.html"))  
+app.add_middleware(  
+    CORSMiddleware,  
+    allow_origins=["*"],  
+    allow_credentials=True,  
+    allow_methods=["*"],  
+    allow_headers=["*"], 
+     
+) 
+app.include_router(login_router)  
+
+# 使用 Redis 会话  
+redis_session = RedisSession("redis://localhost:8001")  
+app.add_middleware(SessionMiddleware, secret_key=JWT_SECRET_KEY)  
 
 
-# 授权码获取 access_token 的路由  
-@app.post("/get_access_token")  
-async def get_access_token(request: Request):  
-    client_key = os.environ.get("CLIENT_KEY")  # 从环境变量中获取 client_key  
-    client_secret = os.environ.get("CLIENT_SECRET")  # 从环境变量中获取 client_secret  
-    try:  
-        code = request.query_params["code"]  # 从查询参数中获取 code  
-    except KeyError:  
-        raise HTTPException(status_code=400, detail="Missing 'code' parameter")  
-  
-    # 发送请求获取 access_token  
-    async with httpx.AsyncClient() as client:  
-        response = await client.post(  
-            "https://open.douyin.com/oauth/access_token/",  
-            headers={"Content-Type": "application/json"},  
-            json={  
-                "grant_type": "authorization_code",  
-                "client_key": client_key,  
-                "client_secret": client_secret,  
-                "code": code,  
-            },  
-        )  
-  
-    if response.status_code != 200:  
-        raise HTTPException(status_code=response.status_code, detail=response.text)  
-    '''
-    {
-    "data": {
-        "access_token": "act.f7094fbffab2ecbfc45e9af9c32bc241oYdckvBKe82BPx8T******",
-        "captcha": "",
-        "desc_url": "",
-        "description": "",
-        "error_code": 0,
-        "expires_in": 1296000,
-        "log_id": "20230525105733ED3ED7AC56A******",
-        "open_id": "b9b71865-7fea-44cc-******",
-        "refresh_expires_in": 2592000,
-        "refresh_token": "rft.713900b74edde9f30ec4e246b706da30t******",
-        "scope": "user_info"
-        },
-        "message": "success"
-    }
-    '''
-    data = response.json()["data"]  
-  
-    print(data)
-    # magong_user = UserOAuthToken(access_token=data['access_token'], open_id=data['open_id'])
-    return {"message": "Access token stored successfully"}  
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    # 在应用启动前运行的代码
+    await redis_session.connect()  
+    yield
+    # 在应用关闭后运行的代码
+    await redis_session.disconnect()
 
 
-@app.get("/verify_callback")  
-async def verify_callback(request: Request):  
-    # 打印请求方法  
-    print(f"Method: {request.method}")  
-    # open-douyin.magong.site/verify_callback?code=676a1101ea02bc5dTaUVtKg8c5enYaGqB4dT&state=&scopes=user_info,trial.whitelist
-    print(request.url)
-    # 打印请求头  
-    print(f"Headers:")  
-    for key, value in request.headers.items():  
-        print(f"{key}: {value}")  
+def get_session(request: Request):  
+    return request.session  
+    
+@app.get("/")  
+async def read_root(request: Request):  
+    return {"message": "ok", "time":time.strftime("%Y-%m-%d_%H-%M-%S", time.localtime())}  
 
 
-    # 打印查询参数  
-    print(f"Query Parameters:")  
-    for key, value in request.query_params.items():  
-        print(f"{key}: {value}")  
-    return HTMLResponse("<h1>Callback Received! Verification Successful.</h1>")
 
 
 def main():
 def main():
     print(f"https://open-douyin-cf.magong.site  公网代理地址,cloudflare dns proxy ,由 caddy 转发到 8600 端口")
     print(f"https://open-douyin-cf.magong.site  公网代理地址,cloudflare dns proxy ,由 caddy 转发到 8600 端口")
     print(f"https://open-douyin-wk.magong.site  公网代理地址,cloudflare workers 转发到 8600 端口")
     print(f"https://open-douyin-wk.magong.site  公网代理地址,cloudflare workers 转发到 8600 端口")
     print(f"http://sv-v2.magong.site:{PORT}  ⭐ 推荐,仅支持 ipv6 ,直连、满速、无延迟。缺点是不支持 https 协议,因为不经过 Caddy 代理,直达 Fastapi 没有配置 https")
     print(f"http://sv-v2.magong.site:{PORT}  ⭐ 推荐,仅支持 ipv6 ,直连、满速、无延迟。缺点是不支持 https 协议,因为不经过 Caddy 代理,直达 Fastapi 没有配置 https")
     print(f"https://open-douyin.magong.site  内网穿透隧道,cloudflare tunnel ,经常访问不了")
     print(f"https://open-douyin.magong.site  内网穿透隧道,cloudflare tunnel ,经常访问不了")
+    print(f"https://swl-8l9.pages.dev/  访问前端网站")
     uvicorn.run(app, host=None, port=PORT, log_level="info")
     uvicorn.run(app, host=None, port=PORT, log_level="info")
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":

+ 40 - 0
readme.md

@@ -82,6 +82,46 @@ curl --location 'https://open.douyin.com/oauth/access_token/' \
 
 
 第三步:获取用户信息
 第三步:获取用户信息
 参考文档: https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/account-management/get-account-open-info
 参考文档: https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/account-management/get-account-open-info
+请求示例:
+```shell
+curl --location --request POST 'https://open.douyin.com/oauth/userinfo/' \
+--header 'Content-Type: application/x-www-form-urlencoded' \
+--data-urlencode 'open_id=ba253642-0590-40bc-9bdf-9a1334******' \
+--data-urlencode 'access_token=act.1d1021d2aee3d41fee2d2add43456badMFZnrhFhfWotu3Ecuiuka2******'
+```
+响应示例
+正常示例
+```json
+{
+  "data": {
+    "avatar": "https://example.com/x.jpeg",
+    "avatar_larger": "https://example.com/x.jpeg",
+    "client_key": "ExampleClientKey",
+    "e_account_role": "",
+    "error_code": 0,
+    "log_id": "202212011600080101351682282501F9E7",
+    "nickname": "TestAccount",
+    "open_id": "0da22181-d833-447f-995f-1beefe******",
+    "union_id": "1ad4e099-4a0c-47d1-a410-bffb4f******"
+  },
+  "message": "success"
+}
+
+```
+
+异常示例
+```json
+{
+  "data": {
+    "description": "Parameter error",
+    "error_code": 2100005
+  },
+  "extra": {
+    "logid": "2020070614111601022506808001045D59",
+    "now": 1594015876138
+  }
+}
+```
 
 
 以下是我的代码:
 以下是我的代码:
 ```python
 ```python

+ 50 - 0
test/account.py

@@ -0,0 +1,50 @@
+import jwt
+from fastapi import FastAPI, HTTPException, Depends, Request
+from pydantic import BaseModel
+from fastapi.responses import JSONResponse
+from test.config import JWT_SECRET_KEY, HOST, PORT
+import uvicorn
+app = FastAPI()
+
+class ScanCode(BaseModel):
+    code: str
+    scopes: str
+
+# 解码并验证JWT
+def decode_jwt(token: str):
+    try:
+        payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=["HS256"])
+        return ScanCode(username=payload.get("sub"))
+    except jwt.PyJWTError as e:
+        raise HTTPException(status_code=403, detail="Invalid or expired token")
+
+# 登录端点
+@app.post("/login")
+async def login(user: ScanCode):
+    # 在这里验证user_credentials(例如,检查username和密码)
+    # 如果凭证有效,生成并返回token
+    access_token = jwt.encode({"sub": user.username}, JWT_SECRET_KEY, algorithm="HS256")
+    return {"token": access_token}
+
+from fastapi.security import OAuth2PasswordBearer
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
+# 创建一个依赖项来获取当前活动用户
+def get_current_user(token: str = Depends(oauth2_scheme)):
+    current_user = decode_jwt(token)
+    return current_user
+
+# 受保护资源示例
+@app.get("/account")
+async def read_account(current_user: User = Depends(oauth2_scheme)):
+    # 在这里返回当前用户的信息
+    return {"nickname": current_user.username, "avatar": "https://p26.douyinpic.com/aweme/100x100/aweme-avatar/tos-cn-i-0813_66c4e34ae8834399bbf967c3d3c919db.jpeg?from=4010531038"}
+
+# 其他受保护的资源...
+
+# 启动应用
+def main():
+    uvicorn.run(app, host=None, port=PORT, log_level="info")
+
+if __name__ == "__main__":
+    main()

+ 13 - 0
test/config.py

@@ -0,0 +1,13 @@
+
+import os
+import socket
+
+os.environ["CLIENT_KEY"] = 'aw6aipmfdtplwtyq'
+os.environ["CLIENT_SECRET"] = '53cf3dcd2663629e8a773ab59df0968b'
+os.environ["JWT_SECRET_KEY"]="123"
+# HOST = socket.gethostbyname(socket.gethostname())
+# 这个网址 https://open-douyin.magong.site 对应这台服务器的 192.168.1.32:8600 端口,因为这台服务器没有公网ip,所以在本地计算机无法通过  http://192.168.1.32:8600/ 访问到 fastapi 接口,只能通过 https://open-douyin.magong.site/ 访问
+HOST = '::'
+PORT = 8601
+JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"]
+# print(HOST)

+ 11 - 7
待办事项.md

@@ -3,10 +3,13 @@
 - [ ] **了解数据库**
 - [ ] **了解数据库**
   - [x] 数据库模型 sqlmodel ,官方文档: https://github.com/tiangolo/sqlmodel  
   - [x] 数据库模型 sqlmodel ,官方文档: https://github.com/tiangolo/sqlmodel  
   - [x] 数据库软件 PostgreSQL 。 2023 最流行的开源数据库,超越 MySQL 。官网: https://github.com/postgres/postgres ,github 地址:https://github.com/postgres/postgres
   - [x] 数据库软件 PostgreSQL 。 2023 最流行的开源数据库,超越 MySQL 。官网: https://github.com/postgres/postgres ,github 地址:https://github.com/postgres/postgres
-  - [ ] 根据 sqlmodel 的方式编写代码,定义数据库模型,连接至 PostgreSQL ,代码存放在 db/ 目录中
+  - [x] 根据 sqlmodel 的方式编写代码,定义数据库模型,连接至 PostgreSQL ,代码存放在 db/ 目录中(调试了sqlmodel连接PostgreSQL服务器,往数据库中写入数据)
+  - [x] 添加、删除、显示记录
 - [ ] **获取抖音用户信息**
 - [ ] **获取抖音用户信息**
+  - [ ] 详见 [api](./api/readme.md#获取抖音用户信息)
   - [ ] 参考文档 https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/sdk/web-app/web/permission
   - [ ] 参考文档 https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/sdk/web-app/web/permission
-  - [ ] 扫码后,抖音将扫码结果推送到 /verify_callback 地址,将 code 解析出来。获取用户 access-token 、 expires_in、open_id、refresh_expires_in、refresh_token。参考文档 https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/account-permission/get-access-token
+  - [ ] 后端管理框架参考: https://github.com/amisadmin/fastapi-amis-admin
+  - [ ] 将 code 解析出来。获取用户 access-token 、 expires_in、open_id、refresh_expires_in、refresh_token。参考文档 https://developer.open-douyin.com/docs/resource/zh-CN/dop/develop/openapi/account-permission/get-access-token
   - [ ] 利用 access-token 获取用户公开信息。
   - [ ] 利用 access-token 获取用户公开信息。
 - [ ] **接入 LangChain (代补充)**
 - [ ] **接入 LangChain (代补充)**
   - [ ] 向量数据库服务器部署
   - [ ] 向量数据库服务器部署
@@ -18,15 +21,16 @@
   - [x] 数据库软件 PostgreSQL 。 2023 最流行的开源数据库,超越 MySQL 。官网: https://github.com/postgres/postgres ,github 地址:https://github.com/postgres/postgres
   - [x] 数据库软件 PostgreSQL 。 2023 最流行的开源数据库,超越 MySQL 。官网: https://github.com/postgres/postgres ,github 地址:https://github.com/postgres/postgres
   - [x] 部署数据库服务器 PostgreSQL ,确定分布式数据库可行性 https://blog.csdn.net/qq_23934063/article/details/120267886。微软Citus 11 for Postgres分布式架构 https://github.com/citusdata/citus
   - [x] 部署数据库服务器 PostgreSQL ,确定分布式数据库可行性 https://blog.csdn.net/qq_23934063/article/details/120267886。微软Citus 11 for Postgres分布式架构 https://github.com/citusdata/citus
   - [x] 已完成部署,[使用教程](./db/readme.md)
   - [x] 已完成部署,[使用教程](./db/readme.md)
-- [ ] **内网穿透**
-  - 💡 必要性:一个应用程序/网站有许多微服务,子请求到不同的后端主机,负载均衡,数据库,文件服务器,对象存储,大文件、音视频流点对点传输,用户没有ipv6,方便协作开发,等等
-  - [x] 评估 FRP 穿透打洞可行性,方便API动态增删改查隧道 (不可行,打洞仍需要中继公网服务器)
-  - [ ] natter 搭建一个 API 增删改查隧道。natter + Vmess 探究
 - [ ] **编写前端代码**
 - [ ] **编写前端代码**
-  - [ ] 扫码登录,页面跳转。前端项目:[vue-pure-admin](https://mp.weixin.qq.com/s/iJPJizHKhbXe9iHpclU3FA) 基于 Vue3、Vite、Element-Plus 和 TypeScript 编写的后台管理系统。github链接:https://github.com/xiaoxian521/vue-pure-admin
+  - [x] 扫码登录,页面跳转(1月26日)。
+    - [ ] 前端项目:[vue-pure-admin](https://mp.weixin.qq.com/s/iJPJizHKhbXe9iHpclU3FA) 基于 Vue3、Vite、Element-Plus 和 TypeScript 编写的后台管理系统。github链接:https://github.com/xiaoxian521/vue-pure-admin
   - [ ] 了解用户鉴权, element-plus-admin + Fastapi + Oauth2
   - [ ] 了解用户鉴权, element-plus-admin + Fastapi + Oauth2
   - [ ] 从数据库获取用户数据,展示到用户登录页中
   - [ ] 从数据库获取用户数据,展示到用户登录页中
   - [ ] 文档上传、下载、更新、删除
   - [ ] 文档上传、下载、更新、删除
+- [ ] **内网穿透**
+  - 💡 必要性:一个应用程序/网站有许多微服务,子请求到不同的后端主机,负载均衡,数据库,文件服务器,对象存储,大文件、音视频流点对点传输,用户没有ipv6,方便协作开发,等等
+  - [x] 评估 FRP 穿透打洞可行性,方便API动态增删改查隧道 (不可行,打洞仍需要中继公网服务器)
+  - [ ] natter 搭建一个 API 增删改查隧道。natter + Vmess 探究
 - [ ] **接入 LangChain (代补充)**
 - [ ] **接入 LangChain (代补充)**
   - [ ] 向量数据库服务器部署
   - [ ] 向量数据库服务器部署
   - [ ] 评估大模型记忆框架 MemoryBank https://zhuanlan.zhihu.com/p/674220905?utm_campaign=shareopn&utm_medium=social&utm_oi=766444166291935232&utm_psn=1730836140159057920&utm_source=wechat_session
   - [ ] 评估大模型记忆框架 MemoryBank https://zhuanlan.zhihu.com/p/674220905?utm_campaign=shareopn&utm_medium=social&utm_oi=766444166291935232&utm_psn=1730836140159057920&utm_source=wechat_session