飞书API 2-6:如何将 MySQL 数据库的查询结果写入多维表(中)

一、引入

接上一篇,已经解决了单向的数据传输问题,即:将 MySQL 的数据写入到多维表。
但是如果需求具有持续性,换句话说,每隔一段时间需要同步一次数据,今天需要同步数据,明天还需要同步数据,又该怎么办?
数据的同步中一般会有两种同步策略:

  • 增量同步:只同步新增的数据;
  • 全量同步:同步所有数据。

本文针对这两种同步策略展开探讨下如何持续同步数据。

二、策略之增量同步

增量同步主要解决两个问题,一个是怎么做增量,一个是补数据。

怎么做增量?
其实逻辑很简单,就是通过时间划分区间,下一个区间开始或者上一个去接结束后,同步上一个区间的数据,比如说今天同步昨天产生的数据。只要控制好时间范围就可以了。这是针对只插入不更新的表单而言的策略。
如果是事务表会根据时间的推移发生数据的变更,比如说订单状态由下单成功变成了退款成功,那么就会涉及到数据更新的逻辑,相应的,为了保证数据的一致性,如果使用到订单状态,则需要进行数据更新操作。
什么是补数据?
其实就是数据缺了需要补充,比如说你周末放假了,没有跑任务,到了周一再数据,这就是补数据。如果是通过调度工具跑任务,则更多的是任务的数据未来得及更新或者跑失败了,需要重新跑数,补全数据。通常也是修改同步的时间范围即可。

2.1 增量同步配置

在 SQL 代码中加一个“where”条件限制即可,假设 T+1 更新,参考设置如下,通过datetime模块获取今天和昨天的日期,再通过自定义函数date_to_timestamp()将日期转为时间戳,然后传递到 SQL 中,限制paid_time的范围。
注意:user_orders表的paid_time是秒级时间戳,所以转化的时间戳也是秒级,下同。

import datetime

def date_to_timestamp(date_):
    timestamp = int(datetime.datetime(date_.year, date_.month, date_.day).timestamp())
    return timestamp
yesterday = datetime.datetime.now().date() - datetime.timedelta(days=1)		# 昨天
today = datetime.datetime.now().date()										# 今天
yesterday_timestamp = date_to_timestamp(yesterday)
today_timestamp = date_to_timestamp(today)

sql = f'''
select uo.user_id								as "用户ID"
  ,u.nickname									as "昵称"
  ,(case u.sex when 0 then '女' when 1 then '男' else '未知' end) as "性别"
  ,u.mobile										as "手机号"
  ,u.city										as "城市"
  ,uo.id            							as "订单号"
  ,uo.paid_time*1000 							as "下单时间"
  ,uo.amount/100    							as "下单金额"
from my_datas.user_orders uo
join my_datas.users u on u.id=uo.user_id
where uo.production_id=10 
and uo.paid_time>={yesterday_timestamp}
and uo.paid_time< {today_timestamp};
'''

如果涉及数据更新该怎么办呢?
可以根据更新时间updated_at进行判断,选取updated_at时间在昨天的数据,然后和现有的 SQL 进行 union(去重合并),然后需要拉飞书多维表的数据进行匹配,将已存在的数据视为需要更新的数据,其他的则是新增的数据。
具体操作可参考下文全量同步的数据分类逻辑。

2.2 补数据

如果需要补数据,则传递日期的标准格式字符串,如:2024年1月1日,使用“2024-01-01”。
特别注意的是,存在一个日期边界的问题,结束日期需要加 1 天,即 86400 秒,否则会少记录结束日期当天的数据。

import datetime

def date_to_timestamp(date_str):
    y_m_d = date_str.split('-') 
    timestamp = int(datetime.datetime(y_m_d[0], y_m_d[1], y_m_d[2]).timestamp())
    return timestamp
