用戶功能設計與實現--Django播客系統(六)

用戶功能設計與實現–Django播客系統(六)

  • 提供用戶註冊處理
  • 提供用戶登錄處理
  • 提供路由配置

用戶註冊接口設計

  • 接受用戶通過Post方法提交的註冊信息,提交的數據是JSON格式數據
  • 檢查email是否存在與數據庫表中,如果存在返回錯誤狀態碼,例如4xx,如果不存在,將用戶提交的數據存入表中
  • 整個過程都採用AJAX異步過程,用戶提交JSON數據,服務端獲取數據後處理,返回JSON。
POST /users/ 創建用戶

請求體 application/json
{
    "password":"string",
    "name":"string",
    "email":"string"
}

響應
201 創建成功
400 請求數據錯誤

路由配置

  • 爲了避免項目中的urls.py條目過多,也爲了讓應用自己管理路由,採用多級路由
# djweb/urls.py文件
from django.urls import include
urlpatterns = [
    path('admin/', admin.site.urls),
    re_path(r'^$',index),
    re_path(r'^index$',index),#不同路徑可以指向同一個函數執行
    re_path(r'^users/',include("user.urls")),
]
  • include函數參數寫應用.路由模塊,該函數就會動態導入指定的包的模塊,從模塊裏面讀取urlpatterns,返回三元組。

  • url函數第二參數如果不是可調用對象,如果是元組或列表,則會從路徑中除去已匹配的部分,將剩餘部分與應用中的路由模塊的urlpatterns進行匹配。

  • 新建user/uls.py文件

# user/uls.py
from django.conf.urls import re_path
from .views import reg

urlpatterns = [
    re_path(r'^$',reg), #/users/
]
  • user/views.py文件中添加reg方法
# user/views.py
from django.shortcuts import render
from django.http import HttpResponse,HttpRequest

def reg(request:HttpRequest):
    return HttpResponse("user.reg")
  • 瀏覽器中輸入http://127.0.0.1:8000/users/測試(這是GET請求),可以看到響應的數據。下面開始完善視圖函數。
  • user/views.py中編寫視圖函數reg

測試JSON數據

  • 使用POST方法,提交數據類型爲application/json,json字符串要使用雙引號

  • 這個數據是註冊用的,由客戶端提交。

    1. 數據提交模板爲:
    {
        "password":"abc",
        "name":"xdd",
        "email":"[email protected]"
    }
    
    1. 可以使用Postman軟件測試。
      django5_001

CSRF處理

  • 在Post數據的時候,發現出現了下面的提示
    django5_002

    1. 原因:默認Django CsrfViewMiddleware中間件會對所有POST方法提交的信息做CSRF校驗
  • CSRF或XSRF(Cross-site Request Forgery),即跨站請求僞造。它也被稱爲:one click attack/session riding。是一種對網站的惡意利用。它僞造成來自受信任用戶發起的請求,難以防範。

  • 原理:
    django5_003

    1. 用戶登錄某網站A完成登錄認證,網站返回敏感信息的Cookie,即使是會話級的Cookie
    2. 用戶沒有關閉瀏覽器,或認證的Cookie一段時間內不過期還持久化了,用戶就訪問攻擊網站B
    3. 攻擊網站B看似一切正常,但是某些頁面裏面有一些隱藏運行的代碼,或者誘騙用戶操作的按鈕等
    4. 這些代碼一旦運行就是悄悄地向網站A發起特殊請求,由於網站A的Cookie還有效,且訪問的是網站A,則其Cookie就可以一併發給網站A
    5. 網站A看到這些Cookie就只能認爲是登錄用戶發起的合理合法請求,就會執行
  • CSRF解決

    1. 關閉CSRF中間件(不推薦)

      #djweb/settings.py
      MIDDLEWARE = [
          'django.middleware.security.SecurityMiddleware',
          'django.contrib.sessions.middleware.SessionMiddleware',
          'django.middleware.common.CommonMiddleware',
          # 'django.middleware.csrf.CsrfViewMiddleware', #註釋掉
          'django.contrib.auth.middleware.AuthenticationMiddleware',
          'django.contrib.messages.middleware.MessageMiddleware',
          'django.middleware.clickjacking.XFrameOptionsMiddleware',
      ]
      
    2. csrftoken驗證

      • 在表單POST提交時,需要發給服務器一個csrf_token
      • 模板中的表單Form中增加{% csrf_token %},它返回到了瀏覽器端就會爲cookie增加csrftoken字段,還會在表單中增加一個名爲csrfiddlewaretoken隱藏控件<input type='hidden' name='csrfmiddlewaretoken' value='jZTxU0v5mPoLvugcfLbS1B6vT8COYrKuxMzodWv8oNAr3a4ouWlb5AaYG2tQi3dD' />
      • POST提交表單數據時,需要將csrfmiddlewaretoken一併提交,Cookie中的csrf_token也一併會提交,最終在中間件中比較,相符通過,不相符就看到上面的403提示
    3. 雙cookie驗證

      • 訪問本站先獲得csrftoken的cookie
      • 如果使用AJAX進行POST,需要在每一次請求Header中增加自定義字段X-CSRFTOKEN,其值來自cookie中獲取的csrftoken值
      • 在服務端比較cookie和X-CSRFTOKEN中的csrftoken,相符通過
  • 現在沒有前端代碼,爲了測試方便,可以選擇第一種方法先禁用中間件,測試完成後開啓。

