Rowy数据库如何与Excel无缝同步从手动导出到API自动对接的全流程实战避坑指南新手也能轻松上手避免数据丢失和格式错乱
嘿,朋友!今天咱来聊聊一个让无数人抓狂但又必须面对的问题——Rowy数据库和Excel之间的同步。你是不是也经历过这样的痛苦:早上花半小时整理数据,导出Excel的时候格式全乱,公式全报错,或者到手的Excel里有些字段直接消失,第二天去汇报的时候尴尬得想找个地缝钻进去……
别慌,这篇文章就是来救你的。我会从最基础的手动导出讲起,一步一步带你走到API自动同步的高级玩法,顺便把那些让人血泪的坑都给你标清楚。咱们不玩虚的,直接上干货。
先搞清楚:Rowy到底是什么东西
在你决定要不要投入时间学习之前,咱得先确认一下Rowy是不是你的菜。
Rowy是一个基于Google Sheets的开源数据库管理工具,它把你的Google Sheet变成一个真正的数据库。你可以用它来:
- 创建和管理结构化数据表
- 设置字段类型和验证规则
- 通过UI界面直接操作数据,不需要写代码
- 通过API进行自动化操作
如果你现在每天花大量时间在Google Sheets里手动整理数据,或者用Excel做各种复杂的数据处理然后还要导出来给别人用,那Rowy基本上就是为你量身定做的。
但这里有个关键点:Rowy本身并不直接存储Excel文件。它存储的是你的结构化数据,而Excel只是这些数据的一个”展示窗口”。所以我们要做的同步,本质上是让两个不同的”语言”能够互相听懂对方的话。
第一步:手动导出 —— 最基础但也最容易出问题的环节
很多人第一次尝试Rowy和Excel同步的时候,直接就是在Rowy里导出CSV或者Excel文件,然后手动打开看看。这个方法听起来很简单,但实际上充满了各种小坑。
手动导出的标准流程
1. 在Rowy中导出数据
进入你的Rowy表,点击右上角的”导出”按钮。你会看到几个选项:
- CSV:纯文本格式,通用性强但格式信息会丢失
- Excel (.xlsx):保留格式和公式,但某些特殊字段可能处理不当
- JSON:结构化数据,适合程序处理
对于新手,我建议先选CSV。为什么?因为CSV最透明,你可以直接用文本编辑器打开看里面的内容,这样能最直观地发现数据问题。
2. 打开并检查导出的文件
这一步很多人直接跳过,但我强烈建议你花两分钟仔细看一下。重点检查:
- 字段数量对不对?有没有字段消失?
- 日期格式是否正确?(很多坑都出在这里)
- 数字有没有被错误地转换为文本?
- 空值是怎么表示的?
手动导出的常见坑
坑一:日期格式灾难
你Rowy里存的是标准的日期格式,比如 2024-03-15,导到Excel里可能就变成了 15-Mar-24 或者干脆变成一串数字 45362。
这不是Excel的错,是Rowy导出时对日期字段的处理方式问题。CSV格式本身不区分日期和文本,所以日期会被当作普通字符串导出。
解决方法:在Rowy导出后,打开Excel,选中日期列,使用”数据” → “分列”功能,将列数据转换为日期格式。选中日期列后,点”分列”,在第三步选择”日期”,并选择你数据实际的格式(YMD或MDY)。
坑二:长数字变成科学计数法
如果你的数据里包含身份证号、订单号这类长数字,Excel会自动把它们显示成科学计数法,比如 1.23E+18。
解决方法:导出CSV后,不要直接双击打开。先用Excel打开,然后使用”数据” → “从文本/CSV导入”功能,在导入向导中把包含长数字的列明确设置为”文本”格式。
坑三:特殊字符导致列错位
数据里如果有逗号、换行符或者引号,CSV解析的时候就会出问题。比如你的数据里有一行写了 姓名, 备注,这个逗号会让Excel以为这是两个字段。
解决方法:在Rowy导出时检查”引用文本”选项是否勾选。如果已经导出了有问题的文件,建议使用Power Query重新导入,在导入设置中勾选”使用引号内的逗号和换行符”。
第二步:理解Rowy的数据模型 —— 为自动同步打基础
手动导出能解决问题,但每次都要手动操作太麻烦了。要走向自动化,你得先理解Rowy是怎么存储和组织数据的。
Rowy的核心概念
Rowy本质上是Google Sheets的增强版,每个表都是一个Sheet。但它有一些自己特有的概念:
字段类型(Field Types):Rowy支持文本、数字、日期、布尔值、下拉列表、关联记录等多种字段类型。不同的类型在导出到Excel时处理方式不同。
记录ID(Record ID):每条数据在Rowy中都有一个唯一的ID,这在你做同步和更新时非常重要。
API接口:Rowy提供RESTful API,你可以用它来读取、创建、更新和删除数据。
理解字段类型对同步的影响
不同类型的字段在Excel中的表现差异很大:
| 字段类型 | Excel中的表现 | 注意事项 |
|---|---|---|
| 文本 | 正常显示 | 长文本可能被截断 |
| 数字 | 正常显示 | 长数字需要用文本格式 |
| 日期 | 可能格式错乱 | 需要手动设置日期格式 |
| 布尔值 | TRUE/FALSE | Excel可能显示为1/0 |
| 下拉列表 | 正常显示选项值 | 选项值本身无问题 |
| 关联记录 | 显示关联ID或文本 | 取决于Rowy的配置 |
| 附件/图片 | 无法直接同步 | 需要特殊处理 |
理解这些差异后,你就能在设计表结构的时候提前规避很多问题。比如,如果你知道某些字段以后要同步到Excel,那就避免使用那些在Excel中难以处理的字段类型,或者提前做好转换准备。
第三步:使用Google Apps Script实现半自动同步
如果你不想直接搞API,Google Apps Script是一个很好的中间方案。它让你能在Google Sheets的环境里编写脚本,直接操作Rowy的数据,然后导出到Excel格式。
为什么要用Apps Script?
- 不需要额外的服务器或工具
- 直接运行在Google生态内
- 可以设置定时触发,实现半自动化
- 代码逻辑清晰,调试方便
实战:写一个自动导出Excel的脚本
打开你的Rowy表,点击”扩展功能” → “Apps Script”,然后粘贴以下代码:
/**
* Rowy数据导出为Excel文件
* 支持自定义字段映射和格式处理
*/
// ==================== 配置区 ====================
const CONFIG = {
// Rowy表ID(从URL中获取,格式如 https://rowy.io/tables/[tableId]/records)
TABLE_ID: 'your_table_id_here',
// 导出的文件名称
EXPORT_FILENAME: 'Rowy数据导出_' + formatDate(new Date()),
// 需要导出的字段(空数组表示导出所有字段)
FIELDS: [],
// 过滤条件(可选,格式同Rowy API的filter参数)
FILTERS: null,
// 排序方式(可选)
SORT: null,
// 最大导出记录数
MAX_RECORDS: 10000,
// 导出格式:'xlsx' 或 'csv'
EXPORT_FORMAT: 'xlsx',
// 特殊字段处理规则
FIELD_HANDLERS: {
'date': 'date', // 日期字段:转为标准日期格式
'datetime': 'datetime', // 日期时间字段
'id': 'string', // ID字段:转为字符串避免科学计数法
'phone': 'string', // 电话字段:转为字符串
'amount': 'number' // 金额字段:保留两位小数
}
};
// ==================== 主函数 ====================
function exportRowyToExcel() {
Logger.log('开始导出...');
try {
// 1. 获取数据
const records = fetchRowyData();
Logger.log('获取到 ' + records.length + ' 条记录');
if (records.length === 0) {
throw new Error('没有数据可导出');
}
// 2. 转换为表格数据
const sheetData = convertToSheetData(records);
// 3. 创建新的工作簿并写入数据
const wb = createWorkbook(sheetData);
// 4. 下载文件
downloadFile(wb);
Logger.log('导出完成!');
} catch (error) {
Logger.log('导出失败: ' + error.message);
SpreadsheetApp.getUi()
.alert('导出失败: ' + error.message);
}
}
// ==================== 数据获取 ====================
function fetchRowyData() {
const apiKey = PropertiesService.getScriptProperties().getProperty('ROWY_API_KEY');
if (!apiKey) {
throw new Error('请先在Script Properties中配置 ROWY_API_KEY');
}
const baseUrl = `https://api.rowy.io/v1/tables/${CONFIG.TABLE_ID}/records`;
let url = baseUrl + '?limit=' + CONFIG.MAX_RECORDS;
if (CONFIG.FILTERS) {
url += '&filter=' + encodeURIComponent(JSON.stringify(CONFIG.FILTERS));
}
if (CONFIG.SORT) {
url += '&sort=' + encodeURIComponent(JSON.stringify(CONFIG.SORT));
}
const response = UrlFetchApp.fetch(url, {
headers: {
'Authorization': 'Bearer ' + apiKey,
'Content-Type': 'application/json'
},
muteHttpExceptions: true
});
const result = JSON.parse(response.getContentText());
if (response.getResponseCode() !== 200) {
throw new Error('API请求失败: ' + JSON.stringify(result));
}
return result.data || [];
}
// ==================== 数据转换 ====================
function convertToSheetData(records) {
if (records.length === 0) {
return [['暂无数据']];
}
// 获取所有字段名作为表头
const headers = Object.keys(records[0]);
const allHeaders = [...new Set(headers)]; // 去重
// 添加特殊列(如Rowy的记录ID)
if (!allHeaders.includes('rowy_id')) {
allHeaders.push('rowy_id');
}
// 构建数据行
const data = [allHeaders];
for (const record of records) {
const row = [];
for (const header of allHeaders) {
if (header === 'rowy_id') {
// Rowy记录ID转为字符串
row.push(String(record.id || ''));
} else if (record[header] !== undefined && record[header] !== null) {
// 根据字段类型处理
const value = processFieldValue(header, record[header]);
row.push(value);
} else {
row.push('');
}
}
data.push(row);
}
return data;
}
// ==================== 字段值处理 ====================
function processFieldValue(fieldName, value) {
// 处理日期类型
if (value instanceof Date) {
return formatDateTime(value);
}
// 处理时间戳
if (typeof value === 'number' && value > 1000000000000) {
return formatDateTime(new Date(value));
}
// 处理对象类型(如关联记录)
if (value !== null && typeof value === 'object') {
if (value.id) {
return String(value.id); // 转为字符串避免科学计数法
}
return JSON.stringify(value);
}
// 处理布尔值
if (typeof value === 'boolean') {
return value ? '是' : '否';
}
// 处理数组
if (Array.isArray(value)) {
return value.join('、');
}
// 默认转为字符串
return String(value !== null && value !== undefined ? value : '');
}
// ==================== 格式化函数 ====================
function formatDateTime(date) {
if (!(date instanceof Date) || isNaN(date.getTime())) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
}
// ==================== 创建工作簿 ====================
function createWorkbook(sheetData) {
// 由于Google Apps Script不能直接创建xlsx文件,
// 我们创建一个Google Sheet然后导出为xlsx
const tempSheet = SpreadsheetApp.create(CONFIG.EXPORT_FILENAME + '_temp');
const sheet = tempSheet.getSheets()[0];
// 写入数据
const range = sheet.getRange(1, 1, sheetData.length, sheetData[0].length);
range.setValues(sheetData);
// 格式化表头
const headerRange = sheet.getRange(1, 1, 1, sheetData[0].length);
headerRange.setFontWeight('bold');
headerRange.setBackground('#4472C4');
headerRange.setFontColor('#FFFFFF');
// 自动调整列宽
for (let i = 0; i < sheetData[0].length; i++) {
sheet.autoResizeColumn(i + 1);
}
// 冻结首行
sheet.setFrozenRows(1);
return tempSheet;
}
// ==================== 下载文件 ====================
function downloadFile(ss) {
const blob = ss.getBlob()
.getAs('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
.setName(CONFIG.EXPORT_FILENAME + '.xlsx');
// 生成下载链接
const url = 'https://script.google.com/macros/d/' +
PropertiesService.getScriptProperties().getProperty('DEPLOY_ID') +
'/exec?action=download';
// 创建菜单
const ui = SpreadsheetApp.getUi();
ui.createMenu('Rowy导出')
.addItem('导出为Excel', 'exportRowyToExcel')
.addToUi();
// 删除临时文件(实际项目中需要更完善的清理机制)
// DriveApp.getFileById(ss.getId()).setTrashed(true);
}
// ==================== 测试函数 ====================
function testExport() {
Logger.log('测试导出...');
const apiKey = PropertiesService.getScriptProperties().getProperty('ROWY_API_KEY');
if (!apiKey) {
Logger.log('请先配置API Key');
return;
}
const response = UrlFetchApp.fetch(
`https://api.rowy.io/v1/tables/${CONFIG.TABLE_ID}/records?limit=5`,
{
headers: {
'Authorization': 'Bearer ' + apiKey,
'Content-Type': 'application/json'
},
muteHttpExceptions: true
}
);
const result = JSON.parse(response.getContentText());
Logger.log('返回数据: ' + JSON.stringify(result, null, 2));
}
脚本配置说明
这段代码看起来很长,但核心逻辑其实很简单:
- 配置区:你需要修改的主要是
TABLE_ID和API_KEY - 数据获取:通过Rowy API拉取数据
- 数据转换:把API返回的JSON格式转换成Excel能识别的表格格式
- 格式化:特别处理日期、数字等容易出问题的字段
配置步骤
1. 获取API Key
登录Rowy控制台,进入你的项目设置,找到API Key。把它复制好。
2. 配置Script Properties
在Apps Script编辑器中,点击左侧的”项目设置”(齿轮图标),找到”脚本属性”,添加一个键值对:
- Key:
ROWY_API_KEY - Value: 你的API Key
3. 修改配置区
把代码中的 your_table_id_here 替换成你实际的表ID。表ID可以从Rowy的URL中找到,格式类似 https://rowy.io/tables/abc123/records,其中 abc123 就是表ID。
4. 运行并测试
点击”运行” → “exportRowyToExcel”,首次运行会要求你授权,按提示操作即可。
第四步:API自动同步 —— 真正的自动化方案
半自动方案虽然比手动操作方便,但还不能完全解放双手。要实现真正的自动化同步,我们需要通过API直接对接。
Rowy API基础
Rowy提供了完整的RESTful API,主要接口包括:
| 方法 | 路径 | 功能 |
|---|---|---|
| GET | /v1/tables/{tableId}/records |
获取记录列表 |
| GET | /v1/tables/{tableId}/records/{recordId} |
获取单条记录 |
| POST | /v1/tables/{tableId}/records |
创建记录 |
| PUT | /v1/tables/{tableId}/records/{recordId} |
更新记录 |
| DELETE | /v1/tables/{tableId}/records/{recordId} |
删除记录 |
所有API请求都需要在Header中携带 Authorization: Bearer {apiKey}。
Python实现完整同步系统
下面是一个比较完整的Python方案,实现了Rowy到Excel的双向同步:
"""
Rowy数据库与Excel自动同步系统
支持双向同步、数据校验、错误恢复
"""
import os
import json
import time
import requests
from datetime import datetime
from typing import List, Dict, Any, Optional
from dataclasses import dataclass, asdict
from pathlib import Path
import pandas as pd
from openpyxl import load_workbook, Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
import logging
# ==================== 配置区 ====================
@dataclass
class SyncConfig:
"""同步配置"""
# Rowy配置
rowy_api_key: str = os.getenv('ROWY_API_KEY', '')
rowy_base_url: str = 'https://api.rowy.io/v1'
table_id: str = ''
# Excel配置
excel_file_path: str = './rowy_sync.xlsx'
sheet_name: str = '数据同步'
# 同步策略
auto_sync: bool = True
sync_interval_minutes: int = 5 # 自动同步间隔(分钟)
max_records_per_request: int = 500
# 字段映射(Rowy字段名 -> Excel列名)
field_mapping: Dict[str, str] = None
# 需要特殊处理的字段
special_fields: Dict[str, Dict] = None
# 日志配置
log_level: str = 'INFO'
log_file: str = './sync.log'
def __post_init__(self):
if self.field_mapping is None:
self.field_mapping = {}
if self.special_fields is None:
self.special_fields = {}
# 默认配置示例
DEFAULT_CONFIG = SyncConfig(
table_id='your_table_id',
field_mapping={
'created_at': '创建时间',
'updated_at': '更新时间',
'status': '状态',
'amount': '金额',
'phone': '联系电话'
},
special_fields={
'created_at': {'type': 'datetime', 'format': '%Y-%m-%d %H:%M:%S'},
'amount': {'type': 'number', 'decimal_places': 2},
'phone': {'type': 'string', 'mask': '00000000000'} # 确保11位
}
)
# ==================== 日志配置 ====================
def setup_logging(config: SyncConfig):
"""设置日志"""
logging.basicConfig(
level=getattr(logging, config.log_level.upper()),
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler(config.log_file, encoding='utf-8'),
logging.StreamHandler()
]
)
return logging.getLogger(__name__)
logger = setup_logging(DEFAULT_CONFIG)
# ==================== Rowy API客户端 ====================
class RowyClient:
"""Rowy API客户端"""
def __init__(self, config: SyncConfig):
self.config = config
self.base_url = config.rowy_base_url
self.headers = {
'Authorization': f'Bearer {config.rowy_api_key}',
'Content-Type': 'application/json'
}
self.session = requests.Session()
self.session.headers.update(self.headers)
def _request(self, method: str, endpoint: str, **kwargs) -> Dict:
"""发送HTTP请求,带重试机制"""
url = f'{self.base_url}/{endpoint}'
max_retries = 3
retry_delay = 1
for attempt in range(max_retries):
try:
response = self.session.request(method, url, **kwargs)
response.raise_for_status()
return response.json()
except requests.exceptions.RequestException as e:
if attempt < max_retries - 1:
wait_time = retry_delay * (2 ** attempt)
logger.warning(f'请求失败,{wait_time}秒后重试 ({attempt + 1}/{max_retries}): {e}')
time.sleep(wait_time)
else:
logger.error(f'请求最终失败: {e}')
raise
def get_records(self,
filter: Optional[Dict] = None,
sort: Optional[List[Dict]] = None,
limit: int = None,
offset: int = 0) -> List[Dict]:
"""获取记录列表"""
params = {
'offset': offset,
'limit': limit or self.config.max_records_per_request
}
if filter:
params['filter'] = json.dumps(filter)
if sort:
params['sort'] = json.dumps(sort)
result = self._request('GET', f'tables/{self.config.table_id}/records', params=params)
return result.get('data', [])
def get_total_count(self) -> int:
"""获取总记录数"""
result = self._request('GET', f'tables/{self.config.table_id}/records', params={'limit': 1})
return result.get('total', 0)
def get_record(self, record_id: str) -> Dict:
"""获取单条记录"""
return self._request('GET', f'tables/{self.config.table_id}/records/{record_id}')
def create_record(self, data: Dict) -> Dict:
"""创建记录"""
return self._request('POST', f'tables/{self.config.table_id}/records', json=data)
def update_record(self, record_id: str, data: Dict) -> Dict:
"""更新记录"""
return self._request('PUT', f'tables/{self.config.table_id}/records/{record_id}', json=data)
def delete_record(self, record_id: str) -> bool:
"""删除记录"""
self._request('DELETE', f'tables/{self.config.table_id}/records/{record_id}')
return True
# ==================== 数据转换器 ====================
class DataConverter:
"""Rowy数据与Excel数据之间的转换器"""
@staticmethod
def rowy_to_excel(records: List[Dict],
field_mapping: Dict[str, str],
special_fields: Dict[str, Dict]) -> pd.DataFrame:
"""将Rowy数据转换为Excel格式的数据"""
if not records:
return pd.DataFrame()
# 确定Excel列名
excel_columns = list(field_mapping.values()) if field_mapping else list(records[0].keys())
# 转换数据
excel_data = []
for record in records:
row = {}
for rowy_field, excel_field in field_mapping.items():
value = record.get(rowy_field)
row[excel_field] = DataConverter._convert_value(value, special_fields.get(rowy_field))
# 处理没有映射的字段
for field, value in record.items():
if field not in field_mapping:
excel_field = field
if excel_field in excel_columns:
excel_field = f'{field}_1'
row[excel_field] = DataConverter._convert_value(value, special_fields.get(field))
excel_data.append(row)
return pd.DataFrame(excel_data, columns=excel_columns)
@staticmethod
def _convert_value(value: Any, field_config: Optional[Dict] = None) -> Any:
"""转换单个字段值"""
if value is None:
return ''
# 处理日期时间
if field_config and field_config.get('type') == 'datetime':
if isinstance(value, str):
try:
dt = datetime.fromisoformat(value.replace('Z', '+00:00'))
fmt = field_config.get('format', '%Y-%m-%d %H:%M:%S')
return dt.strftime(fmt)
except:
return value
elif isinstance(value, (int, float)):
# 时间戳
return datetime.fromtimestamp(value / 1000).strftime(
field_config.get('format', '%Y-%m-%d %H:%M:%S')
)
# 处理数字
if field_config and field_config.get('type') == 'number':
try:
num = float(value)
decimal_places = field_config.get('decimal_places', 2)
return round(num, decimal_places)
except (ValueError, TypeError):
return value
# 处理字符串(确保长数字不被Excel当作科学计数法)
if field_config and field_config.get('type') == 'string':
return str(value)
# 处理布尔值
if isinstance(value, bool):
return '是' if value else '否'
# 处理数组
if isinstance(value, list):
return '、'.join(str(v) for v in value)
# 处理对象
if isinstance(value, dict):
return json.dumps(value, ensure_ascii=False)
return value
@staticmethod
def excel_to_rowy(df: pd.DataFrame,
field_mapping: Dict[str, str],
special_fields: Dict[str, Dict]) -> List[Dict]:
"""将Excel数据转换为Rowy格式"""
# 反转映射:Excel列名 -> Rowy字段名
reverse_mapping = {v: k for k, v in field_mapping.items()}
records = []
for _, row in df.iterrows():
record = {}
for excel_col, rowy_field in reverse_mapping.items():
if excel_col in row:
value = row[excel_col]
if pd.isna(value):
value = None
record[rowy_field] = DataConverter._excel_to_rowy_value(value, special_fields.get(rowy_field))
records.append(record)
return records
@staticmethod
def _excel_to_rowy_value(value: Any, field_config: Optional[Dict] = None) -> Any:
"""将Excel值转换为Rowy格式"""
if value is None or (isinstance(value, float) and pd.isna(value)):
return None
# 处理日期
if field_config and field_config.get('type') == 'datetime':
if isinstance(value, str):
try:
fmt = field_config.get('format', '%Y-%m-%d %H:%M:%S')
dt = datetime.strptime(value, fmt)
return dt.isoformat()
except ValueError:
return value
elif hasattr(value, 'isoformat'):
return value.isoformat()
# 处理数字
if field_config and field_config.get('type') == 'number':
try:
return float(value)
except (ValueError, TypeError):
return value
# 处理长数字字符串(如手机号)
if field_config and field_config.get('type') == 'string':
return str(value)
return value
# ==================== Excel格式化工具 ====================
class ExcelFormatter:
"""Excel格式化工具"""
# 常用样式
HEADER_FONT = Font(bold=True, color='FFFFFF', size=11)
HEADER_FILL = PatternFill(start_color='4472C4', end_color='4472C4', fill_type='solid')
HEADER_ALIGNMENT = Alignment(horizontal='center', vertical='center')
BORDER = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
@classmethod
def format_workbook(cls, wb: Workbook, df: pd.DataFrame) -> None:
"""格式化整个工作簿"""
sheet = wb.active
sheet.title = '数据同步'
# 写入表头
headers = df.columns.tolist()
for col_idx, header in enumerate(headers, 1):
cell = sheet.cell(row=1, column=col_idx, value=header)
cell.font = cls.HEADER_FONT
cell.fill = cls.HEADER_FILL
cell.alignment = cls.HEADER_ALIGNMENT
cell.border = cls.BORDER
# 写入数据
for row_idx, row in enumerate(df.values, 2):
for col_idx, value in enumerate(row, 1):
cell = sheet.cell(row=row_idx, column=col_idx, value=value)
cell.border = cls.BORDER
# 数值右对齐,文本左对齐
if isinstance(value, (int, float)):
cell.alignment = Alignment(horizontal='right')
# 冻结首行
sheet.freeze_panes = 'A2'
# 自动调整列宽(限制最大宽度)
for col_idx, header in enumerate(headers, 1):
max_length = len(str(header))
for row in df.iloc[:, col_idx - 1]:
if pd.notna(row):
max_length = max(max_length, len(str(row)))
adjusted_width = min(max_length + 2, 50)
sheet.column_dimensions[get_column_letter(col_idx)].width = adjusted_width
# 为包含长数字的列设置文本格式
for col_idx, header in enumerate(headers, 1):
first_cell = sheet.cell(row=2, column=col_idx)
if first_cell and isinstance(first_cell.value, str) and first_cell.value.isdigit() and len(first_cell.value) >= 10:
sheet.column_dimensions[get_column_letter(col_idx)].number_format = '@'
# ==================== 同步引擎 ====================
class SyncEngine:
"""同步引擎"""
def __init__(self, config: SyncConfig = None):
self.config = config or DEFAULT_CONFIG
self.rowy_client = RowyClient(self.config)
self.converter = DataConverter()
self.excel_formatter = ExcelFormatter()
# 同步状态
self.last_sync_time = None
self.sync_stats = {
'total_records': 0,
'new_records': 0,
'updated_records': 0,
'deleted_records': 0,
'errors': []
}
logger.info(f'同步引擎初始化完成,配置表: {self.config.table_id}')
def sync_rowy_to_excel(self, force_full_sync: bool = False) -> Dict:
"""
Rowy同步到Excel
force_full_sync: 是否强制全量同步
"""
logger.info('开始 Rowy -> Excel 同步')
try:
# 1. 获取Rowy数据
rowy_records = self.rowy_client.get_records()
logger.info(f'从Rowy获取到 {len(rowy_records)} 条记录')
if not rowy_records:
logger.warning('Rowy中没有数据')
return self.sync_stats
# 2. 转换为Excel格式
df = self.converter.rowy_to_excel(
rowy_records,
self.config.field_mapping,
self.config.special_fields
)
if df.empty:
logger.warning('转换后的数据为空')
return self.sync_stats
# 3. 保存到Excel
self._save_to_excel(df)
# 4. 更新统计
self.sync_stats['total_records'] = len(rowy_records)
self.sync_stats['last_sync_time'] = datetime.now().isoformat()
logger.info(f'Rowy -> Excel 同步完成,共 {len(rowy_records)} 条记录')
except Exception as e:
logger.error(f'同步失败: {e}', exc_info=True)
self.sync_stats['errors'].append(str(e))
return self.sync_stats
def sync_excel_to_rowy(self, excel_file: Optional[str] = None) -> Dict:
"""
Excel同步到Rowy
"""
logger.info('开始 Excel -> Rowy 同步')
try:
file_path = excel_file or self.config.excel_file_path
# 1. 读取Excel
df = pd.read_excel(file_path, sheet_name=self.config.sheet_name)
logger.info(f'从Excel读取到 {len(df)} 条记录')
if df.empty:
logger.warning('Excel中没有数据')
return self.sync_stats
# 2. 转换为Rowy格式
rowy_records = self.converter.excel_to_rowy(
df,
self.config.field_mapping,
self.config.special_fields
)
# 3. 同步到Rowy(这里简化处理,实际项目需要增量同步逻辑)
for record in rowy_records:
try:
self.rowy_client.create_record(record)
self.sync_stats['new_records'] += 1
except Exception as e:
logger.error(f'创建记录失败: {e}')
self.sync_stats['errors'].append(f'创建失败: {str(e)}')
self.sync_stats['last_sync_time'] = datetime.now().isoformat()
logger.info(f'Excel -> Rowy 同步完成')
except Exception as e:
logger.error(f'同步失败: {e}', exc_info=True)
self.sync_stats['errors'].append(str(e))
return self.sync_stats
def _save_to_excel(self, df: pd.DataFrame) -> None:
"""保存数据到Excel文件"""
# 如果文件已存在,先加载
if os.path.exists(self.config.excel_file_path):
wb = load_workbook(self.config.excel_file_path)
if self.config.sheet_name in wb.sheetnames:
del wb[self.config.sheet_name]
ws = wb.create_sheet(self.config.sheet_name)
else:
wb = Workbook()
if 'Sheet' in wb.sheetnames:
del wb['Sheet']
ws = wb.create_sheet(self.config.sheet_name)
# 写入数据
for r_idx, row in enumerate(df.values, 1):
for c_idx, value in enumerate(row, 1):
ws.cell(row=r_idx, column=c_idx, value=value)
# 格式化
self.excel_formatter.format_workbook(wb, df)
# 保存
wb.save(self.config.excel_file_path)
logger.info(f'数据已保存到 {self.config.excel_file_path}')
def get_sync_status(self) -> Dict:
"""获取同步状态"""
return {
'last_sync_time': self.sync_stats.get('last_sync_time'),
'total_records': self.sync_stats.get('total_records', 0),
'new_records': self.sync_stats.get('new_records', 0),
'updated_records': self.sync_stats.get('updated_records', 0),
'errors': self.sync_stats.get('errors', []),
'excel_file': self.config.excel_file_path
}
# ==================== 定时同步任务 ====================
import threading
import schedule
def run_scheduled_sync():
"""定时执行同步"""
engine = SyncEngine()
engine.sync_rowy_to_excel()
status = engine.get_sync_status()
logger.info(f'定时同步完成: {json.dumps(status, ensure_ascii=False)}')
def start_scheduler(config: SyncConfig):
"""启动定时任务"""
interval = config.sync_interval_minutes
schedule.every(interval).minutes.do(run_scheduled_sync)
logger.info(f'定时同步已启动,每 {interval} 分钟执行一次')
while True:
schedule.run_pending()
time.sleep(1)
# ==================== 主程序入口 ====================
def main():
"""主程序"""
print('=' * 60)
print('Rowy数据库与Excel自动同步系统')
print('=' * 60)
# 初始化配置
config = SyncConfig(
table_id='your_table_id_here',
rowy_api_key=os.getenv('ROWY_API_KEY')
)
# 初始化引擎
engine = SyncEngine(config)
# 执行同步
print('\n执行 Rowy -> Excel 同步...')
stats = engine.sync_rowy_to_excel()
print(f'\n同步结果:')
print(f' 总记录数: {stats.get("total_records", 0)}')
print(f' 最后同步时间: {stats.get("last_sync_time")}')
if stats.get('errors'):
print(f' 错误数: {len(stats["errors"])}')
for error in stats['errors'][:5]: # 只显示前5个错误
print(f' - {error}')
# 显示文件路径
print(f'\nExcel文件已保存到: {config.excel_file_path}')
# 询问是否启动定时同步
choice = input('\n是否启动定时同步?(y/n): ').strip().lower()
if choice == 'y':
print('启动定时同步...')
try:
start_scheduler(config)
except KeyboardInterrupt:
print('\n定时同步已停止')
if __name__ == '__main__':
main()
代码结构说明
这段代码比我之前写的Apps Script要完整得多,但核心思路是一样的:
- 配置区:所有需要自定义的参数都在这里
- RowyClient:封装了所有API调用,自带重试机制
- DataConverter:负责数据格式转换,处理日期、数字、长字符串等特殊字段
- ExcelFormatter:负责Excel的格式美化
- SyncEngine:核心同步逻辑
- 定时任务:支持自动定时同步
部署步骤
1. 安装依赖
pip install requests pandas openpyxl schedule
2. 设置环境变量
在项目根目录创建 .env 文件:
ROWY_API_KEY=your_actual_api_key_here
ROWY_TABLE_ID=your_table_id_here
或者直接使用命令行导出:
export ROWY_API_KEY='your_actual_api_key_here'
3. 运行程序
python rowy_sync.py
第五步:那些让人踩坑的细节 —— 避坑实战指南
讲完了基础方案,现在咱们来聊聊那些实际项目中经常遇到的问题。这些问题如果不提前知道,等你遇到了绝对会头疼。
坑一:同步失败后的数据一致性问题
问题场景:你设置了定时同步,每隔5分钟同步一次。某天突然同步失败了,但Rowy中的数据已经更新了几次。等你发现问题的时候,Excel里的数据已经落后很多了。
解决方案:实现增量同步机制。
Rowy的API支持返回updated_at或created_at时间戳,你可以利用这个来实现增量同步:
def sync_incremental(self, last_sync_time: str = None) -> Dict:
"""增量同步:只同步最近修改的数据"""
# 如果没有上次同步时间,使用配置中的值
if not last_sync_time:
last_sync_time = self.sync_stats.get('last_sync_time')
if not last_sync_time:
# 首次同步,全量同步
logger.info('首次同步,执行全量同步')
return self.sync_rowy_to_excel(force_full_sync=True)
# 增量同步:只获取最后同步时间之后的数据
filter = {
'field': 'updated_at',
'operator': '>',
'value': last_sync_time
}
updated_records = self.rowy_client.get_records(filter=filter)
logger.info(f'增量同步:发现 {len(updated_records)} 条更新记录')
if not updated_records:
return {'total_records': 0, 'updated': 0}
# 转换为DataFrame
df = self.converter.rowy_to_excel(
updated_records,
self.config.field_mapping,
self.config.special_fields
)
# 更新Excel中的现有记录
self._update_excel_with_incremental(df)
# 更新同步时间
if updated_records:
latest_time = max(r.get('updated_at', '') for r in updated_records)
self.sync_stats['last_sync_time'] = latest_time
self.sync_stats['updated_records'] = len(updated_records)
return {
'total_records': len(updated_records),
'last_sync_time': self.sync_stats['last_sync_time']
}
这个方案的优点是:
- 减少了API调用次数
- 同步速度更快
- 数据一致性更好
缺点是:
- 需要Rowy中的记录有
updated_at字段 - 如果错过了某次同步,可能会有数据遗漏(需要定期全量同步作为补充)
坑二:Excel公式和格式被覆盖
问题场景:你的Excel里有一些公式,比如=SUM(A2:A100)或者=VLOOKUP(...)。每次同步后,这些公式都消失了,只剩下原始数据。
解决方案:在导出时保留公式区域。
def _save_to_excel_with_formulas(self, df: pd.DataFrame) -> None:
"""保存数据到Excel,保留公式区域"""
file_path = self.config.excel_file_path
# 如果文件已存在,加载现有格式
if os.path.exists(file_path):
wb = load_workbook(file_path)
# 检查是否有公式区域
if self.config.sheet_name in wb.sheetnames:
existing_sheet = wb[self.config.sheet_name]
# 备份公式区域(假设公式在数据区域之后)
data_end_row = existing_sheet.max_row
formula_start_row = data_end_row + 2
# 记录公式区域的范围
formula_range = {
'start_row': formula_start_row,
'end_row': existing_sheet.max_row,
'formulas': {}
}
for row in range(formula_start_row, existing_sheet.max_row + 1):
for col in range(1, existing_sheet.max_column + 1):
cell = existing_sheet.cell(row=row, column=col)
if cell.value and str(cell.value).startswith('='):
formula_range['formulas'][(row, col)] = cell.value
# 清除旧数据
existing_sheet.delete_rows(2, data_end_row - 1)
ws = existing_sheet
else:
ws = wb.create_sheet(self.config.sheet_name)
else:
wb = Workbook()
if 'Sheet' in wb.sheetnames:
del wb['Sheet']
ws = wb.create_sheet(self.config.sheet_name)
# 写入新数据
for r_idx, row in enumerate(df.values, 2): # 从第2行开始(第1行是表头)
for c_idx, value in enumerate(row, 1):
ws.cell(row=r_idx, column=c_idx, value=value)
# 恢复公式区域
if 'formula_range' in locals() and formula_range['formulas']:
for (row, col), formula in formula_range['formulas'].items():
ws.cell(row=row, column=col, value=formula)
# 保存
wb.save(file_path)
logger.info(f'数据已保存到 {file_path}(保留公式)')
这样,你的Excel公式和格式就不会被同步覆盖了。
坑三:大数据量的性能问题
问题场景:当你的Rowy表中有上万条记录时,同步过程变得非常慢,甚至超时。
解决方案:实现分批同步。
def sync_in_batches(self, batch_size: int = 500) -> Dict:
"""分批同步,避免大数据量超时"""
total_records = self.rowy_client.get_total_count()
logger.info(f'总记录数: {total_records}')
all_records = []
offset = 0
while offset < total_records:
logger.info(f'正在获取数据... 偏移量: {offset}, 批次大小: {batch_size}')
batch_records = self.rowy_client.get_records(
offset=offset,
limit=batch_size
)
if not batch_records:
break
all_records.extend(batch_records)
offset += len(batch_records)
logger.info(f'已获取 {len(all_records)}/{total_records} 条记录')
# 每获取一批就保存一次,防止中断丢失数据
if len(all_records) % (batch_size * 2) == 0:
self._save_partial_data(all_records)
# 最终保存
df = self.converter.rowy_to_excel(
all_records,
self.config.field_mapping,
self.config.special_fields
)
self._save_to_excel(df)
self.sync_stats['total_records'] = len(all_records)
self.sync_stats['last_sync_time'] = datetime.now().isoformat()
logger.info(f'分批同步完成,共 {len(all_records)} 条记录')
return self.sync_stats
def _save_partial_data(self, records: List[Dict]) -> None:
"""保存中间数据(用于断点续传)"""
temp_file = self.config.excel_file_path + '.temp'
df = self.converter.rowy_to_excel(
records,
self.config.field_mapping,
self.config.special_fields
)
# 临时保存
if os.path.exists(temp_file):
wb = load_workbook(temp_file)
if self.config.sheet_name in wb.sheetnames:
del wb[self.config.sheet_name]
ws = wb.create_sheet(self.config.sheet_name)
else:
wb = Workbook()
if 'Sheet' in wb.sheetnames:
del wb['Sheet']
ws = wb.create_sheet(self.config.sheet_name)
for r_idx, row in enumerate(df.values, 2):
for c_idx, value in enumerate(row, 1):
ws.cell(row=r_idx, column=c_idx, value=value)
wb.save(temp_file)
logger.info(f'中间数据已保存: {len(records)} 条')
分批同步的核心思想是:不要一次性拉取所有数据,而是分批次获取并保存。这样即使同步过程中断了,也不至于全部重来。
坑四:字段类型不匹配导致的静默错误
问题场景:Rowy中的某个字段是数字类型,但Excel中对应的列被设置成了文本格式。结果数字看起来正常,但公式计算时却出错,而且没有任何报错提示。
解决方案:添加数据校验层。
def validate_and_convert(self, df: pd.DataFrame) -> pd.DataFrame:
"""数据校验和类型转换"""
validated_df = df.copy()
# 定义需要校验的字段
validation_rules = {
'金额': {'type': 'number', 'min': 0},
'年龄': {'type': 'integer', 'min': 0, 'max': 150},
'日期': {'type': 'datetime'},
'手机号': {'type': 'string', 'pattern': r'^1[3-9]\d{9}$'}
}
errors = []
for column, rules in validation_rules.items():
if column not in validated_df.columns:
continue
for idx, value in enumerate(validated_df[column]):
if pd.isna(value):
continue
# 类型校验
if rules.get('type') == 'number':
try:
num_value = float(value)
if 'min' in rules and num_value < rules['min']:
errors.append(f'{column}[{idx}]: 值 {num_value} 小于最小值 {rules["min"]}')
except (ValueError, TypeError):
errors.append(f'{column}[{idx}]: 无法转换为数字')
elif rules.get('type') == 'integer':
try:
int_value = int(float(value))
if 'min' in rules and int_value < rules['min']:
errors.append(f'{column}[{idx}]: 值 {int_value} 小于最小值 {rules["min"]}')
if 'max' in rules and int_value > rules['max']:
errors.append(f'{column}[{idx}]: 值 {int_value} 大于最大值 {rules["max"]}')
except (ValueError, TypeError):
errors.append(f'{column}[{idx}]: 无法转换为整数')
elif rules.get('type') == 'datetime':
try:
if isinstance(value, str):
datetime.fromisoformat(value.replace('Z', '+00:00'))
elif hasattr(value, 'isoformat'):
pass # 已经是datetime对象
else:
errors.append(f'{column}[{idx}]: 无效的日期格式')
except:
errors.append(f'{column}[{idx}]: 无法解析日期')
elif rules.get('type') == 'string' and 'pattern' in rules:
if not re.match(rules['pattern'], str(value)):
errors.append(f'{column}[{idx}]: 值 "{value}" 不符合格式要求')
if errors:
logger.warning(f'发现 {len(errors)} 个数据校验错误:')
for error in errors[:10]: # 只显示前10个
logger.warning(f' - {error}')
if len(errors) > 10:
logger.warning(f' ... 还有 {len(errors) - 10} 个错误')
return validated_df
在同步过程中调用这个校验函数:
# 在sync_rowy_to_excel方法中添加
df = self.converter.rowy_to_excel(...)
df = self.validate_and_convert(df) # 添加数据校验
self._save_to_excel(df)
坑五:权限和安全问题
问题场景:你的同步脚本泄露了API Key,或者Excel文件被未经授权的访问。
解决方案:
- API Key安全存储
# 使用环境变量,不要硬编码
import os
from dotenv import load_dotenv
load_dotenv() # 从.env文件加载环境变量
config = SyncConfig(
rowy_api_key=os.getenv('ROWY_API_KEY'),
table_id=os.getenv('ROWY_TABLE_ID')
)
- Excel文件加密
from openpyxl import load_workbook
from openpyxl.worksheet.properties import SheetProtection
# 保存时添加保护
wb = load_workbook(self.config.excel_file_path)
ws = wb[self.config.sheet_name]
# 设置工作表保护(防止篡改)
ws.protection.sheet = True
ws.protection.password = 'your_strong_password' # 建议用强密码
# 保存时加密(需要openpyxl 3.1+)
wb.save(self.config.excel_file_path)
- 访问日志
import logging
# 记录每次同步的详细信息
logger.info(f'同步开始 - 用户: {os.getenv("USER", "unknown")}, '
f'时间: {datetime.now().isoformat()}, '
f'记录数: {len(records)}')
第六步:监控和告警 —— 让同步过程可追溯
同步成功了不代表就万事大吉了。你需要知道同步是否正常运行,是否有数据异常,有没有失败的情况。
同步状态监控
class SyncMonitor:
"""同步监控器"""
def __init__(self, config: SyncConfig):
self.config = config
self.alert_history = []
def check_sync_health(self, stats: Dict) -> Dict:
"""检查同步健康状态"""
health_report = {
'status': 'healthy',
'warnings': [],
'errors': [],
'recommendations': []
}
# 1. 检查同步时间
last_sync = stats.get('last_sync_time')
if last_sync:
last_sync_dt = datetime.fromisoformat(last_sync)
hours_since_sync = (datetime.now() - last_sync_dt).total_seconds() / 3600
if hours_since_sync > 24:
health_report['warnings'].append(
f'最后同步时间已超过24小时: {last_sync}'
)
health_report['status'] = 'warning'
elif hours_since_sync > 12:
health_report['recommendations'].append(
'建议检查同步频率设置'
)
# 2. 检查错误数量
errors = stats.get('errors', [])
if errors:
health_report['errors'] = errors[-5:] # 最近5个错误
health_report['status'] = 'error'
# 3. 检查数据量
total_records = stats.get('total_records', 0)
if total_records == 0:
health_report['warnings'].append('当前没有同步任何记录,请检查Rowy表是否有数据')
# 4. 检查Excel文件大小
if os.path.exists(self.config.excel_file_path):
file_size_mb = os.path.getsize(self.config.excel_file_path) / (1024 * 1024)
if file_size_mb > 100:
health_report['warnings'].append(
f'Excel文件过大 ({file_size_mb:.2f} MB),建议拆分或归档'
)
# 5. 检查数据完整性
if total_records > 0:
df = pd.read_excel(self.config.excel_file_path, sheet_name=self.config.sheet_name)
missing_values = df.isnull().sum().sum()
if missing_values > total_records * 0.1: # 空值超过10%
health_report['warnings'].append(
f'数据空值比例较高 ({missing_values}/{total_records * len(df.columns)})'
)
# 记录告警历史
if health_report['status'] != 'healthy':
self.alert_history.append({
'time': datetime.now().isoformat(),
'status': health_report['status'],
'warnings': health_report['warnings'],
'errors': health_report['errors']
})
return health_report
def send_alert(self, message: str, level: str = 'info'):
"""发送告警(可以通过多种方式实现)"""
alert = {
'time': datetime.now().isoformat(),
'level': level,
'message': message
}
self.alert_history.append(alert)
# 根据级别选择告警方式
if level == 'error':
# 发送邮件/Slack通知
self._send_error_alert(message)
elif level == 'warning':
# 记录警告日志
logger.warning(f'[告警] {message}')
else:
logger.info(f'[通知] {message}')
def _send_error_alert(self, message: str):
"""发送错误告警"""
# 这里可以实现邮件、Slack、企业微信等多种告警方式
logger.error(f'需要告警: {message}')
# 示例:发送邮件
# send_email_alert('同步错误告警', message)
# 示例:发送Slack消息
# send_slack_alert('同步错误', message)
定时健康检查
def run_health_check():
"""定时健康检查"""
engine = SyncEngine()
stats = engine.sync_rowy_to_excel()
monitor = SyncMonitor(engine.config)
health_report = monitor.check_sync_health(stats)
logger.info(f'健康检查: {json.dumps(health_report, ensure_ascii=False)}')
if health_report['status'] == 'error':
for error in health_report['errors']:
monitor.send_alert(error, 'error')
elif health_report['status'] == 'warning':
for warning in health_report['warnings']:
monitor.send_alert(warning, 'warning')
常见问题FAQ
Q: Rowy免费版支持API调用吗?
A: 支持,但有限制。免费版每天最多1000次API调用,每个月最多10万条记录。如果需要同步大量数据,建议升级到付费版。
Q: 如何同步多个Rowy表到同一个Excel?
A: 可以创建一个配置,包含多个表的同步设置,然后循环执行:
tables_config = [
{'table_id': 'table1', 'sheet_name': '表1数据'},
{'table_id': 'table2', 'sheet_name': '表2数据'},
{'table_id': 'table3', 'sheet_name': '表3数据'}
]
for table_config in tables_config:
config.table_id = table_config['table_id']
config.sheet_name = table_config['sheet_name']
engine = SyncEngine(config)
engine.sync_rowy_to_excel()
Q: Excel中的修改能回传到Rowy吗?
A: 可以,但需要谨慎处理。建议:
- 先备份Excel文件
- 实现增量同步,只更新修改过的行
- 添加冲突检测机制
Q: 同步过程中断怎么办?
A: 实现断点续传功能。每次同步时记录当前进度,中断后可以从上次的位置继续:
def get_sync_checkpoint(self) -> int:
"""获取同步断点"""
checkpoint_file = self.config.excel_file_path + '.checkpoint'
if os.path.exists(checkpoint_file):
with open(checkpoint_file, 'r') as f:
return int(f.read().strip())
return 0
def save_sync_checkpoint(self, offset: int):
"""保存同步断点"""
checkpoint_file = self.config.excel_file_path + '.checkpoint'
with open(checkpoint_file, 'w') as f:
f.write(str(offset))
总结
从手动导出到API自动同步,这是一条循序渐进的道路。手动导出适合少量数据的一次性需求,Apps Script脚本适合中等规模的数据同步,而Python完整方案则适合生产环境的自动化需求。
关键要点回顾:
- 理解字段类型转换的差异,特别是日期和长数字
- 实现增量同步减少API调用
- 添加数据校验避免静默错误
- 做好监控告警及时发现问题
- 实现断点续传应对中断情况
记住,没有一劳永逸的方案。你的数据量、业务需求、技术栈都会影响最终的选择。根据实际情况灵活调整,才能在Rowy和Excel之间建立起真正可靠的数据同步桥梁。
祝你好运!如果遇到问题,记得先检查日志,大部分问题都能在日志里找到线索。