start_date = '2024-01-01'								# 开始日期
end_date = '2024-01-03'									# 结束日期
start_timestamp = date_to_timestamp(start_date)
end_timestamp = date_to_timestamp(end_date) + 86400  	# 需要加 1 天

sql = f'''
select uo.user_id								as "用户ID"
  ,u.nickname									as "昵称"
  ,(case u.sex when 0 then '女' when 1 then '男' else '未知' end) as "性别"
  ,u.mobile										as "手机号"
  ,u.city										as "城市"
  ,uo.id            							as "订单号"
  ,uo.paid_time*1000 							as "下单时间"
  ,uo.amount/100    							as "下单金额"
from my_datas.user_orders uo
join my_datas.users u on u.id=uo.user_id
where uo.production_id=10 
and uo.paid_time>={start_timestamp}
and uo.paid_time< {end_timestamp};
'''

三、策略之全量同步

全量同步一般有两种方式,一种是删除旧数据重新插入所有数据,一种是更新旧数据+插入新数据+删除异常数据。

  • 前者会清除所有历史的数据,如果业务人员在多维表上仅读数据,不编辑数据,比如说新增列记录是否已经回访用户,用户的反馈是什么等,可以采用,但是如果有做了一些数据标记,则行不通;
  • 后者则支持业务人员共创协作,针对历史数据做更新,对新增的数据直接插入,当然对于异常的数据,比如说业务人员自行新增的行,需要视具体情况进行处理,或标记或删除。

3.1 先删除后全量新增

这种方式比较简单粗暴,主要是在上一篇的基础上加上一个删除历史数据的逻辑。先删除历史数据,删完之后直接跑下上一篇的代码即可完成全量数据新增。

删除历史数据的逻辑也相对简单,直接读取多维表全部数据,然后获取所有的“record_id”,按每份 500 个进行切分,然后循环传递给飞书多维表删除多条记录的 API 接口进行删除即可。
读取多维表全部数据,这个在《飞书API(3):Python 自动读取多维表所有分页数据的三种方法》中已经介绍过,可以拿来即用;调接口删除数据在《飞书 API 2-4:如何使用 API 将数据写入数据表》中也有涉及到。所以该方式最终只需要补充一个函数,用于处理“record_id”,把“record_id”提取出来并切分即可。
注意:以下是一个伪代码,不能直接执行。


# 读取代码:函数为xxx
def 读取多维表函数():
    """《飞书API(3):Python 自动读取多维表所有分页数据的三种方法》代码"""
    feishu_datas = [{"fields":{},"record_id":"1"},{"fields":{},"record_id":"2"}]
    return feishu_datas
    
def 读数据库写入多维表函数():
    """飞书API 2-5:如何将 MySQL 数据库的查询结果写入多维表(上)"""
    pass

def delete_records(access_token,app_token,table_id,request_body):
    """《飞书 API 2-4:如何使用 API 将数据写入数据表》代码"""
    url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_delete"
    payload = json.dumps(request_body)

    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {access_token}'
    }

    response = requests.request("POST", url, headers=headers, data=payload)
    code = response.json()['code']
    if code == 0:
        len_record = len(request_body["records"])
        print(f"成功删除 {len_record} 数据。关联函数:delete_records。")
    else:
        msg = response.json().get("msg")
        raise f"更新数据失败,失败信息:{msg}。关联函数:delete_records。"

# 新增的代码
def format_record_id(feishu_datas,change_size=500):
    """格式化 record_id:提取并切分"""
    datas_field = [record['record_id'] for record in feishu_datas]
    
    body_ls = []
    for i in range(0,len(datas_field), change_size):
        data_records = datas_field[i:i+change_size]
        body_ls.append(data_records)
    return body_ls

def main():
    feishu_datas = 读取多维表函数()
    record_id_ls = get_record_id(feishu_datas)
    for request_body in body_ls:
        delete_records(access_token,app_token,table_id,request_body)
    读数据库写入多维表函数()

