FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

Neal_yang發表於2019-09-10

前言

關於 FlutterGo 或許不用太多介紹了。

如果有第一次聽說的小夥伴,可以移步FlutterGo官網檢視下簡單介紹.

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

FlutterGo 在這次迭代中有了不少的更新,筆者在此次的更新中,負責開發後端以及對應的客戶端部分。這裡簡單介紹下關於 FlutterGo 後端程式碼中幾個功能模組的實現。

總體來說,FlutterGo 後端並不複雜。此文中大概介紹以下幾點功能(介面)的實現:

  • FlutterGo 登陸功能
  • 元件獲取功能
  • 收藏功能
  • 建議反饋功能

環境資訊

阿里雲 ECS 雲伺服器

Linux iz2ze3gw3ipdpbha0mstybz 3.10.0-957.21.3.el7.x86_64 #1 SMP Tue Jun 18 16:35:19 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux

mysql :mysql Ver 8.0.16 for Linux on x86_64 (MySQL Community Server - GPL)

node:v12.5.0

開發語言:midway + typescript + mysql

程式碼結構:

src
├─ app
│    ├─ class 定義表結構
│    │    ├─ app_config.ts 
│    │    ├─ cat.ts
│    │    ├─ collection.ts
│    │    ├─ user.ts
│    │    ├─ user_collection.ts
│    │    └─ widget.ts
│    ├─ constants 常量
│    │    └─ index.ts
│    ├─ controller 
│    │    ├─ app_config.ts
│    │    ├─ auth.ts
│    │    ├─ auth_collection.ts
│    │    ├─ cat_widget.ts
│    │    ├─ home.ts
│    │    ├─ user.ts
│    │    └─ user_setting.ts
│    ├─ middleware 中介軟體
│    │    └─ auth_middleware.ts
│    ├─ model
│    │    ├─ app_config.ts
│    │    ├─ cat.ts
│    │    ├─ collection.ts
│    │    ├─ db.ts
│    │    ├─ user.ts
│    │    ├─ user_collection.ts
│    │    └─ widget.ts
│    ├─ public
│    │    └─ README.md
│    ├─ service
│    │    ├─ app_config.ts
│    │    ├─ cat.ts
│    │    ├─ collection.ts
│    │    ├─ user.ts
│    │    ├─ user_collection.ts
│    │    ├─ user_setting.ts
│    │    └─ widget.ts
│    └─ util 工具集
│           └─ index.ts
├─ config 應用的配置資訊
│    ├─ config.default.ts
│    ├─ config.local.ts
│    ├─ config.prod.ts
│    └─ plugin.ts
└─ interface.ts
複製程式碼

登陸功能

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

首先在class/user.ts中定義一個 user 表結構,大概需要的欄位以及在 interface.ts 中宣告相關介面。這裡是 midwayts 的基礎配置,就不展開介紹了。

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

FlutterGo 提供了兩種登陸方式:

  • 使用者名稱、密碼登陸
  • GitHubOAuth 認證

因為是手機客戶端的 GitHubOauth 認證,所以這裡其實是有一些坑的,後面再說。這裡我們先從簡單的開始說起

使用者名稱/密碼登陸

因為我們使用 github 的使用者名稱/密碼登陸方式,所以這裡需要羅列下 github 的 api:developer.github.com/v3/auth/,

文件中的核心部分:curl -u username https://api.github.com/user (大家可以自行在 terminal 上測試),回車輸入密碼即可。所以這裡我們完全可以在拿到使用者輸入的使用者名稱和密碼後進行 githu 的認證。

關於 midway 的基本用法,這裡也不再贅述了。整個過程還是非常簡單清晰的,如下圖:

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

相關程式碼實現(相關資訊已脫敏:xxx):

service部分

    //獲取 userModel
    @inject()
    userModel
    
    // 獲取 github 配置資訊
    @config('githubConfig')
    GITHUB_CONFIG;

    //獲取請求上下文
    @inject()
    ctx;
複製程式碼
    //githubAuth 認證
    async githubAuth(username: string, password: string, ctx): Promise<any> {
        return await ctx.curl(GITHUB_OAUTH_API, {
            type: 'GET',
            dataType: 'json',
            url: GITHUB_OAUTH_API,
            headers: {
                'Authorization': ctx.session.xxx
            }
        });
    }
