Bläddra i källkod

完成登录鉴权

qyl 1 år sedan
förälder
incheckning
20ff604e22
18 ändrade filer med 737 tillägg och 102 borttagningar
  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__
-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 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_SECRET"] = '53cf3dcd2663629e8a773ab59df0968b'
+DOUYIN_OPEN_API="https://open.douyin.com"
+
 
 # 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 = 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 操作数据库
 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
@@ -43,6 +40,23 @@ export PGPASSWORD=pg
 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
 

+ 127 - 10
db/user.py

@@ -1,8 +1,16 @@
+from datetime import datetime
 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):  
@@ -12,15 +20,124 @@ class UserOAuthToken(SQLModel, table=True):
     open_id:str
     refresh_expires_in: Optional[int] = None
     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 time
 from fastapi import FastAPI, Request  
 from fastapi.responses import HTMLResponse  
 from fastapi.staticfiles import StaticFiles  
 from fastapi.templating import Jinja2Templates
 import requests  
 import uvicorn
-from fastapi.responses import FileResponse  
+from fastapi.responses import FileResponse,JSONResponse
 from fastapi import FastAPI, Depends, HTTPException, Form  
 import httpx
 import os
 # 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.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():
     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"http://sv-v2.magong.site:{PORT}  ⭐ 推荐,仅支持 ipv6 ,直连、满速、无延迟。缺点是不支持 https 协议,因为不经过 Caddy 代理,直达 Fastapi 没有配置 https")
     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")
 
 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
+请求示例:
+```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

+ 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] 数据库软件 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
-  - [ ] 扫码后,抖音将扫码结果推送到 /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 获取用户公开信息。
 - [ ] **接入 LangChain (代补充)**
   - [ ] 向量数据库服务器部署
@@ -18,15 +21,16 @@
   - [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] 已完成部署,[使用教程](./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
   - [ ] 从数据库获取用户数据,展示到用户登录页中
   - [ ] 文档上传、下载、更新、删除
+- [ ] **内网穿透**
+  - 💡 必要性:一个应用程序/网站有许多微服务,子请求到不同的后端主机,负载均衡,数据库,文件服务器,对象存储,大文件、音视频流点对点传输,用户没有ipv6,方便协作开发,等等
+  - [x] 评估 FRP 穿透打洞可行性,方便API动态增删改查隧道 (不可行,打洞仍需要中继公网服务器)
+  - [ ] natter 搭建一个 API 增删改查隧道。natter + Vmess 探究
 - [ ] **接入 LangChain (代补充)**
   - [ ] 向量数据库服务器部署
   - [ ] 评估大模型记忆框架 MemoryBank https://zhuanlan.zhihu.com/p/674220905?utm_campaign=shareopn&utm_medium=social&utm_oi=766444166291935232&utm_psn=1730836140159057920&utm_source=wechat_session