if __name__ == '__main__':
    main()

3.2 更新旧数据+插入新数据

更新数据,需要获取每一行记录的“record_id”,而“record_id”和数据库查询到的数据,没有直接的关系,需要通过一个“桥梁”来把二者串联起来,这就需要使用到每一行记录的唯一值,上一篇的案例中,可以使用用户ID 或者订单 ID 来做这个桥梁。将数据关联起来之后,需要针对不同的数据进行处理,所以需要分类。如果数据超过了接口的最高限制 500 条记录,还需要考虑对分类数据进行分组。

所以整个流程可分为

  • 先对数据进行格式化,主要是提取“外键”;
  • 对格式化数据进行分类
  • 对分类数据进行分组
  • 遍历分组,调接口实现数据的变更

3.2.1 格式化:提取“外键”

分类之前还需要格式化。格式化分两步,一步是处理数据库数据,另外一步是处理飞书多维表的数据。

  • 处理数据库数据:将数据库的数据处理为接口数据的格式,并提取出外键
  • 处理飞书数据:提取外键和“record_id”。

前面在《飞书API 2-5》中也有介绍到将数据库读取到的数据格式为 API 所需要的请求头,如下图,这里把格式化和分组拆开,因为中间涉及一个分类的操作。
image.png

格式化数据库数据,将数据库数据以键值对的形式包括起来作为“fields”的值,并把外键“订单号”提取出来,最终转化为 DataFrame 返回,该 DataFrame 共有 2 列:“fields”和“foreign_key”。

# 新增格式化
def format_db_datas(df_db, foreign_key):
    """格式化数据库数据:整理为 API 所需格式,并将 外键 提取出来"""
    # 处理为结构 [{foreign_key: <外键值>,'fields' : <所有明细数据>]
    datas_ls = df_db.to_dict(orient='records')
    datas_field = [{foreign_key: data[foreign_key],'fields' : data} for data in datas_ls]
    # 转为DataFrame,以便下 Python SQL 处理
    df_db = pd.DataFrame(datas_field)
    print(f'成功提取【数据库】的关键字段:fields, {foreign_key}。方法:format_db_datas')
    return df_db

格式化飞书多维表数据,由于飞书多维表不同的数据类型的数据格式不同,需要多传递一个数据类型的编号,以便对“foreign_key”进行格式化,此代码中,“foreign_key”仅支持较为常用的几种数据类型:文本、数字、单选、手机号、日期。另外,为了避免多维表是一个空表,加了一个判断,当为空表是生成一个空的 DataFrame。最终返回一个 DataFrame,包含 2 列:“record_id”和“foreign_key”。

def format_feishu_datas(feishu_datas, foreign_key, data_type=1):
    """格式化飞书数据:提取关键字段:外键 和 record_id"""
    if feishu_datas == []:
        #如果飞书多维表没有数据,返回无值的DataFrame
        df_tb = pd.DataFrame(columns=['record_id', foreign_key])
    else:
        df_tb = pd.DataFrame(feishu_datas)

        if data_type == 1: 	# 文本
            df_tb[foreign_key] = df_tb['fields'].map(lambda x: x.get(foreign_key)[0]['text'] if x.get(foreign_key) else None)
        elif data_type  in (2, 3, 13, 5):  # 数字、单选、手机号、日期
            df_tb[foreign_key] = df_tb['fields'].map(lambda x: x.get(foreign_key) if x.get(foreign_key) else None)
        else:
            raise '暂不支持改类型!'

        # 实际生产过程,可能新增行行为。
        df_tb_ok = df_tb[~df_tb[foreign_key].isna()]
        df_tb_ok = df_tb_ok[['record_id', foreign_key]]
    invalid_num = df_tb[df_tb[foreign_key].isna()].shape[0]
    print(f'成功提取【飞书表单】的关键字段:record_id, {foreign_key}。发现无效数据 {invalid_num} 条。方法:format_feishu_datas')
    return df_tb_ok