JSON數據處理

  • simplejson標準庫方便好用,功能強大。
    1. pip install simplejson
  • 瀏覽器端提交的數據放在了請求對象的body中,需要使用simplejson解析,解析的方式同Json模塊,但是simplejson更方便。

錯誤處理

  • Django中有很多異常類,定義在django.http下,這些類都繼承自HttpResponse。
#user/views.py文件

from django.http import HttpResponse,HttpRequest,HttpResponseBadRequest,JsonResponse
import simplejson

def reg(request:HttpRequest):
    print(request.POST)
    print(request.body)
    # print("- " * 30)
    try:
        payload = simplejson.loads(request.body)
        email = payload['email']
        name = payload['name']
        password = payload["password"]
        print(email,name,password)
        return JsonResponse({},status=201) #創建成功返回201
    except Exception as e: #有任何異常,都返回
        print(e)
        return HttpResponseBadRequest() #這裏返回實例,這不是異常類
  • 將上面代碼增加郵箱檢查、用戶信息保存功能,就要用到Django的模型操作。
  • 本次採用Restful實踐的設計,採用返回錯誤狀態碼+JSON錯誤信息方式。

註冊代碼 v1

# user/views.py文件

from django.http import HttpResponse,HttpRequest,HttpResponseBadRequest,JsonResponse
import simplejson
from .models import User

def reg(request:HttpRequest):

    try:
        payload = simplejson.loads(request.body)
        email = payload['email']
        query = User.objects.filter(email=email)
        print(query)
        print(query.query) #查看sQL語句
        if query.first():
            return JsonResponse({"error":"用戶已存在"},status=400);

        name = payload['name']
        password = payload["password"]
        print(email,name,password)

        user = User()
        user.email = email
        user.name = name
        user.password = password
        user.save()

        return JsonResponse({},status=201) #創建成功返回201
    except Exception as e: #有任何異常,都返回
        print(e)
        # 失敗返回錯誤信息和400,所有其他錯誤一律用戶名密碼錯誤
        return JsonResponse({"error":"用戶名或密碼錯誤"},status=400)
  1. 郵箱檢查
    • 郵箱檢查需要查user表,需要使用filter方法。
    • email=email,前面是字段名email,後面是email變量。查詢後返回結果,如果查詢有結果,則說明該email已經存在,郵箱已經註冊,返回400到前端
  2. 用戶信息存儲
    • 創建User類實例,屬性存儲數據,最後調用save方法。Django默認是在save()、delete()的時候事務自動提交
    • 如果提交拋出任何錯誤,則捕獲此異常做相應處理。
    • 如果沒有異常,則返回201,不要返回任何用戶信息。之後可能需要驗證、用戶登錄等操作。
  3. 異常處理
    • 出現獲取輸入框提交信息異常,就返回400
    • 查詢郵箱存在,返回400
    • save()方法保存數據,有異常,則返回400
    • 注意一點,Django的異常類繼承自HttpResponse類,所以不能raise,只能return
    • 前端通過狀態碼判斷是否成功
    • 由於採用Restful實戰,所有異常全部返回JSON的錯誤信息,所以一律使用了JsonResponse