複製程式碼
    // 查詢使用者 
    async find(options: IUserOptions): Promise<IUserResult> {
        const result = await this.userModel.findOne(
            {
                attributes: ['xx', 'xx', 'xx', 'xx', 'xx', "xx"],//相關資訊脫敏
                where: { username: options.username, password: options.password }
            })
            .then(userModel => {
                if (userModel) {
                    return userModel.get({ plain: true });
                }
                return userModel;
            });
        return result;
    }
複製程式碼
    // 通過 URLName 查詢使用者
    async findByUrlName(urlName: string): Promise<IUserResult> {
        return await this.userModel.findOne(
            {
                attributes: ['xxx', 'xxx', 'xxx', 'xxx', 'xxx', "xxx"],
                where: { url_name: urlName }
            }
        ).then(userModel => {
            if (userModel) {
                return userModel.get({ plain: true });
            }
            return userModel;
        });
    }
複製程式碼
    // 建立使用者
    async create(options: IUser): Promise<any> {
        const result = await this.userModel.create(options);
        return result;
    }
    
    // 更新使用者資訊
    async update(id: number, options: IUserOptions): Promise<any> {
        return await this.userModel.update(
            {
                username: options.username,
                password: options.password
            },
            {
                where: { id },
                plain: true
            }
        ).then(([result]) => {
            return result;
        });
    }
複製程式碼

controller

    // inject 獲取 service 和加密字串
    @inject('userService')
    service: IUserService

    @config('random_encrypt')
    RANDOM_STR;
複製程式碼
流程圖中邏輯的程式碼實現
複製程式碼

GitHubOAuth 認證

這裡有坑!我回頭介紹

githubOAuth 認證就是我們常說的 github app 了,這裡我直接了當的丟文件:creating-a-github-app

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)
筆者還是覺得文件類的無需介紹

當然,我這裡肯定都建好了,然後把一些基本資訊都寫到 server 端的配置中

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

還是按照上面的套路,我們們先介紹流程。然後在說坑在哪。

客戶端部分

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

客戶端部分的程式碼就相當簡單了,新開 webView ,直接跳轉到 github.com/login/oauth/authorize 帶上 client_id即可。

server 端

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

整體流程如上,部分程式碼展示:

service

    //獲取 github access_token
    async getOAuthToken(code: string): Promise<any> {
        return await this.ctx.curl(GITHUB_TOKEN_URL, {
            type: "POST",
            dataType: "json",
            data: {
                code,
                client_id: this.GITHUB_CONFIG.client_id,
                client_secret: this.GITHUB_CONFIG.client_secret
            }
        });
    }
複製程式碼

controller程式碼邏輯就是呼叫 service 中的資料來走上面流程圖中的資訊。

OAuth 中的坑

其實,github app 的認證方式非常適用於瀏覽器環境下,但是在 flutter 中,由於我們是新開啟的 webView 來請求的 github 登陸地址。當我們後端成功返回的時候,無法通知到 Flutter 層。就導致我自己的 Flutter 中 dart 寫的程式碼,無法拿到介面的返回。

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

中間腦暴了很多解決辦法,最終在查閱 flutter_webview_plugin 的 API 裡面找了個好的方法:onUrlChanged

簡而言之就是,Flutter 客戶端部分新開一個 webView去請求 github.com/login,github.com/login檢查 client_id 後會帶著code 等亂七八糟的東西來到後端,後端校驗成功後,redirect Flutter 新開的 webView,然後flutter_webview_plugin去監聽頁面 url 的變化。傳送相關 event ,讓Flutter 去 destroy 當前 webVIew,處理剩餘邏輯。

Flutter 部分程式碼

//定義相關 OAuth event
class UserGithubOAuthEvent{
  final String loginName;
  final String token;
  final bool isSuccess;
  UserGithubOAuthEvent(this.loginName,this.token,this.isSuccess);
}
複製程式碼