3.2.2 分类与分组

由于涉及更新、插入或删除操作,需要将数据进行分类。基本逻辑是将从数据库读取到的数据进行分类,如果多维表已经有记录则进行更新,多维表没有的进行新增,多维表多出的进行删除或标记。

可以类比数据库的联结操作,分类逻辑伪代码参考如下:

-- 更新集合:取多维表的 record_id + 数据库的数据
select 多维表数据.record_id,数据库数据.*
from 数据库数据
join 多维表数据 on 多维表数据.订单ID=数据库数据.订单ID

-- 插入集合:取数据库多出部分
select 数据库数据.*
from 数据库数据
left join 多维表数据 on 多维表数据.订单ID=数据库数据.订单ID
where 多维表数据.订单ID is null

-- 删除或标记集合
select 多维表数据.record_id
from 数据库数据
right join 多维表数据 on 多维表数据.订单ID=数据库数据.订单ID
where 数据库数据.订单ID is null

特别注意:先分类再分组,即每个集合分完之后才做数据分组,按接口上限 500 条记录切分。

在 Python 中,可以通过merge()方法来实现,当涉及“where”条件时,可以通过query()方法来实现,由于 NaN 不等于 NaN,所以可以使用“record_id != record_id”或“fields != fields”筛选出该字段值为空的行。参考代码如下:

def classify_datas(df_from, df_join, on=None, left_on=None, right_on=None):
    """将两个[{},{}]或 DF 结构的数据分类。ls_from 为数据库数据,ls_join 为多维表数据"""    
    if on is not None:
        df_cre = df_from.merge(df_join, how='left' , on=on).query('record_id != record_id')[['fields']] 	# 等同于'record_id.isnull()',因为 NaN != NaN
        df_ups = df_from.merge(df_join, how='inner', on=on)[['record_id', 'fields']]
        df_del = df_from.merge(df_join, how='right', on=on).query('fields != fields')[['record_id']] 		# 等同于'fields.isnull()',因为 NaN != NaN
    else:
        df_cre = df_from.merge(df_join, how='left' , left_on=left_on, right_on=right_on).query('record_id != record_id')[['fields']] 	# 等同于'record_id.isnull()',因为 NaN != NaN
        df_ups = df_from.merge(df_join, how='inner', left_on=left_on, right_on=right_on)
        df_del = df_from.merge(df_join, how='right', left_on=left_on, right_on=right_on).query('fields != fields')[['record_id']] 		# 等同于'fields.isnull()',因为 NaN != NaN

    print('数据分类:新增数据集(df_cre)的数据量为 %s, 更新数据集(df_ups)的数据量为 %s, 删除数据集(df_ups)的数据量为 %s。方法:classify_datas' % (df_cre.shape[0], df_ups.shape[0], df_del.shape[0]))
    return df_cre, df_ups, df_del

分好类之后,需要对数据进行分组,新增和更新接口的请求体外层结构的“records”的值是一个字典结构,而删除接口是一个列表结构,所以数据分组的时候需要分 2 种情况进行处理,分别是“dict”和“list”。


def cut_datas(df,mode='dict',page_size=500):
    """
    按指定尺寸切分数据,并转为API要求格式
    切割数据:接口数据上限500
    mode: 模式,默认是字典格式 dict(插入/更新结构),即 DF.to_dict(orient='records')。还支持 list(删除结构),即 Series.to_list()
    """
    df_ls = []
    for i in range(0,df.shape[0], page_size):
        if mode=='dict':
            data_records = df.iloc[i:i+page_size].to_dict(orient='records')
        elif mode=='list':
            data_records = df.iloc[i:i+page_size].record_id.to_list()
        data_to_table = {"records": data_records}
        df_ls.append(data_to_table)
    print('数据切割:切割数据集为 %s 份。方法:datas_processing.cut_dataset' % (len(df_ls)))
    return df_ls