Django日誌

LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
        },
    },
    'loggers': {
        'django.db.backends': {
            'handlers': ['console'],
            'level': "DEBUG",
        },
    },
}
  • 配置後,就可以在控制檯看到執行的SQL語句。
  • 注意,必須DEBUG=True,同時level是DEBUG,否則從控制檯看不到SQL語句。

模型操作

管理器對象

  • Django會爲模型類提供一個objects對象,它是django.db.models.manager.Manager類型,用於與數據庫交互。當定義模型類的時候沒有指定管理器,則Django會爲模型類提供一個objects的管理器。

  • 如果在模型類中手動指定管理器後,Django不再提供默認的objects的管理器了。

  • 管理器是Django的模型進行數據庫查詢操作的接口,Django應用的每個模型都至少擁有一個管理器。

  • Django ORM

    1. 數據的校驗validation是在對象的Save、update方法上
      django5_004
    2. 對模型對象的CRUD,被Django ORM轉換成相應的SQL語句操作不同的數據源。

查詢

  • 查詢集
    1. 查詢會返回結果的集,它是django.db.models.query.QuerySet類型。
    2. 它是惰性求值,和sqlalchemy一樣。結果就是查詢的集。
    3. 它是可迭代對象。
  1. 惰性求值
    • 創建查詢集不會帶來任何數據庫的訪問,直到調用方法使用數據時,纔會訪問數據庫。在迭代、序列化、if語句中都會立即求值。
  2. 緩存:
    • 每一個查詢集都包含一個緩存,來最小化對數據庫的訪問。
    • 新建查詢集,緩存爲空。首次對查詢集求值時,會發生數據庫查詢,Django會把查詢的結果存在這個緩存中,並返回請求的結果,接下來對查詢集求值將使用緩存的結果。
    • 觀察下面的2個例子是要看真正生成的語句了
      1. 沒有使用緩存,每次都要去查庫,查了2次庫

        [user.name for user in User.objects.all()]
        [user.name for user in User.objects.all()]
        
      2. 下面的語句使用緩存,因爲使用同一個結果集

        qs = User.objects.all()
        [user.name for user in qs]
        [user.name for user in qs]
        

限制查詢集(切片)

  • 查詢集對象可以直接使用索引下標的方式(不支持負索引),相當於SQL語句中的limit和offset子句。
  • 注意使用索引返回的新的結果集,依然是惰性求值,不會立即查詢。
qs = User.objects.all()[20:40]
# LIMIT 20 OFFSET 20
qs = User.objects.all()[20:30]
# LIMIT 10 OFFSET 20

過濾器

  1. 返回查詢集的方法,稱爲過濾器,如下:
名稱 說明
all()
filter() 過濾,返回滿足條件的數據
exclude() 排除,排除滿足條件的數據
order_by() 排序,注意參數是字符串
values() 返回一個對象字典的列表,列表的元素是字典,字典內是字段和值的鍵值對
  • filter(k1=v1).filter(k2=v2)等價於filter(k1=v1,k2=v2)
  • filter(pk=10)這裏pk指的就是主鍵,不用關心主鍵字段名,當然也可以使用主鍵名filter(emp_no=10)
mgr = User.objects
print(mgr.all())
print(mgr.values())
print(mgr.filter(pk=1).values())
print(mgr.exclude(pk=4))
print(mgr.exclude(id=1).values())
print(mgr.exclude(id=6).order_by("-id"))
print(mgr.exclude(id=6).order_by("-id").values())
print(mgr.exclude(id=6).values().order_by("-pk"))
  • 返回單個值的方法
名稱 說明
get() 僅返回單個滿足條件的對象
如果未能返回對象則拋出DoesNotExist異常;如果能返回多條,拋出MultipleObjectsReturned異常
count() 返回當前查詢的總條數
first() 返回第一個對象
last() 返回最後一個對象
exit() 判斷查詢集中是否有數據,如果有則返回True
email = "[email protected]"
mgr = User.objects
print(mgr.get(pk=4)) #使用主鍵查詢
print(mgr.get(email=email)) #只能返回一個結果
print(mgr.filter(id=1).get())

print(mgr.first()) #使用limit 1查詢,返回實例或None
print(mgr.filter(pk=4,email=email).first()) # and條件