webView page:

    //在 initState 中監聽 url 變化,並emit event
    flutterWebviewPlugin.onUrlChanged.listen((String url) {
      if (url.indexOf('loginSuccess') > -1) {
        String urlQuery = url.substring(url.indexOf('?') + 1);
        String loginName, token;
        List<String> queryList = urlQuery.split('&');
        for (int i = 0; i < queryList.length; i++) {
          String queryNote = queryList[i];
          int eqIndex = queryNote.indexOf('=');
          if (queryNote.substring(0, eqIndex) == 'loginName') {
            loginName = queryNote.substring(eqIndex + 1);
          }
          if (queryNote.substring(0, eqIndex) == 'accessToken') {
            token = queryNote.substring(eqIndex + 1);
          }
        }
        if (ApplicationEvent.event != null) {
          ApplicationEvent.event
              .fire(UserGithubOAuthEvent(loginName, token, true));
        }
        print('ready close');

        flutterWebviewPlugin.close();
        // 驗證成功
      } else if (url.indexOf('${Api.BASE_URL}loginFail') == 0) {
        // 驗證失敗
        if (ApplicationEvent.event != null) {
          ApplicationEvent.event.fire(UserGithubOAuthEvent('', '', true));
        }
        flutterWebviewPlugin.close();
      }
    });
複製程式碼

login page:

    //event 的監聽、頁面跳轉以及提醒資訊的處理
    ApplicationEvent.event.on<UserGithubOAuthEvent>().listen((event) {
      if (event.isSuccess == true) {
        //  oAuth 認證成功
        if (this.mounted) {
          setState(() {
            isLoading = true;
          });
        }
        DataUtils.getUserInfo(
                {'loginName': event.loginName, 'token': event.token})
            .then((result) {
          setState(() {
            isLoading = false;
          });
          Navigator.of(context).pushAndRemoveUntil(
              MaterialPageRoute(builder: (context) => AppPage(result)),
              (route) => route == null);
        }).catchError((onError) {
          print('獲取身份資訊 error:::$onError');
          setState(() {
            isLoading = false;
          });
        });
      } else {
        Fluttertoast.showToast(
            msg: '驗證失敗',
            toastLength: Toast.LENGTH_SHORT,
            gravity: ToastGravity.CENTER,
            timeInSecForIos: 1,
            backgroundColor: Theme.of(context).primaryColor,
            textColor: Colors.white,
            fontSize: 16.0);
      }
    });
複製程式碼

元件樹獲取

表結構

在聊介面實現的之前,我們先了解下,關於元件,我們的表機構設計大概是什麼樣子的。

FlutterGO 下面 widget tab很多分類,分類點進去還是分類,再點選去是元件,元件點進去是詳情頁。

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)
上圖模組點進去就是元件 widget

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)
上圖是 widget,點進去是詳情頁

所以這裡我們需要兩張表來記錄他們的關係:cat(category)和 widget 表。

cat 表中我們每行資料會有一個 parent_id 欄位,所以表記憶體在父子關係,而 widget 表中的每一行資料的 parent_id 欄位的值必然是 cat 表中的最後一層。比如 Checkbox widgetparent_id 的值就是 cat 表中 Button 的 id。

需求實現

在登陸的時候,我們希望能獲取所有的元件樹,需求方要求結構如下:

[
   {
    "name": "Element",
      "type": "root",
      "child": [
        {
          "name": "Form",
            "type": "group",
            "child": [
              {
                "name": "input",
                  "type": "page",
                  "display": "old",
                  "extends": {},
                  "router": "/components/Tab/Tab"
               },
               {
                "name": "input",
                  "type": "page",
                  "display": "standard",
                  "extends": {},
                  "pageId": "page1_hanxu_172ba42f_0520_401e_b568_ba7f7f6835e4"
               }
            ]
         }
      ],
   }
]
複製程式碼

因為現在存在三方共建元件,而且我們詳情頁也較FlutterGo 1.0 版本有了很大改動,如今元件的詳情頁只有一個,內容全部靠 md 渲染,在 md 中寫元件的 demo 實現。所以為了相容舊版本的 widget,我們有 display 來區分,新舊 widget 分別通過 pageIdrouter 來跳轉頁面。

新建 widget 的 pageId 是通過FlutterGo 腳手架 goCli生成的