3.2.2 调接口处理数据

直接拿前面《飞书API 2-6》的代码复用即可。不同点在于此处需要遍历分组的列表,然后调用接口进行数据操作。

3.3.4 整合代码

整个流程包含获取飞书 token,再读取飞书多维表数据和读取数据库数据,然后对二者进行格式化,然后联结起来做分类和分组,然后调用飞书的 API 进行数据新增、更新或删除操作。
工作流程图参考如下:

读取飞书多维表数据可以参考《飞书API(7)》,读取数据库数据,参考《飞书API 2-5》,调接口进行增删改记录,参考《飞书 API 2-4》,新增部分主要是格式化、联结数据之后进行分类和分组。
具体参考代码如下:

import requests
import json
import datetime
import pandas as pd
from sqlalchemy import create_engine, text
from urllib.parse import urlparse, parse_qs

# 读取飞书多维表数据:参考《飞书API(7):MySQL 入库通用版本》
def get_table_params(bitable_url):
    # bitable_url = "https://feishu.cn/base/aaaaaaaa?table=tblccc&view=vewddd"
    parsed_url = urlparse(bitable_url)              #解析url:(ParseResult(scheme='https', netloc='feishu.cn', path='/base/aaaaaaaa', params='', query='table=tblccc&view=vewddd', fragment='')
    query_params = parse_qs(parsed_url.query)       #解析url参数:{'table': ['tblccc'], 'view': ['vewddd']}
    app_token = parsed_url.path.split('/')[-1]
    table_id = query_params.get('table', [None])[0]
    view_id = query_params.get('view', [None])[0]
    print(f'成功解析链接,app_token:{app_token},table_id:{table_id},view_id:{view_id}。关联方法:get_table_params。')
    return app_token, table_id, view_id

def get_tenant_access_token(app_id, app_secret):
    url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
    payload = json.dumps({
        "app_id": app_id,
        "app_secret": app_secret
    })
    headers = {'Content-Type': 'application/json'}
    response = requests.request("POST", url, headers=headers, data=payload)
    tenant_access_token = response.json()['tenant_access_token']
    print(f'成功获取tenant_access_token:{tenant_access_token}。关联函数:get_table_params。')
    return tenant_access_token

def get_bitable_datas(tenant_access_token, app_token, table_id, view_id, page_token='', page_size=20):
    url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/search?page_size={page_size}&page_token={page_token}&user_id_type=user_id"
    payload = json.dumps({"view_id": view_id})
    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {tenant_access_token}'
    }
    response = requests.request("POST", url, headers=headers, data=payload)
    print(f'成功获取page_token为【{page_token}】的数据。关联函数:get_bitable_datas。')
    return response.json()


def get_all_bitable_datas(tenant_access_token, app_token, table_id, view_id, page_token='', page_size=20):
    has_more = True
    feishu_datas = []
    while has_more:
        response = get_bitable_datas(tenant_access_token, app_token, table_id, view_id, page_token, page_size)
        if response['code'] == 0:
            page_token = response['data'].get('page_token')
            has_more = response['data'].get('has_more')
            # print(response['data'].get('items'))
            # print('\n--------------------------------------------------------------------\n')
            feishu_datas.extend(response['data'].get('items'))
        else:
            raise Exception(response['msg'])
    print(f'成功获取飞书多维表所有数据,返回 feishu_datas。关联函数:get_all_bitable_datas。')
    return feishu_datas

# ------------------------------------------------------------------------------------------------------------------------
# 读取数据库数据,参考《飞书API 2-5:如何将 MySQL 数据库的查询结果写入多维表(上)》

def get_datas(sql, connect_info, fields_type):
    # connect_info = 'mysql+pymysql://{}:{}@{}:{}/{}?charset=utf8'\
    # .format("your_user_name", "your_password", "127.0.0.1", "3306","my_datas")
    engine = create_engine(connect_info)
    result = pd.read_sql(sql, engine)
    result = result.astype(fields_type,errors='ignore')
    # result.info()
    return result