字段查詢(Field Lookup)表達式

  • 字段查詢表達式可以作爲filter(),exclude(),get()的參數,實現where子句。
  • 語法:屬性名稱__比較運算符=值
  • 注意:屬性名和運算符之間使用雙下劃線
  • 比較運算符如下
名稱 舉例 說明
exact filter(isdeleted=False)
filter(isdeleted__exact=False)
嚴格等於,可省略不寫
contains exclude(title__contains="天") 是否包含,大小寫敏感,等價於like '%天%'
statswith
endswith
filter(title__startswith='天') 以什麼開頭或結尾,大小寫敏感
isnull
isnotnull
filter(title__isnull=False) 是否爲null
iexact
icontains
istartswith
iendswith
i的意思是忽略大小寫
in filter(pk__in=[1,2,3,100]) 是否在指定範圍數據中
gt,gte,lt,lte filter(id__get=3)
filter(pk__lte=6)
filter(pub_date__get=date(2000,1,1))
大於,小於等
year、month、day
week_day
hour、minute
second
filter(pub_date__year=2000) 對日期類型屬性處理
mgr = User.objects
print(mgr.filter(id__exact=4))
print(mgr.filter(email__contains='xdd'))
print(mgr.filter(email__istartswith='xdda'))
print(mgr.filter(id__in=[1,4]))
print(mgr.filter(id__gt=3))

Q對象

  • 雖然Django提供傳入條件的方式,但是不方便,它還提供了Q對象來解決。
  • Q對象是django.db.models.Q,可以使用&、|操作符來組成邏輯表達式。~表示not。
from django.db.models import Q
mgr = User.objects
print(mgr.filter(Q(pk__lt=6))) #不如直接寫filter(pk__lt=6)

print(mgr.filter(pk__lt=6).filter(pk__gt=1)) #與
print(mgr.filter(Q(pk__lt=6) & Q(pk__gt=1))) #與
print(mgr.filter(Q(pk=4) | Q(pk=1))) #或
print(mgr.filter(~Q(pk__lt=6))) #非
  • 可使用&|和Q對象來構造複雜的邏輯表達式
  • 過濾器函數可以使用一個或多個Q對象
  • 如果混用關鍵字參數和Q對象,那麼Q對象必須位於關鍵字參數的面前。所有參數都將and和一起

新增、更新、刪除方法

  • 更新數據
user = User(email='test3',name='test3') #沒有主鍵
user.save() #會新建一個數據

user = User(id=100,email='test4',name='test4') #有自增主鍵,如果不存在,則是插入
user.save()
user = User(id=100,email='test4',name='test4') # 有自增主鍵,如果存在,則是更新
user.save()
  • update在查詢集上同時更新數據
# 更新所有查詢的結果
User.objects.filter(id__get=4).update(password='xyz') #將pk大於4的查詢結果更新,所有用戶的密碼修改
  • delete刪除查詢集數據
ret = User.objects.filter(id__gt=4).delete()
print(ret)

# 運行結果
# DELETE FROM `user` WHERE `user`.`id` > 4 ; args=(4,)
# (3,{"user.User":3})