目前實現實際返回為:

{
    "success": true,
    "data": [
        {
            "id": "3",
            "name": "Element",
            "parentId": 0,
            "type": "root",
            "children": [
                {
                    "id": "6",
                    "name": "Form",
                    "parentId": 3,
                    "type": "category",
                    "children": [
                        {
                            "id": "9",
                            "name": "Input",
                            "parentId": 6,
                            "type": "category",
                            "children": [
                                {
                                    "id": "2",
                                    "name": "TextField",
                                    "parentId": "9",
                                    "type": "widget",
                                    "display": "old",
                                    "path": "/Element/Form/Input/TextField"
                                }
                            ]
                        },
                        {
                            "id": "12",
                            "name": "Text",
                            "parentId": 6,
                            "type": "category",
                            "children": [
                                {
                                    "id": "3",
                                    "name": "Text",
                                    "parentId": "12",
                                    "type": "widget",
                                    "display": "old",
                                    "path": "/Element/Form/Text/Text"
                                },
                                {
                                    "id": "4",
                                    "name": "RichText",
                                    "parentId": "12",
                                    "type": "widget",
                                    "display": "old",
                                    "path": "/Element/Form/Text/RichText"
                                }
                            ]
                        },
                        {
                            "id": "13",
                            "name": "Radio",
                            "parentId": 6,
                            "type": "category",
                            "children": [
                                {
                                    "id": "5",
                                    "name": "TestNealya",
                                    "parentId": "13",
                                    "type": "widget",
                                    "display": "standard",
                                    "pageId": "page1_hanxu_172ba42f_0520_401e_b568_ba7f7f6835e4"
                                }
                            ]
                        }
                    ]
                }
            ]
        }
        {
            "id": "5",
            "name": "Themes",
            "parentId": 0,
            "type": "root",
            "children": []
        }
    ]
}
複製程式碼

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

簡單示例,省去 99%資料

程式碼實現

其實這個介面也是非常簡單的,就是個雙迴圈遍歷嘛,準確的說,有點類似深度優先遍歷。直接看程式碼吧

獲取所有 parentId 相同的 category (後面簡稱為 cat)

async getAllNodeByParentIds(parentId?: number) {
    if (!!!parentId) {
        parentId = 0;
    }

    return await this.catService.getCategoryByPId(parentId);
}
複製程式碼

首字母轉小寫

firstLowerCase(str){
    return str[0].toLowerCase()+str.slice(1);
}
複製程式碼

我們只要自己外部維護一個元件樹,然後cat表中的讀取到的每一個parent_id都是一個節點。當前 id 沒有別的 cat 對應的 parent_id就說明它的下一級是“葉子” widget了,所以就從 widget 中查詢即可。easy~

    //刪除部分不用程式碼
   @get('/xxx')
    async getCateList(ctx) {
        const resultList: IReturnCateNode[] = [];
        let buidList = async (parentId: number, containerList: Partial<IReturnCateNode>[] | Partial<IReturnWidgetNode>[], path: string) => {
            let list: IReturnCateNode[] = await this.getAllNodeByParentIds(parentId);
            if (list.length > 0) {
                for (let i = 0; i < list.length; i++) {
                    let catNode: IReturnCateNode;
                    catNode = {
                        xxx:xxx
                    }
                    containerList.push(catNode);
                    await buidList(list[i].id, containerList[i].children, `${path}/${this.firstLowerCase(containerList[i].name)}`);
                }
            } else {
                // 沒有 cat 表下 children,判斷是否存在 widget
                const widgetResult = await this.widgetService.getWidgetByPId(parentId);
                if (widgetResult.length > 0) {
                    widgetResult.map((instance) => {
                        let tempWidgetNode: Partial<IReturnWidgetNode> = {};
                        tempWidgetNode.xxx = instance.xxx;
                        if (instance.display === 'old') {
                            tempWidgetNode.path = `${path}/${this.firstLowerCase(instance.name)}`;
                        } else {
                            tempWidgetNode.pageId = instance.pageId;
                        }
                        containerList.push(tempWidgetNode);
                    });
                } else {
                    return null;
                }

            }
        }
        await buidList(0, resultList, '');
        ctx.body = { success: true, data: resultList, status: 200 };
    }