# def format_to_reqbody(df,change_size=500):
#     df_dict = df.to_dict(orient='records')
#     datas_field = [{'fields' : data} for data in df_dict]

#     df_ls = []
#     for i in range(0,len(datas_field), change_size):
#         data_records = {"records": datas_field[i:i+change_size]}
#         # data_to_table = json.dumps(data_records).replace(': NaN',': null')
#         df_ls.append(data_records)
    
#     print('数据格式化并切割:切割数据集为 %s 份。关联函数:format_to_reqbody' % (len(df_ls)))
#     return df_ls


# ------------------------------------------------------------------------------------------------------------------------------

# 新增格式化、分类与分组
def format_db_datas(df_db, foreign_key):
    """格式化数据库数据:整理为 API 所需格式,并将 外键 提取出来"""
    # 处理为结构 [{foreign_key: <外键值>,'fields' : <所有明细数据>]
    datas_ls = df_db.to_dict(orient='records')
    datas_field = [{foreign_key: data[foreign_key],'fields' : data} for data in datas_ls]
    # 转为DataFrame,以便下 Python SQL 处理
    df_db = pd.DataFrame(datas_field)
    print(f'成功提取【数据库】的关键字段:fields, {foreign_key}。方法:format_db_datas')
    return df_db

def format_feishu_datas(feishu_datas, foreign_key, data_type=1):
    """格式化飞书数据:提取关键字段:外键 和 record_id"""
    if feishu_datas == []:
        #如果飞书多维表没有数据,返回无值的DataFrame
        df_tb = pd.DataFrame(columns=['record_id', foreign_key])
    else:
        df_tb = pd.DataFrame(feishu_datas)
        
        if data_type == 1:
            df_tb[foreign_key] = df_tb['fields'].map(lambda x: x.get(foreign_key)[0]['text'] if x.get(foreign_key) else None)
        elif data_type  in (2, 3, 13, 5):  #数字、单选、手机号、日期
            df_tb[foreign_key] = df_tb['fields'].map(lambda x: x.get(foreign_key) if x.get(foreign_key) else None)
        else:
            raise '暂不支持改类型!'

        # 实际生产过程,可能新增行行为。
        df_tb_ok = df_tb[~df_tb[foreign_key].isna()]
        df_tb_ok = df_tb_ok[['record_id', foreign_key]]
    invalid_num = df_tb[df_tb[foreign_key].isna()].shape[0]
    print(f'成功提取【飞书表单】的关键字段:record_id, {foreign_key}。发现无效数据 {invalid_num} 条。方法:format_feishu_datas')
    return df_tb_ok

def classify_datas(df_from, df_join, on=None, left_on=None, right_on=None):
    """将两个[{},{}]或 DF 结构的数据分类。ls_from 为数据库数据,ls_join 为多维表数据"""    
    if on is not None:
        df_cre = df_from.merge(df_join, how='left' , on=on).query('record_id != record_id')[['fields']] # 等同于'record_id.isnull()',因为 NaN != NaN
        df_ups = df_from.merge(df_join, how='inner', on=on)[['record_id', 'fields']]
        df_del = df_from.merge(df_join, how='right', on=on).query('fields != fields')[['record_id']] # 等同于'fields.isnull()',因为 NaN != NaN
    else:
        df_cre = df_from.merge(df_join, how='left' , left_on=left_on, right_on=right_on).query('record_id != record_id')[['fields']] # 等同于'record_id.isnull()',因为 NaN != NaN
        df_ups = df_from.merge(df_join, how='inner', left_on=left_on, right_on=right_on)
        df_del = df_from.merge(df_join, how='right', left_on=left_on, right_on=right_on).query('fields != fields')[['record_id']] # 等同于'fields.isnull()',因为 NaN != NaN

    print('数据分类:新增数据集(df_cre)的数据量为 %s, 更新数据集(df_ups)的数据量为 %s, 删除数据集(df_ups)的数据量为 %s。方法:classify_datas' % (df_cre.shape[0], df_ups.shape[0], df_del.shape[0]))
    return df_cre, df_ups, df_del