註冊接口設計完善

  • 認證:HTTP協議是無狀態協議,爲了解決它產生了cookie和session技術。

  • 傳統的session-cookie機制

    1. 瀏覽器發起第一次請求到服務器,服務器發現瀏覽器沒有提供session id,就認爲這是第一次請求,會返回一個新的session id給瀏覽器,瀏覽器只要不關閉,這個session id就會隨着每一次請求重新發給服務端,服務器端查找這個session id,如果查到,就認爲是同一個會話。如果沒有查到,就認爲是新的請求。
    2. session是會話級的,服務端還可以在這個會話session中創建很多數據session鍵值對。
    3. 這個session id有過期的機制,一段時間如果沒有發起請求,認爲用戶已經斷開,服務端就清除本次會話所有session。瀏覽器端也會清除相應的cookie信息。
    4. 服務端保存着大量session信息,很消耗服務器內存,而且如果多服務器部署,可以考慮session複製集羣,也可以考慮session共享的問題,比如redis、memcached等方案。
  • 無ssession方案

    1. 既然服務端就是需要一個ID來表示身份,那麼不使用session也可以創建一個ID返回給客戶端。但是,要保證客戶端不可篡改該信息。
    2. 服務端生成一個標識,並使用某種算法對標識簽名。
    3. 服務端收到客戶端發來的標識,需要檢查簽名。
    4. 這種方案的缺點是,加密、解密需要消耗CPU計算資源,無法讓瀏覽器自己主動檢查過期的數據以清除。這種技術稱作JWT(json WEB Token)。
  • JWT

    1. JWT(Json WEB Token)是一種採用Json方式安裝傳輸信息的方式。本次使用PyJWT,它是Python對JWT的實現。
    2. 安裝pip install pyjwt
    3. jwt原理
    import jwt
    import base64
    
    key = "secret"
    token = jwt.encode({'payload':'abc123'},key,'HS256')
    print(token)
    print(jwt.decode(token,key,algorithms=["HS256"]))
    # b'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJwYXlsb2FkIjoiYWJjMTIzIn0.lZc1PBGdbYDKq9k43tNTt1f0MHy4DjwT8NGTnHEIaVE'
    # token分爲3部分,用.斷開
    
    header,payload,signature = token.split(b'.')
    print("- "*30)
    print(header,payload,signature,sep='\n')
    print("- "*30)
    
    def addeq(b:bytes):
        """ 爲base64編碼補齊等號"""
        rest = 4 - len(b) % 4
        return b + b'='* rest
    
    print("header=",base64.urlsafe_b64decode(addeq(header)))
    print("payload=",base64.urlsafe_b64decode(addeq(payload)))
    print("signature=",base64.urlsafe_b64decode(addeq(signature)))
    print(" ="*30)
    
    # 根據jwt算法,重新生成簽名
    # 1 獲取算法對象
    from jwt import algorithms
    alg = algorithms.get_default_algorithms()["HS256"]
    newkey = alg.prepare_key(key) # key 爲secret
    
    # 2獲取前兩部分 header.payload
    signing_input,_,_ = token.rpartition(b".")
    print(signing_input)
    
    
    # 3使用key簽名
    signature = alg.sign(signing_input,newkey)
    print("--------------")
    print(signature)
    print(base64.urlsafe_b64encode(signature))
    

    django5_005

    1. 由此,可知jwt生成的token分爲三部分
      1. header,由數據類型、加密算法構成
      2. payload,負載就是要傳輸的數據,一般來說放入python對象即可,會被json序列化的
      3. signature,簽名部分。是簽名2部分數據分別base64編碼後使用點號鏈接後,加密算法使用key計算好一個結果,再被base64編碼,得到簽名
      • 所有數據都是明文傳輸的,只是做了base64,如果是敏感信息,請不要使用jwt。
      • 數據簽名的目的不是爲了隱藏數據,而是保證數據不被篡改。如果數據篡改了,發回到服務器端,服務器使用自己的key再計算一遍,然後進行簽名比對,一定對不上簽名。
    2. jwt使用場景
      • 認證:這是jwt最常用的場景,一旦用戶登錄成功,就會得到wt,然後請求中就可以帶上這個jwt。服務器中jwt驗證通過,就可以被允許訪問資源。甚至可以在不同域名中傳遞,在單點登錄(Single Sign On)中應用廣泛。
      • 數據交換:jwt可以防止數據被篡改,它還可以使用公鑰、私鑰加密,確保請求的發送者是可信的
  • 密碼

    1. 使用郵箱+密碼方式登錄。
    2. 郵箱要求唯一就行了,但是,密碼存儲需要加密。早期,都是用明文的密碼存儲。後來,使用MD5存儲,但是,目前也不安全,網上有很多MD5的網站,使用反查方式找到密碼。
    3. 加鹽,使用hash(password+salt)的結果存入數據庫中,就算拿到數據庫的密碼反查,也沒有用了。如果是固定加鹽,還是容易被找到規律,或者從源碼中泄露。隨機加鹽,每一次鹽都變,就增加了破解的難度。
    4. 暴力破解:什麼密碼都不能保證不被暴力破解,例如窮舉。所以要使用慢hash算法,例如bcrypt,就會讓每一次計算都很慢,都是秒即的,這樣窮舉的時間就會很長,爲了一個密碼破解的時間在當前CPU或者GPU的計算能力下可能需要幾十年以上。
  • bcrypt

    1. 安裝pip install bcrypt
    import bcrypt
    import datetime
    
    password = b'12345xdd'
    
    # 每次拿到鹽都不一樣
    print(1,bcrypt.gensalt())
    print(2,bcrypt.gensalt())
    
    salt = bcrypt.gensalt()
    # 拿到的鹽不同,計算等到的密文相同
    print("===============same salt ===============")
    x = bcrypt.hashpw(password,salt)
    y = bcrypt.hashpw(password,salt)
    print(3,x)
    print(4,y)
    
    # 每次拿到的鹽不同,計算生成的密文也不一樣
    print("============different salt===============")
    xx = bcrypt.hashpw(password,bcrypt.gensalt())
    yy = bcrypt.hashpw(password,bcrypt.gensalt())
    print(5,x)
    print(6,y)
    
    # 校驗
    print(bcrypt.checkpw(password,xx),len(xx))
    print(bcrypt.checkpw(password+b' ',xx),len(xx))
    
    # 計算時長
    start = datetime.datetime.now()
    y3 = bcrypt.hashpw(password,bcrypt.gensalt())
    delta = (datetime.datetime.now() - start).total_seconds()
    print(10,'duration={}'.format(delta))
    
    # 檢驗時長
    start = datetime.datetime.now()
    y4 = bcrypt.checkpw(password,xx)
    delta = (datetime.datetime.now() - start).total_seconds()
    print(y4)
    print(11,'duration={}'.format(delta))
    
    start = datetime.datetime.now()
    y5 = bcrypt.checkpw(b'1',xx)
    delta = (datetime.datetime.now() - start).total_seconds()
    print(y5)
    print(12,'duration={}'.format(delta))
    

    django5_006

    1. 從耗時看出,bcrypt加密、驗證非常耗時,所有如果窮舉,非常耗時。而且碰巧攻破一個密碼,由於鹽不一樣,還等窮舉另一個。
    salt=b'$2b$12$jwBD7mg9stvIPydF2bqoPO'
    b'$2b$12$jwBD7mg9stvIPydF2bqoPOodPwWYVvdmZb5uWWuWvlf9iHqNlKSQO'
    
    $是分隔符
    $2b$,加密算法
    12,表示2^12 key expansion rounds
    這是鹽b'jwBD7mg9stvIPydF2bqoPO',22個字符,Base64
    這是密文b'odPwWYVvdmZb5uWWuWvlf9iHqNlKSQO',31個字符,Base64
    