複製程式碼

彩蛋

FlutterGo 中有一個元件搜尋功能,因為我們儲存 widget 的時候,並沒有強制帶上該 widget的路由,這樣也不合理(針對於舊元件),所以在widget表中搜尋出來,還要像上述過程那樣逆向搜尋獲取“舊”widgetrouter欄位

我的個人程式碼實現大致如下:

    @get('/xxx')
    async searchWidget(ctx){
        let {name} = ctx.query;
        name = name.trim();
        if(name){
            let resultWidgetList = await this.widgetService.searchWidgetByStr(name);
            if(xxx){
                for(xxx){
                    if(xxx){
                        let flag = true;
                        xxx
                        while(xxx){
                            let catResult = xxx;
                            if(xxx){
                               xxx
                                if(xxx){
                                    flag = false;
                                }
                            }else{
                                flag = false;
                            }
                        }
                        resultWidgetList[i].path = path;
                    }
                }
                ctx.body={success:true,data:resultWidgetList,message:'查詢成功'};
            }else{
                ctx.body={success:true,data:[],message:'查詢成功'};
            }
        }else{
            ctx.body={success:false,data:[],message:'查詢欄位不能為空'};
        }
        
    }
複製程式碼

求大神指教最簡實現~?

收藏功能

收藏功能,必然是跟使用者掛鉤的。然後收藏的元件該如何跟使用者掛鉤呢?元件跟使用者是多對多的關係。

這裡我新建一個collection表來用作所有收藏過的元件。為什麼不直接使用widget表呢,因為我個人不希望表太過於複雜,無用的欄位太多,且功能不單一。

由於是收藏的元件和使用者是多對多的關係,所以這裡我們需要一箇中間表user_collection來維護他兩的關係,三者關係如下:

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

功能實現思路

  • 校驗收藏

    • collection表中檢查使用者傳入的元件資訊,沒有則為收藏、有則取出其在 collection 表中的 id
    • session 中獲取使用者的 id
    • collection_iduser_id 來檢索user_collection表中是否有這個欄位
  • 新增收藏

    • 獲取使用者傳來的元件資訊
    • findOrCrate的檢索 collection表,並且返回一個 collection_id
    • 然後將 user_idcollection_id存入到 user_collection 表中(互不信任原則,校驗下存在性)
  • 移除收藏

    • 步驟如上,拿到 collection 表中的 collection_id
    • 刪除 user_collection 對應欄位即可
  • 獲取全部收藏

    • 檢索 collection 表中所有 user_id 為當前使用者的所有 collection_id
    • 通過拿到的collection_ids 來獲取收藏的元件列表

部分程式碼實現

整體來說,思路還是非常清晰的。所以這裡我們僅僅拿收藏和校驗來展示下部分程式碼:

service層程式碼實現

    @inject()
    userCollectionModel;
        async add(params: IuserCollection): Promise<IuserCollection> {
        return await this.userCollectionModel.findOrCreate({
            where: {
                user_id: params.user_id, collection_id: params.collection_id
            }
        }).then(([model, created]) => {
            return model.get({ plain: true })
        })
    }

    async checkCollected(params: IuserCollection): Promise<boolean> {
        return await this.userCollectionModel.findAll({
            where: { user_id: params.user_id, collection_id: params.collection_id }
        }).then(instanceList => instanceList.length > 0);
    }
複製程式碼

controller層程式碼實現

    @inject('collectionService')
    collectionService: ICollectionService;

    @inject()
    userCollectionService: IuserCollectionService

    @inject()
    ctx;
    
    // 校驗元件是否收藏
    @post('/xxx')
    async checkCollected(ctx) {
        if (ctx.session.userInfo) {
            // 已登入
            const collectionId = await this.getCollectionId(ctx.request.body);
            const userCollection: IuserCollection = {
                user_id: this.ctx.session.userInfo.id,
                collection_id: collectionId
            }
            const hasCollected = await this.userCollectionService.checkCollected(userCollection);
            ctx.body={status:200,success:true,hasCollected};

        } else {
            ctx.body={status:200,success:true,hasCollected:false};
        }
    }
    
    async addCollection(requestBody): Promise<IuserCollection> {

        const collectionId = await this.getCollectionId(requestBody);

        const userCollection: IuserCollection = {
            user_id: this.ctx.session.userInfo.id,
            collection_id: collectionId
        }

        return await this.userCollectionService.add(userCollection);
    }