def cut_datas(df,mode='dict',page_size=500):
    """
    按指定尺寸切分数据,并转为API要求格式
    切割数据:接口数据上限500
    mode: 模式,默认是字典格式 dict(插入/更新结构),即 DF.to_dict(orient='records')。还支持 list(删除结构),即 Series.to_list()
    """
    df_ls = []
    for i in range(0,df.shape[0], page_size):
        if mode=='dict':
            data_records = df.iloc[i:i+page_size].to_dict(orient='records')
        elif mode=='list':
            data_records = df.iloc[i:i+page_size].record_id.to_list()
        #插入和更新都使用该结构
        # data_to_table = json.dumps({"records": data_records}).replace('NaN','null')
        # data_to_table = json.dumps({"records": data_records}).replace(': NaN',': null')  # 使用re也可以,不过大材小用了。
        data_to_table = {"records": data_records}
        # _data_to_table = json.dumps({"records": data_records})
        # data_to_table = re.sub(r': NaN', ': null', _data_to_table)
        df_ls.append(data_to_table)
    print('数据切割:切割数据集为 %s 份。方法:datas_processing.cut_dataset' % (len(df_ls)))
    return df_ls

# -----------------------------------------------------------------------------------------------------------------------
# 调接口进行增删改记录,参考《飞书 API 2-4:如何使用 API 将数据写入数据表》
def insert_records(access_token,app_token,table_id,request_body):
    url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_create"
    
    payload =  json.dumps(request_body).replace(': NaN',': null')
    
    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {access_token}'
    }

    response = requests.request("POST", url, headers=headers, data=payload)
    code = response.json()['code']
    if code == 0:
        len_record = len(request_body["records"])
        print(f"成功插入 {len_record} 数据。关联函数:insert_records。")
    else:
        msg = response.json().get("msg")
        raise f"插入数据失败,失败信息:{msg}。关联函数:insert_records。"

def update_records(access_token,app_token,table_id,request_body):
    url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_update"
    payload =  json.dumps(request_body).replace(': NaN',': null')

    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {access_token}'
    }

    response = requests.request("POST", url, headers=headers, data=payload)
    code = response.json()['code']
    if code == 0:
        len_record = len(request_body["records"])
        print(f"成功更新 {len_record} 条数据。关联函数:update_records。")
    else:
        msg = response.json().get("msg")
        raise f"更新数据失败,失败信息:{msg}。关联函数:update_records。"

def delete_records(access_token,app_token,table_id,request_body):
    url = f"https://open.feishu.cn/open-apis/bitable/v1/apps/{app_token}/tables/{table_id}/records/batch_delete"
    payload =  json.dumps(request_body)

    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {access_token}'
    }

    response = requests.request("POST", url, headers=headers, data=payload)
    code = response.json()['code']
    if code == 0:
        len_record = len(request_body["records"])
        print(f"成功删除 {len_record} 数据。关联函数:delete_records。")
    else:
        msg = response.json().get("msg")
        raise f"更新数据失败,失败信息:{msg}。关联函数:delete_records。"

def get_tenant_access_token(app_id, app_secret):
    url = "https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal"
    payload = json.dumps({
        "app_id": app_id,
        "app_secret": app_secret
    })
    headers = {'Content-Type': 'application/json'}
    response = requests.request("POST", url, headers=headers, data=payload)
    tenant_access_token = response.json()['tenant_access_token']
    print(f'成功获取tenant_access_token:{tenant_access_token}。关联函数:get_table_params。')
    return tenant_access_token