註冊代碼 v2

  1. 全局變量

    • 項目的settings.py文件實際上就是全局變量的配置 文件。
    • SECRET_KEY一個強KEY
    from django.conf import settings
    print(settings.SECRET_KEY)
    
  • 使用jwt和bcrypt,修改註冊代碼
from django.http import HttpResponse,HttpRequest,HttpResponseBadRequest,JsonResponse
import simplejson
from .models import User
import jwt
import datetime
import bcrypt
from django.conf import settings

# 對id簽名
def gen_token(user_id):
    # 時間戳用來判斷是否過期,以便重發token或重新登錄
    return  jwt.encode({
        "user_id":user_id,
        "timestamp":int(datetime.datetime.now().timestamp()) #取整
    },settings.SECRET_KEY).decode()

def reg(request:HttpRequest):

    try:
        payload = simplejson.loads(request.body)
        email = payload['email']
        query = User.objects.filter(email=email)
        print(query)
        print(query.query) #查看sQL語句
        if query.first():
            return JsonResponse({"error":"用戶已存在"},status=400);

        name = payload['name']
        password = payload["password"].encode()
        print(email,name,password)

        # 密碼加密
        password = bcrypt.hashpw(password,bcrypt.gensalt()).decode()
        print(password)

        user = User()
        user.email = email
        user.name = name
        user.password = password
        user.save()

        return JsonResponse({"token":gen_token(user.id)},status=201) #創建成功返回201
    except Exception as e: #有任何異常,都返回
        print(e)
        # 失敗返回錯誤信息和400,所有其他錯誤一律用戶名密碼錯誤
        return JsonResponse({"error":"用戶名或密碼錯誤"},status=400)
  • save方法會自動提交事務

django5_007

  • 數據庫中內容
    django5_008
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章