複製程式碼

因為常要獲取 collection 表中的 collection_id 欄位,所以這裡抽離出來作為公共方法

    async getCollectionId(requestBody): Promise<number> {
        const { url, type, name } = requestBody;
        const collectionOptions: ICollectionOptions = {
            url, type, name
        };
        const collectionResult: ICollection = await this.collectionService.findOrCreate(collectionOptions);
        return collectionResult.id;
    }
複製程式碼

feedback 功能

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

feedback 功能就是直接可以在 FlutterGo 的個人設定中,傳送 issue 到 Alibaba/flutter-go 下。這裡主要也是呼叫 github 的提 issue 介面 api issues API

後端的程式碼實現非常簡單,就是拿到資料,呼叫 github 的 api 即可

service

    @inject()
    ctx;

    async feedback(title: string, body: string): Promise<any> {
        return await this.ctx.curl(GIHTUB_ADD_ISSUE, {
            type: "POST",
            dataType: "json",
            headers: {
                'Authorization': this.ctx.session.headerAuth,
            },
            data: JSON.stringify({
                title,
                body,
            })
        });
    }
複製程式碼

controller

    @inject('userSettingService')
    settingService: IUserSettingService;

    @inject()
    ctx;

    async feedback(title: string, body: string): Promise<any> {
        return await this.settingService.feedback(title, body);
    }
複製程式碼

彩蛋

猜測可能會有人 FlutterGo 裡面這個 feedback 是用的哪一個元件~這裡介紹下

pubspec.yaml

  zefyr:
    path: ./zefyr
複製程式碼

因為在開發的時候,flutter 更新了,導致zefyr 執行報錯。當時也是提了 issue:chould not Launch FIle (寫這篇文章的時候才看到回覆)

但是當時由於功能開發要釋出,等了好久沒有zefyr作者的回覆。就在本地修復了這個 bug,然後包就直接引入本地的包了。

共建計劃

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

咳咳,敲黑板啦~~

Flutter 依舊在不斷地更新,但僅憑我們幾個 Flutter 愛好者在工作之餘維護 FlutterGo 還是非常吃力的。所以這裡,誠邀業界所有 Flutter 愛好者一起參與共建 FlutterGo!

此處再次感謝所有已經提交 pr 的小夥伴

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

共建說明

由於 Flutter 版本迭代速度較快,產生的內容較多, 而我們人力有限無法更加全面快速的支援Flutter Go的日常維護迭代, 如果您對flutter go的共建感興趣, 歡迎您來參與本專案的共建.

凡是參與共建的成員. 我們會將您的頭像與github個人地址收納進我們的官方網站中.

共建方式

  1. 共建元件
  • 本次更新, 開放了 Widget 內容收錄 的功能, 您需要通過 goCli 工具, 建立標準化元件,編寫markdown程式碼。

  • 為了更好記錄您的改動目的, 內容資訊, 交流過程, 每一條PR都需要對應一條 Issue, 提交你發現的BUG或者想增加的新功能, 或者想要增加新的共建元件,

  • 首先選擇你的issue在型別,然後通過 Pull Request 的形式將文章內容, api描述, 元件使用方法等加入進我們的Widget介面。

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

  1. 提交文章和修改bug
  • 您也可以將例如日常bug. 未來feature等的功能性PR, 申請提交到我們的的主倉庫。

參與共建

關於如何提PR請先閱讀以下文件

貢獻指南

此專案遵循貢獻者行為準則。參與此專案即表示您同意遵守其條款.

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

FlutterGo 期待你我共建~

具體 pr 細節和流程可參看 FlutterGo README 或 直接釘釘掃碼入群

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

學習交流

關注公眾號: 【全棧前端精選】 每日獲取好文推薦。還可以入群,一起學習交流呀~~

FlutterGo 後端知識點提煉:midway+Typescript+mysql(sequelize)

相關文章