def main(bitable_url, connect_info, sql, fields_type, foreign_key, data_type=1, page_size=500):
    # 基本配置
    app_token, table_id, view_id = get_table_params(bitable_url)
    app_id = 'your_app_id'
    app_secret = 'your_app_secret'
    access_token = get_tenant_access_token(app_id, app_secret)    
    
    # 读取数据库数据并格式化
    df_db = get_datas(sql, connect_info, fields_type)
    df_from = format_db_datas(df_db, foreign_key)
    # 读取多维表数据并格式化
    feishu_datas = get_all_bitable_datas(access_token, app_token, table_id, view_id, page_size=page_size)
    df_join = format_feishu_datas(feishu_datas, foreign_key, data_type)
    # 数据分类
    df_cre, df_ups, df_del = classify_datas(df_from, df_join, on=foreign_key)

    ls_cre = cut_datas(df_cre,mode='dict',page_size=page_size)
    ls_ups = cut_datas(df_ups,mode='dict',page_size=page_size)
    ls_del = cut_datas(df_del,mode='list',page_size=page_size)
    # body 已经做了格式化
    if ls_cre:[insert_records(access_token,app_token,table_id,cre_body) for cre_body in ls_cre]
    if ls_ups:[update_records(access_token,app_token,table_id,ups_body) for ups_body in ls_ups]
    if ls_del:[delete_records(access_token,app_token,table_id,del_body) for del_body in ls_del]
    print('更新完成。关联函数:main')

if __name__ == '__main__':
    bitable_url = 'https://vl933ry4wy.feishu.cn/base/EPYFbi4ThahvLUsJ9nUchaXQnLh?table=tblnE4CHrysoKYNO&view=vewH9qJSRL'
    connect_info = 'mysql+pymysql://{}:{}@{}:{}/{}?charset=utf8'\
                .format("root", "123456", "127.0.0.1", "3306","my_datas")
    sql = '''
    select uo.user_id                               as "用户ID"
      ,u.nickname                                   as "昵称"
      ,(case u.sex when 0 then '女' when 1 then '男' else '未知' end) as "性别"
      ,u.mobile                                     as "手机号"
      ,u.city                                       as "城市"
      ,uo.id                                        as "订单号"
      ,uo.paid_time*1000                            as "下单时间"
      ,uo.amount/100                                as "下单金额"
    from my_datas.user_orders uo
    join my_datas.users u on u.id=uo.user_id
    where uo.production_id=10;
    '''
    fields_type = {"用户ID": int, "昵称": str, "性别": str, "手机号": str, "城市": str, "订单号": int, "下单时间": 'int64', "下单金额": float}
    foreign_key ='订单号'
    data_type = 2
    page_size = 2
    main(bitable_url, connect_info, sql, fields_type, foreign_key, data_type, page_size)

为了测试上面代码功能,在数据库中,新增两条记录,参考如下:

insert into user_orders(user_id, production_id, paid_time,amount) values
(4,10,1704398000,99900),
(3,11,1704695836,39900);

在多维表上也新增 2 条记录,一条空记录,一条在订单号调试一个数字。
image.png

执行完整代码,结果如下:
image.png

多维表结果如下:
image.png

至此,完成了新增数据的同步。

四、小结

本文介绍了针对需要持续从 MySQL 数据库读取数据并更新到多维表的工作流,介绍了几种可供参考的同步策略:增量同步和全量同步。
增量同步包含定时更新和补数据两种方式,全量同步包含先删除再全量新增和件数据分类,更新旧数据并插入新数据和删除多余数据。
可根据实际的业务场景进行选择同步策略。

补充一点,如果多维表多出的数据不做删除动作,而是新增一个列(比如:异常备注)进行备注,直接走更新逻辑,然后“fields”中只需要给对应列的值标记接口,比如说:{“fields”:{“异常备注”:“多维表数据异常”}}。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Xin学数据

为你点亮一盏灯,愿你前进无阻。

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值