
本文记录一套部署于 Synology NAS(DSM 7.x)的自助在线协作表格系统的完整搭建过程。
前提:已安装 Synology Drive Server、Synology Office、Web Station 三个套件;本文从系统账号与权限配置开始,覆盖到代码实现与日常运维。
系统能力:用户通过网页输入表格名称即可创建在线电子表格,同名进入同一表格;多人免登录实时协同编辑;可设置 1–30 天有效期,到期自动清理并释放名称。

一、需求分析与方案设计
1.0 背景:一切都围绕方便展开
本系统的落地环境是单位的业务网(内部办公网络),这一前提决定了需求取向,也决定了技术选型的全部边界:
- 数据不允许流向互联网是硬约束。 业务网内的数据不允许流向互联网协作平台,腾讯文档、金山文档一类公有 SaaS 直接排除——即使它们”更好用”。可用的只有内网既有设备:一台 Synology NAS(目前虚拟机部署),以及它已具备的 Drive/Office 套件能力。
- “零门槛、无约束”是最高优先级。 填表的主体是支行与部门同事,不是技术人员。任何需要”注册账号—登录—学操作”的方案,在实际推行时都会死在第一步。因此免登录、输入名字就能用、浏览器打开即编辑,是设计目标而不是锦上添花。
- 便利与安全的权衡取舍。 免登录模型意味着”持有链接即可编辑”,不存在账号级管控;这在互联网环境是不可接受的,但在业务网内,可信任范围与人员规模可控、有效期自动清理限制了长尾暴露——这是内网场景下便利与安全的一次明确取舍。
- 运维资源有限,方案必须轻便。 无需专职开发运维,任何依赖容器编排、额外数据库或需长期人工值守的架构都不成立。日常零值守,故障可由日志自证。
1.1 需求约束
| 编号 | 需求 | 说明 |
|---|---|---|
| R1 | 免登录 | 使用者无需注册、不分配任何账号 |
| R2 | 名称即入口 | 输入表名:不存在则新建,存在则直接进入 |
| R3 | 多人实时协同 | 同一表格多人同时编辑,单元格级同步 |
| R4 | 生命周期管理 | 建表时设定有效期(1/3/7/10/30 天),到期自动删除、释放名称 |
| R5 | 数据本地化 | 全部数据保存在单位内网,不出域 |
1.2 方案选型
| 方案 | 结论 | 理由 |
|---|---|---|
| 公有 SaaS 协作表格 | 否 | 违反数据本地化要求 |
| EtherCalc 自建 | 否 | 免登录天然满足,但表格功能过弱,不满足办公填报场景 |
| Nextcloud / ONLYOFFICE | 否 | 依赖容器或重资源部署,与现有 Synology 体系重复建设,增加故障点 |
| Synology 套件 + 前端开发 | 采用 | 表格能力完整(公式/历史版本/访客协同)、数据原生保存在 Drive |
1.3 关键逻辑
| 角色 | 账号需求 |
|---|---|
| 创建者 | 需要:向团队文件夹写入文件、创建分享链接,都必须由有权限的账号执行。这是系统的限制。 |
| 编辑者 | 不需要:Drive 的公开分享链接支持”任何知道链接的人”以访客身份直接编辑——这是套件的常规能力。 |
正常的途径是:有账号的创建者通过设置公开分享链接实现编辑者免账号协同编辑。所以,真正的难点是:任意使用者在无账号状态下,经过输入表名、点击创建后,通过 WebAPI 让定义的服务账号自动产出”公开、免登录、可编辑”的分享链接。而官方文档对这条自动化路径几乎没有覆盖。
- 授权层:服务账号默认不具备创建和公开链接的资格,需在 Drive 管理控制台放开非管理员的文件共享与公开共享授权;
- 语义层:
role是分享链接的权限级别参数,决定拿到链接的人能做什么——viewer只能查看、commenter可附加评论、editor可直接编辑;本系统需要后者,创建链接时传入role=editor。但create接口对这个参数并不理会,建出的链接一律是默认的只读(viewer)——“可编辑”权限必须对已建成的链接再执行一次update才真正生效;有效期:链接创建时虽会尝试向update写入due_date,但真正保证到期自动删表、释放名称的,是每日定时的清理任务,它以登记表记录的到期时间为准,撤销链接、删除文件。链接侧的due_date是双保险,即便个别版本不接受,清理任务也会执行; - 寻址层:一个文件在 Drive 里存在两个分享地址,均由群晖生成的,区别在于使用对象:
私人地址:https://10.30.1.253:5000/oo/r/ <令牌>内部链接,需登录;
公开地址:https://10.30.1.253:5000/d/s/ <令牌>/<密钥>公开分享,免登录;
令牌:文件分享的编号,指向对应表;
密钥:公开地址要求的额外段,相当于免登录的通行凭证。
建链成功后,接口响应中取 url 字段即为完整公开地址。注意响应里另有 sharing_link 字段,内容只是密钥部分,并非网址,避免误用。
- 创建侧集中于一个隐形账号:即上文提到的服务账号。建表、建链、回收全由服务账号 sheetbot 执行,使用者不知道它的存在——几十个使用者的账号需求,由 1 个系统内部账号的初始化配置;
- 编辑侧彻底零账号:进入、编辑、协同全部发生在程序自动生成的公开链接内;
- 表名为唯一入口:协同编辑侧无账号体系,表名就是定位协同的唯一字段,体现在前端是一个输入框。
1.4 排除项
交付后为进一步开发并改造官方界面,曾评估过两条路线,最终均予放弃,结论一并记录:
路线一:反向代理 + 页面注入。
在 NAS 上另起独立 nginx 整站反代 DSM,利用响应改写(sub_filter)向 Office 页面注入脚本,劫持该按钮的点击行为。技术上可行,但代价明确:
- 反代将成为全部流量的通路,新增一个整体性故障点;
- 需处理 CSRF 来源校验、WebSocket 长连接、Cookie 域等一系列与 DSM 深度耦合的细节,调试轮次不可预估;
- 依赖对群晖闭源前端资源路径结构的假设,套件版本一旦演进即告失效。
路线二:Office 套件前端二开。
直接修改套件安装目录内的打包 JS,从源头改掉按钮。相比路线一更进一步地侵入平台本体:
- 目标代码为混淆后的打包产物,改动无调试工具支撑,风险极大;
- 套件更新会静默覆盖修改,且故障排查时极难定位。
这套系统的价值在于为业务提供稳定的协作入口,而不是维系与 DSM 内部实现耦合。
1.5 总体架构
整个系统由三层构成,一次完整的使用过程:
第一层 · 首页层(Web Station)
使用者打开 https://10.30.1.253/online-sheet/,输入表名并提交。Web Station 以 PHP 8.x 运行首页程序。
第二层 · 服务层(DSM WebAPI)
首页程序通过本机回环调用 entry.cgi,按环节使用以下接口:
| 接口 | 作用 |
|---|---|
SYNO.API.Auth | 登录取得会话 |
SYNO.FileStation.CopyMove | 把模板复制到团队文件夹 |
SYNO.FileStation.Rename | 将副本重命名为表名 |
SYNO.FileStation.List | 在目标目录中定位副本 |
SYNO.SynologyDrive.TeamFolders | 定位团队文件夹 |
SYNO.SynologyDrive.Files | 按名称查找文件、删除文件 |
SYNO.SynologyDrive.AdvanceSharing | 创建分享链接并提权为可编辑;撤销过期链接 |
| Synology Drive / Office 套件 | 表格存储与协同编辑本身 |
第三层 · 使用层(公开分享链接)
完成链接建立后首页 302 跳转,使用者此后的一切编辑、协同都发生在 Office 编辑器的公开链接内,与首页程序不再交互。
独立后台链路:DSM 任务计划每日运行清理脚本,按登记表撤销到期链接、删除过期表格并释放名称;清理脚本与首页程序共用同一套服务账号与接口封装。
二、环境准备:账号与权限
2.1 创建专用服务账号
控制面板 → 用户 → 新增:
| 配置项 | 取值 | 说明 |
|---|---|---|
| 账号名 | sheetbot | 仅程序使用,不提供给任何人登录 |
| 所属群组 | user,不加入 administrators | 普通用户即可 |
| 两步验证 | 禁止开启 | 开启后 API 登录返回错误码 402,系统全链路失效 |
该账号承担程序全部特权操作,权限范围严格限定。
2.2 创建业务共享文件夹
- 控制面板 → 共享文件夹 → 新增,命名
表格; - 权限页签:
sheetbot勾选 读/写; - Synology Drive 管理控制台 → 团队文件夹 → 勾选启用
表格。
2.3 授权服务账号读取模板目录
模板文件存放于 Web 目录 /web/online-sheet/;
控制面板 → 共享文件夹 → web → 编辑 → 权限:sheetbot 勾选 只读。
2.4 开放非管理员用户分享权限
Synology Drive 管理控制台 → 设置 → 共享 → 非 admin 用户的共享设置:
- 授予文件共享权限:允许(可限定成员为
sheetbot); - 允许公开共享:允许。
2.5 端口与协议
本案例 DSM 的 Web 服务提供两个端口:HTTPS 5000、HTTP 5001。本系统对外分享链接统一走 HTTPS 加密端口(https://10.30.1.253:5000)。需要说明的是,NAS 使用自签证书,浏览器首次访问会出现”不安全”提示,确认即可;若希望彻底消除告警,可将 NAS 证书导入各终端的受信任根证书存储。
三、Web 服务配置
- 套件中心确认 Web Station 已安装,并确认 PHP 配置文件存在;
- Web Station → 脚本语言设置 → PHP → 新增配置文件,关键项:
- PHP 版本:8.x;
- 扩展:勾选
curl与openssl(前者为全部接口调用的传输层,后者支撑本机 HTTPS 回环),其余保持默认; - 调试阶段可选勾选
display_errors,稳定运行后可关闭; open_basedir:不限制(系统默认)。
- Web Station → 网页服务 → 编辑默认站点 → PHP 配置文件选择上述新建配置 → 保存;
- File Station 中创建部署目录
web/online-sheet,新建并授予系统http群组对该目录的读写权限(程序需在其下自动创建数据目录)。
四、系统设计
4.1 文件结构
/web/online-sheet/
├── index.php 首页与控制器:建表/进表/列表/跳转(附录 C)
├── synology.php DSM WebAPI 封装层(附录 B)
├── config.php 环境配置,全部可调参数集中于此(附录 A)
├── cron_cleanup.php 到期清理脚本,由任务计划调度(附录 D)
├── 模板.osheet 空白模板(Office套件 原生电子表格)
├── logo.png / favicon.png LOGO
└── data/ 程序自动创建
├── registry.php 登记表:表名 → 文件ID/链接/有效期(防直读)
└── app.log.php 运行日志(防直读)4.2 核心流程
建表(新表名):
- 服务账号登录,按名称定位团队文件夹 ID;
- 目录中已存在同名
.osheet则直接进入链接建立步骤; - 否则以 File Station 官方接口将
模板.osheet复制至团队文件夹,并异步轮询复制任务、重命名为表名.osheet; - 轮询 Drive 文件列表获取新表
file_id; - 调用 AdvanceSharing 创建分享链接,随后二次 update 将权限提升为 editor(并尝试写入链接到期时间作为双保险;有效期以登记表与清理任务为准);
- 将接口返回的完整公开地址写入登记表,302 跳转。
进表(已存在):
- 查登记表:存在且未过期 → 校验文件仍存在 → 直接归一化返回既有链接;
- 文件已被手动删除 → 清理登记记录,按新表流程重建;
- 链接字段缺失或损坏,则按原名称复用文件、重建链接,沿用原到期时间(防止反复进入重置到期时间)。
列表(首页渲染):
渲染前对登记表中未过期条目逐一验证文件是否在 File Station/Drive 中仍存在,已删除项则从列表剔除并回写登记表;服务账号临时登录失败(如系统繁忙、网络抖动等)时跳过校验,点击时由进表的逻辑流程自愈。
清理(每日定时):
遍历登记表,对已过期条目撤销分享链接、删除表格文件、移除记录并回写。
任务计划新增每日任务:/usr/local/bin/php84 -d extension=curl /volume1/web/online-sheet/cron_cleanup.php
4.3 设计要点
| 要点 | 设计与原因 |
|---|---|
| 接口入口配置 | API 入口(CFG_ENTRY)直接配置为内网地址(与浏览器打开 DSM 一致,如 https://NAS_IP:5000/webapi/entry.cgi),省掉”先用本机地址调用、再替换成内网地址”的环节 |
| 双会话体系 | Drive 操作与文件操作分属 SynologyDrive、File Station 两个会话名,登录/登出按会话释放 |
| Referer 强制 | DSM 将 WebAPI 的请求来源纳入 CSRF 校验(本机 DSM 7.4 实测),程序间调用必须在 HTTP 头携带与入口同源的 Referer,否则一切请求都返回错误码 105,这是所有接口调用的前置条件,官方文档未提示 |
| multipart 的 SID | File Station 上传类接口不从表单体识别会话,_sid 必须置于 URL 查询串(否则错误码 119) |
| 建链双步语义 | create 返回的默认权限是 viewer;editor 权限须经 update 二次设置方能生效(链接侧 due_date 为双保险,到期以登记表+清理任务为准) |
| PRG 防重放 | 建表入口成功后 302 进入编辑页面,用户后退或重开首页时,浏览器可能重放上一次 POST。由于本系统的 POST 会在服务器上执行”建表+建链接”这种不可逆操作,一旦被重放就会重复建表。因此,所有 POST 处理(含成功与失败)一律先 302 落回 GET:浏览器停在 GET 页面上,刷新、后退都只是重新打开首页,不会再触发动作 |
| 大小写不敏感 | 表名是协同空间的唯一标识,实测中 A 与 a 各自建表并自动重命名导致错误。登记表键与文件查找均统一转小写比对,任意大小写输入命中同一张表 |
| 数据防直读 | 登记表与日志以 .php 为扩展名且首行为 <?php exit; ?>,浏览器直接访问返回空白 |
| 列表入口走服务端 | 列表点击仅传表名(?go=),由服务端执行与表单完全一致的定位逻辑,杜绝历史坏链接直达 |
| 列表存在性校验 | 首页渲染前逐条验证登记项对应文件是否仍存在,管理员手动删除的表格自动从列表与登记表中剔除,保证列表与实际文件一致 |
4.4 DSM 平台隐性行为备忘录
本节问题均无官方文档提示,记录以供同类开发参考:
| 现象 | 错误码 | 根因 | 对策 |
|---|---|---|---|
| Public 分享接口不可用 | 103 | 该 Drive 版本不存在 AdvanceSharing.Public | 改用 AdvanceSharing,从响应 url 字段取公开地址 |
| 上传报 SID 无效 | 119 | multipart 请求中 _sid 位于表单体不被识别 | _sid 移至 URL 查询串 |
| 无法创建分享链接 | 1040 | 非 admin 用户未获公开共享授权 | Drive 管理控制台 → 设置 → 共享,放开非管理员的”文件共享”与”公开共享”授权 |
| 建出的链接只读、无法编辑 | — | create 返回的默认权限为 viewer,传入 editor 被忽略 | 对已建成的链接二次 update 提权为 editor |
| 新上传/复制文件查询不到 | — | Drive 文件索引存在秒级延迟 | 带超时轮询(本文设置为最长 12 秒) |
五、运维说明
- 模板禁忌:
模板.osheet禁止删除、改名、写入内容;误删/误改后重建一张空白电子表格、放回原路径即可。 - 故障定位:页面红框提示均可读原因;接口层原始返回记录于
/web/online-sheet/data/app.log.php。 - 数据备份:全部表格实体集中于共享文件夹”表格”中,Hyper Backup 常规备份该文件夹即可;登记表为可再生数据,丢失仅导致按名称重建链接。
- 安全边界:持有分享链接即拥有编辑权限,这是初始的设计目标;部署边界应限定于可信内网,有效期机制用于限制长尾暴露。
六、附录源码
附录 A 环境配置
config.php
<?php
// 群晖 DSM API 入口
define('CFG_ENTRY', 'https://10.30.1.253:5000/webapi/entry.cgi');
define('CFG_REF', 'https://10.30.1.253:5000'); // 与 CFG_ENTRY 同协议同主机同端口
define('CFG_PUBLIC', 'https://10.30.1.253:5000');
// 专用服务账号
define('BS_USER', 'sheetbot');
define('BS_PASS', 'PASSWORK');
// Drive 团队共享文件夹“表格”
define('TEAM_FOLDER', '表格');
// 模板.osheet 存放在 online-sheet 文件夹中
define('TPL_FILE', '/volume1/web/online-sheet/模板.osheet');
define('TPL_NAME', '模板');
// File Station 视角下模板路径
define('TPL_FS_PATH', '/web/online-sheet/模板.osheet');
// 登记册与日志目录
define('DATA_DIR', '/volume1/web/online-sheet/data');
define('REG_FILE', DATA_DIR . '/registry.php');
define('LOG_FILE', DATA_DIR . '/app.log.php');
// 可选有效期(天)
define('DAYS_OPTIONS', [1, 3, 7, 10, 30]);
// 表名最长字符数
define('NAME_MAXLEN', 40);附录 B DSM WebAPI 封装层
synology.php
<?php
require_once __DIR__ . '/config.php';
// 数据目录自动创建
if (!is_dir(DATA_DIR)) { @mkdir(DATA_DIR, 0777, true); @chmod(DATA_DIR, 0777); }
/** API 入口与 Referer 均取自 config.php 配置,部署时按实际协议与端口填写 */
function entry_url(): string { return CFG_ENTRY; }
function ref_url(): string { return CFG_REF; }
/** 追加运行日志,日志文件首行为 PHP 退出头,防止 HTTP 直接读取 */
function blog(string $msg): void {
if (!is_file(LOG_FILE)) { @file_put_contents(LOG_FILE, "<?php exit; ?>\n"); }
@file_put_contents(LOG_FILE, date('Y-m-d H:i:s') . ' ' . $msg . "\n", FILE_APPEND);
}
/**
* DSM WebAPI 统一请求封装,系统内全部接口调用经由本函数执行
* 职责:参数装配、传输层配置、响应反序列化与异常记录
*
* 传输层约定:
* 1. GET、PUT 请求参数经 URL 查询串传递;POST、DELETE 请求参数经请求体传递。参数位置不当时 DSM 返回业务失败而非传输错误,须在集成阶段严格遵循
* 2. 请求头须携带与入口地址同源的 Referer,用于通过 DSM 跨站请求校验,缺失时所有请求返回错误码 105
* 3. 服务与 DSM 同机部署、以本机地址通信,证书为自签名,故关闭对端证书校验
*
* @param string $api 目标接口标识,如 SYNO.API.Auth
* @param int $version 接口版本号,与目标接口不匹配时返回错误码 104
* @param array $params 业务参数集合
* @param string $verb HTTP 方法:GET|POST|PUT|DELETE
* @param string $sid 会话标识,取自认证接口返回值;空串表示调用匿名接口
* @return array DSM 标准 JSON 响应,含 success 字段;失败时含 error.code
*/
function api(string $api, int $version, array $params, string $verb = 'POST', string $sid = ''): array {
$common = ['api' => $api, 'version' => $version] + $params;
if ($sid !== '') $common['_sid'] = $sid;
$ch = curl_init();
if ($verb === 'GET') {
curl_setopt($ch, CURLOPT_URL, entry_url() . '?' . http_build_query($common));
} elseif ($verb === 'PUT') {
curl_setopt($ch, CURLOPT_URL, entry_url() . '?' . http_build_query($common));
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, '');
} else {
curl_setopt($ch, CURLOPT_URL, entry_url());
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $verb);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($common));
}
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_TIMEOUT, 20);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Referer: ' . ref_url()]);
$raw = curl_exec($ch);
$errno = curl_errno($ch); $err = curl_error($ch);
curl_close($ch);
if ($raw === false) { blog("[$api] curl错误 $errno $err"); return ['success' => false, 'error' => ['code' => -1, 'msg' => $err]]; }
$j = json_decode($raw, true);
if (!is_array($j)) { blog("[$api] 非JSON响应: " . substr($raw, 0, 400)); return ['success' => false, 'error' => ['code' => -2, 'msg' => 'bad json']]; }
if (empty($j['success'])) blog("[$api $verb] 失败: " . json_encode($j, JSON_UNESCAPED_UNICODE));
return $j;
}
/** 服务账号登录,返回会话标识;失败时记录错误供 auth_err_text() 呈现 */
function ds_login(string $session = 'SynologyDrive'): ?string {
$r = api('SYNO.API.Auth', 6, [
'method' => 'login',
'session' => $session, 'format' => 'sid',
'account' => BS_USER, 'passwd' => BS_PASS,
'enable_once_ways_code' => 'false',
], 'GET');
if (!empty($r['success'])) return $r['data']['sid'];
$GLOBALS['AUTH_ERR'] = $r;
blog('登录失败(' . $session . '): ' . json_encode($r, JSON_UNESCAPED_UNICODE));
return null;
}
/** 释放会话;$session 须与登录时一致,避免会话残留 */
function ds_logout(string $sid, string $session = 'SynologyDrive'): void {
api('SYNO.API.Auth', 6, ['method' => 'logout', 'session' => $session], 'GET', $sid);
}
/** 将认证错误码转换为可读说明,并附原始返回,用于页面信息反馈与日志记录 */
function auth_err_text(): string {
$r = $GLOBALS['AUTH_ERR'] ?? [];
$code = $r['error']['code'] ?? null;
$map = [
101 => '请求缺少必需参数(程序内部问题,请核对接口文档)',
102 => '请求的 API 名称不存在',
103 => '该 API 版本不支持',
104 => '请求的方法不被该方法支持',
105 => 'Referer 或参数校验失败(CFG_ENTRY 与 CFG_REF 须同协议同主机同端口)',
106 => '请求的 API 不存在(Drive 套件可能未安装或未启用)',
400 => '密码不正确',
401 => '账号不存在(检查 config.php 里 BS_USER 拼写)',
402 => '该账号开启了“两步验证”,请在 DSM 用户设置里关闭后重试',
403 => '两步验证验证码错误(同上,建议关闭该账号的 OTP)',
404 => '账号已被停用或过期',
406 => '该账号被要求先修改密码(用服务账号登录一次 DSM 网页改掉)',
];
$t = $code === null ? '无法连接 DSM 接口(' . ($r['error']['msg'] ?? '未知') . ',检查入口地址/证书)'
: ($map[$code] ?? ('未收录错误码')) . '(code=' . $code . ')';
return $t . '|原始返回: ' . mb_substr(json_encode($r, JSON_UNESCAPED_UNICODE), 0, 200);
}
/** 查询路径或 ID 对应节点的元信息(响应 data 含 file_id) */
function node_get(string $sid, string $path): array {
$p = ['method' => 'get', 'path' => (strpos($path, 'id:') === 0 || strpos($path, '/') === 0) ? $path : '/' . $path];
return api('SYNO.SynologyDrive.Files', 3, $p, 'POST', $sid);
}
/**
* 在团队文件夹根目录内按文件名查找 Drive 文件,文件名比对不区分大小写
* $folderFid 为空时查询主团队文件夹;Drive 索引存在秒级延迟,调用方应配合轮询
*/
function drive_find_by_name(string $sid, string $fileName, ?string $folderFid = null): ?string {
$fid = $folderFid ?? team_fid($sid);
if ($fid === null) return null;
$want = mb_strtolower($fileName);
$offset = 0;
while ($offset < 3000) {
$r = api('SYNO.SynologyDrive.Files', 2, [
'method' => 'list', 'path' => 'id:' . $fid,
'offset' => $offset, 'limit' => 1000,
'sort_by' => 'name', 'sort_direction' => 'asc',
], 'GET', $sid);
if (empty($r['success'])) return null;
$items = $r['data']['items'] ?? [];
foreach ($items as $it) {
if (mb_strtolower($it['name'] ?? '') === $want) return (string)$it['file_id'];
}
if (count($items) < 1000) return null;
$offset += 1000;
}
return null;
}
/**
* 创建公开可编辑、带有效期的分享链接
* 实测语义(本机 DSM 7.4 / Drive 版本):
* 1. AdvanceSharing.Public 不存在(返回 103),公开地址由 AdvanceSharing 的响应 url 字段给出(/d/s/ 两段式);响应 sharing_link 字段仅为密钥段,不可当地址使用
* 2. create 返回的 role 为 viewer、due_date 为 0——可编辑权限须经二次 update 生效;链接侧 due_date 为双保险,到期清理以登记表与定时任务为准
*/
function share_create(string $sid, string $idPath, int $dueTs, string $pwd = ''): array {
$p = ['method' => 'create', 'path' => $idPath, 'role' => 'editor'];
if ($dueTs > 0) $p['due_date'] = $dueTs;
if ($pwd !== '') $p['password'] = $pwd;
$r = api('SYNO.SynologyDrive.AdvanceSharing', 1, $p, 'PUT', $sid);
blog('建链[AdvanceSharing] → ' . mb_substr(json_encode($r, JSON_UNESCAPED_UNICODE), 0, 600));
$d = $r['data'] ?? [];
$key = $d['sharing_link'] ?? '';
$url = $d['url'] ?? '';
if (empty($r['success']) || $key === '') {
// 回退通道:Sharing.create_link(/oo/r/ 内部链接,需登录访问,仅作降级)
$r2 = api('SYNO.SynologyDrive.Sharing', 1, ['method' => 'create_link', 'path' => $idPath], 'POST', $sid);
blog('建链回退[create_link] → ' . mb_substr(json_encode($r2, JSON_UNESCAPED_UNICODE), 0, 300));
if (!empty($r2['success']) && !empty($r2['data']['url'])) {
$r2['data']['final_url'] = $r2['data']['url'];
return $r2;
}
return $r;
}
// 二次 update:提升为 editor 并写入到期时间
$u = [];
foreach ([$dueTs, date('Y-m-d H:i:s', $dueTs)] as $due) {
$u = api('SYNO.SynologyDrive.AdvanceSharing', 1, [
'method' => 'update', 'path' => $idPath,
'sharing_link' => $key, 'role' => 'editor', 'due_date' => $due,
], 'PUT', $sid);
if (!empty($u['success'])) break;
}
blog('提权+有效期update → ' . mb_substr(json_encode($u, JSON_UNESCAPED_UNICODE), 0, 300));
$r['data']['final_url'] = $url; // 完整公开地址
return $r;
}
/**
* 以 File Station 官方复制接口将模板复制到团队文件夹,并重命名为目标表名
* 流程:CopyMove.start 下发异步任务 → status 轮询至完成(上限 10 秒) → List 定位副本模板 → Rename 为正式名称
*/
function fs_copy(string $sid, string $srcPath, string $destDir, string $newName): array {
$r = api('SYNO.FileStation.CopyMove', 3, [
'method' => 'start', 'path' => json_encode([$srcPath], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
'dest_folder_path' => $destDir, 'overwrite' => 'false',
], 'POST', $sid);
blog('FS复制start → ' . mb_substr(json_encode($r, JSON_UNESCAPED_UNICODE), 0, 300));
if (empty($r['success']) || empty($r['data']['taskid'])) return $r;
$taskid = $r['data']['taskid'];
$finished = false;
for ($i = 0; $i < 20; $i++) {
usleep(500000);
$s = api('SYNO.FileStation.CopyMove', 3, ['method' => 'status', 'taskid' => $taskid], 'GET', $sid);
if (empty($s['success'])) {
api('SYNO.FileStation.CopyMove', 3, ['method' => 'cancel', 'taskid' => $taskid], 'GET', $sid);
return $s;
}
if (!empty($s['data']['finished'])) { $finished = true; break; }
}
api('SYNO.FileStation.CopyMove', 3, ['method' => 'cancel', 'taskid' => $taskid], 'GET', $sid);
if (!$finished) return ['success' => false, 'error' => ['code' => -9, 'msg' => '复制任务超时']];
$ls = api('SYNO.FileStation.List', 2, ['method' => 'list', 'folder_path' => $destDir], 'GET', $sid);
$copied = null;
$srcBase = basename($srcPath, '.' . pathinfo($srcPath, PATHINFO_EXTENSION));
$srcExt = pathinfo($srcPath, PATHINFO_EXTENSION);
foreach ($ls['data']['files'] ?? [] as $f) {
$n = $f['name'] ?? '';
if ($n === basename($srcPath)
|| preg_match('/^' . preg_quote($srcBase, '/') . '( \(\d+\))?\.' . preg_quote($srcExt, '/') . '$/u', $n)) {
$copied = $n; break;
}
}
if ($copied === null) return ['success' => false, 'error' => ['code' => -10, 'msg' => '复制完成但未找到副本文件']];
if ($copied !== $newName) {
$rn = api('SYNO.FileStation.Rename', 2, [
'method' => 'rename', 'path' => $destDir . '/' . $copied, 'name' => $newName,
], 'POST', $sid);
blog('FS重命名 → ' . mb_substr(json_encode($rn, JSON_UNESCAPED_UNICODE), 0, 200));
if (empty($rn['success'])) return $rn;
}
blog('FS复制完成 → ' . $destDir . '/' . $newName);
return ['success' => true];
}
/** 撤销分享链接:优先按公开链接接口删除,失败回退内部接口;本机实际生效为后者 */
function share_delete(string $sid, string $linkId): array {
$a = api('SYNO.SynologyDrive.AdvanceSharing.Public', 1, ['method' => 'delete', 'id' => $linkId], 'POST', $sid);
if (!empty($a['success'])) return $a;
return api('SYNO.SynologyDrive.AdvanceSharing', 1, ['method' => 'delete', 'id' => $linkId], 'POST', $sid);
}
/** 删除 Drive 文件(进入回收体系,即回收站) */
function file_delete(string $sid, string $fileId): array {
return api('SYNO.SynologyDrive.Files', 2, [
'method' => 'delete', 'files' => ['id:' . $fileId], 'permanent' => 'false',
], 'POST', $sid);
}
// 登记表(表名:文件ID/链接/有效期)
const REG_HEADER = "<?php exit; ?>\n";
function reg_read(): array {
if (!is_file(REG_FILE)) return [];
$raw = (string)file_get_contents(REG_FILE);
if (strpos($raw, REG_HEADER) === 0) $raw = substr($raw, strlen(REG_HEADER));
$j = json_decode($raw, true);
return is_array($j) ? $j : [];
}
function reg_write(array $reg): bool {
return file_put_contents(REG_FILE, REG_HEADER . json_encode($reg, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT), LOCK_EX) !== false;
}
/** 登记键:表名统一小写形态,实现大小写不敏感的表名匹配 */
function reg_key(string $name): string { return mb_strtolower(trim($name)); }
/**
* 查询指定名称团队文件夹的 file_id(按名称独立缓存)
* 找不到时返回 null,并将最近一次可见列表写入 $GLOBALS['TF_NAMES'] 供错误提示使用
*/
function team_fid_named(string $sid, string $folder): ?string {
static $cache = [];
if (array_key_exists($folder, $cache)) return $cache[$folder];
$r = api('SYNO.SynologyDrive.TeamFolders', 1, [
'method' => 'list', 'offset' => 0, 'limit' => 100,
'sort_by' => 'name', 'sort_direction' => 'asc',
], 'GET', $sid);
$found = null; $names = [];
if (!empty($r['success'])) {
foreach ($r['data']['items'] ?? [] as $it) {
$names[] = $it['name'];
if ($it['name'] === $folder) $found = (string)$it['file_id'];
}
} else {
$names[] = '(查询失败 code=' . ($r['error']['code'] ?? '?') . ')';
}
$GLOBALS['TF_NAMES'] = $names;
if ($found !== null) blog('团队文件夹已定位: ' . $folder . ' id=' . $found);
return $cache[$folder] = $found;
}
/** 主团队文件夹默认查询入口(对 team_fid_named 的固定参数封装) */
function team_fid(string $sid): ?string { return team_fid_named($sid, TEAM_FOLDER); }
/** 表名合法性校验:统一空白、长度限制、禁止路径、保留字符、模板保留名 */
function clean_name(string $raw): ?string {
$n = trim(preg_replace('/\s+/u', ' ', $raw));
if ($n === '' || $n === TPL_NAME || mb_strlen($n) > NAME_MAXLEN) return null;
if (preg_match('#[\\\\/:*?"<>|]#u', $n)) return null;
return $n;
}附录 C 首页与控制器
index.php
<?php
require_once __DIR__ . '/synology.php';
session_start();
$error = '';
$name = '';
$days = 7;
$okMsg = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$rawName = (string)($_POST['name'] ?? '');
$name = clean_name($rawName);
$days = (int)($_POST['days'] ?? 7);
if ($name === null) {
$_SESSION['os_error'] = '表名不合法:不能为空/过长,且不能包含 \\ / : * ? " < > | 等字符';
$_SESSION['os_name'] = $rawName;
} elseif (!in_array($days, DAYS_OPTIONS, true)) {
$_SESSION['os_error'] = '有效期选项不合法';
} else {
$result = open_or_create($name, $days);
$url = $result['url'] ?? '';
if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL)) {
// 直接 302 跳进公开可编辑的表格地址(GET 跳转,刷新只重载编辑页,避免重复建表)
header('Location: ' . $url, true, 302);
exit;
}
if ($url !== '') {
// 链接格式异常:不跳转,返回首页,并把原始链接记入日志便于修正
blog('跳转地址校验失败,原始链接: ' . $url);
$_SESSION['os_url'] = $url;
$_SESSION['os_ok'] = '表格「' . $name . '」已创建,点击下方按钮进入编辑。';
} else {
$_SESSION['os_error'] = $result['error'] ?? '未知错误(详见服务器日志)';
$_SESSION['os_name'] = $rawName;
}
}
// 失败时 302 返回首页显示错误
header('Location: ' . ($_SERVER['SCRIPT_NAME'] ?? '/online-sheet/index.php'));
exit;
}
// GET ?go=表名:列表点击入口,与表单“进入/复用”后 302 跳转的逻辑相同
if (isset($_GET['go'])) {
$gname = clean_name((string)$_GET['go']);
$regG = reg_read();
$gkey = $gname !== null ? reg_key($gname) : '';
if ($gname === null || empty($regG[$gkey]) || ($regG[$gkey]['due'] ?? 0) <= time()) {
header('Location: ' . $_SERVER['SCRIPT_NAME'], true, 302); exit; // 非法/已过期:回首页
}
$gdays = (int)($regG[$gkey]['days'] ?? 7);
if (!in_array($gdays, DAYS_OPTIONS, true)) $gdays = 7;
$result = open_or_create($gname, $gdays);
$url = $result['url'] ?? '';
if ($url !== '' && filter_var($url, FILTER_VALIDATE_URL)) {
header('Location: ' . $url, true, 302); exit;
}
$_SESSION['os_error'] = $result['error'] ?? ('表格「' . $gname . '」暂时无法打开,请重新输入表名进入');
header('Location: ' . $_SERVER['SCRIPT_NAME'], true, 302); exit;
}
$error = $_SESSION['os_error'] ?? ''; unset($_SESSION['os_error']);
$okMsg = $_SESSION['os_ok'] ?? ''; unset($_SESSION['os_ok']);
$okUrl = $_SESSION['os_url'] ?? ''; unset($_SESSION['os_url']);
$name = $_SESSION['os_name'] ?? ''; unset($_SESSION['os_name']);
/**
* 核心逻辑:登记表里有且未过期,则直接返回既有链接;否则建表
* 建表步骤:定位团队文件夹,复制模板为 表名.osheet,轮询获取文件ID ,建可编辑公开链接,写登记表并返回跳转地址
*/
function open_or_create(string $name, int $days): array {
$reg = reg_read();
$key = reg_key($name);
$now = time();
// 已有且未过期:先验证文件是否存在
if (isset($reg[$key]) && ($reg[$key]['due'] ?? 0) > $now) {
$sid = ds_login();
if (!$sid) return ['error' => '服务账号登录失败:' . auth_err_text()];
$info = node_get($sid, 'id:' . $reg[$key]['file_id']);
ds_logout($sid);
if (!empty($info['success']) && !empty($reg[$key]['url'])
&& preg_match('#^https?://#i', $reg[$key]['url'])
&& !preg_match('#/oo/r/#', $reg[$key]['url'])) {
// 经 share_url 规范化:历史 https:5000 记录统一换成当前 CFG_PUBLIC 出口
return ['url' => share_url($reg[$key]['url'])];
}
if (empty($info['success'])) {
unset($reg[$key]); // 文件已被手动删除,则重建
reg_write($reg);
}
// 文件存在,但旧记录链接缺失或损坏,则新建,按名称复用该文件重新建立链接
}
// 新建
$sid = ds_login();
if (!$sid) return ['error' => '服务账号登录失败:' . auth_err_text()];
try {
$fid = team_fid($sid);
if ($fid === null) {
$seen = $GLOBALS['TF_NAMES'] ?? [];
if (empty($seen) || in_array('(查询失败 code=?)', $seen, true)) {
return ['error' => '无法查询团队文件夹列表(Drive 接口异常),请确认已安装 Synology Drive Server 并登录 DSM 打开 Drive 管理控制台'];
}
return ['error' => '共享文件夹「' . TEAM_FOLDER . '」还不是团队文件夹,或 sheetbot 无读写权限。'
. '请到 Drive 管理控制台→团队文件夹 勾选启用「' . TEAM_FOLDER . '」,并在 控制面板→共享文件夹→' . TEAM_FOLDER . '→权限 给 sheetbot 勾读/写。'
. '(当前 sheetbot 可见的团队文件夹:' . implode('、', $seen) . ')'];
}
// 目录里已有同名表格(比如未清理成功),则直接复用新建链接
$fileId = drive_find_by_name($sid, $name . '.osheet') ?? '';
if ($fileId === '') {
// 1) 模板在 online-sheet 文件夹里:以 sheetbot 文件权限,按名称复制一份到「表格」并重命名为 表名.osheet
if (!is_file(TPL_FILE)) {
return ['error' => '母版不存在:请确认 模板.osheet 已放在 web/online-sheet 文件夹里'
. '(可从 Drive「表格」文件夹里右键 模板.osheet → 下载,再上传到 online-sheet)。'];
}
$sidF = ds_login('FileStation');
if (!$sidF) return ['error' => '获取文件服务会话失败:' . auth_err_text()];
$cp = fs_copy($sidF, TPL_FS_PATH, '/' . TEAM_FOLDER, $name . '.osheet');
ds_logout($sidF, 'FileStation');
if (empty($cp['success'])) {
return ['error' => '复制母版失败:' . api_err($cp)
. '|原始返回: ' . mb_substr(json_encode($cp, JSON_UNESCAPED_UNICODE), 0, 260)];
}
// 2) 轮询 Drive 拿到新表 file_id(最多 12 秒)
for ($i = 0; $i < 12; $i++) {
$fileId = drive_find_by_name($sid, $name . '.osheet') ?? '';
if ($fileId !== '') break;
sleep(1);
}
if ($fileId === '') {
return ['error' => '复制完成但 12 秒内未出现 ' . $name . '.osheet,请查日志或稍后重试'];
}
}
// 复用已有文件重建链接时,若原到期时间未过则沿用;已过期则从现在起按新选天数重新计时
$due = (isset($reg[$key]) && ($reg[$key]['due'] ?? 0) > $now) ? (int)$reg[$key]['due'] : $now + $days * 86400;
$created = (isset($reg[$key]) && !empty($reg[$key]['created'])) ? (int)$reg[$key]['created'] : $now;
$sh = share_create($sid, 'id:' . $fileId, $due);
if (empty($sh['success'])) {
$hint = (($sh['error']['code'] ?? null) === 1040)
? '(1040=该账号没有创建共享链接的权限:请到 Synology Drive 管理控制台 → 设置 → 共享 → “非管理员用户的共享设置”,勾选允许文件共享后重试)'
: '';
return ['error' => '创建共享链接失败:' . api_err($sh) . $hint
. '|原始返回: ' . mb_substr(json_encode($sh, JSON_UNESCAPED_UNICODE), 0, 200)];
}
$d = $sh['data'] ?? [];
$link = $d['sharing_link'] ?? '';
$raw = $d['final_url'] ?? '';
// 响应主机跟随调用地址(CFG_ENTRY),统一换算为对外发布地址(CFG_PUBLIC)
$url = $raw !== '' ? share_url($raw) : ($link !== '' ? share_url($link) : '');
if ($url === '') return ['error' => '接口未返回有效链接,请查日志'];
$reg[$key] = ['name' => $name, 'file_id' => $fileId, 'link' => $link,
'url' => $url, 'due' => $due, 'created' => $created, 'days' => $days];
reg_write($reg);
return ['url' => $url];
} finally {
ds_logout($sid);
}
}
/** 分享链接规范化:兼容完整URL/相对路径/裸别名三种返回格式,统一附件到对外地址 */
function share_url(string $link): string {
$link = trim($link);
$base = rtrim(CFG_PUBLIC, '/');
if ($link === '') return $base . '/';
if (preg_match('#^https?://#i', $link)) { // 完整URL(主机为调用入口 CFG_ENTRY)附加到对外地址 CFG_PUBLIC
$p = parse_url($link);
return $base . ($p['path'] ?? '') . (isset($p['query']) ? '?' . $p['query'] : '') . (isset($p['fragment']) ? '#' . $p['fragment'] : '');
}
if ($link[0] === '/') return $base . $link; // 相对路径:/d/s/xxx 或 /oo/r/xxx 原样组合
if (substr($link, 0, 2) !== './' && strpos($link, '/') !== false) {
return $base . '/d/s/' . ltrim($link, '/'); // “令牌/密钥”两段形态组合形成公开链接路径
}
return $base . '/oo/r/' . $link; // 保障分支(正常流程不会触发):参数是一串字符(无协议/路径前缀),拼到 http://对外地址/oo/r/ 之后;此类链接需登录群晖后才能打开
}
function api_err(array $r): string {
return isset($r['error']['code']) ? ('接口错误码 ' . $r['error']['code']) : '接口无有效响应';
}
$reg = reg_read();
$active = array_filter($reg, fn($v) => ($v['due'] ?? 0) > time());
/*
* 列表有效性校验:登记表中的文件可能已被管理员在 File Station / Drive 中手动删除,渲染前逐一验证文件仍存在,剔除已删除项并回写登记表,保证列表与实际文件一致
* 服务账号登录失败时跳过校验(列表照常显示,点击时由 open_or_create 自愈重建)
*/
if ($active) {
$sidV = ds_login();
if ($sidV) {
$removed = [];
foreach ($active as $k => $v) {
$info = node_get($sidV, 'id:' . $v['file_id']);
if (empty($info['success'])) { $removed[] = $k; unset($active[$k]); }
}
ds_logout($sidV);
if ($removed) {
foreach ($removed as $k) unset($reg[$k]);
reg_write($reg);
}
}
}
uasort($active, fn($a, $b) => $b['created'] <=> $a['created']);
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>在线协作表格</title>
<link rel="icon" href="hrbrcb-favicon.png">
<style>
:root { --blue:#1677ff; --bg:#f5f7fa; }
* { box-sizing:border-box; margin:0; padding:0; }
body { font-family:"Microsoft YaHei",system-ui,sans-serif; background:var(--bg);
min-height:100vh; display:flex; align-items:center; justify-content:center; padding:16px; }
.card { background:#fff; border-radius:16px; box-shadow:0 6px 24px rgba(0,0,0,.07);
padding:30px 28px; width:100%; max-width:460px; }
.brand { display:flex; align-items:center; gap:10px; margin-bottom:14px; }
.brand img { width:40px; height:40px; }
.brand h1 { font-size:21px; }
.tip-main { font-size:15px; color:#333; font-weight:600; margin-bottom:4px; }
.tip-sub { font-size:12.5px; color:#9aa0a8; margin-bottom:16px; }
input[type=text] { width:100%; font-size:17px; padding:12px 14px; border:2px solid #dcdfe6;
border-radius:10px; outline:none; }
input[type=text]:focus { border-color:var(--blue); }
.valid-head { display:flex; align-items:baseline; gap:8px; margin:18px 0 8px; }
.valid-label { font-size:14px; color:#333; font-weight:600; }
.valid-note { font-size:12px; color:#b0b6bf; }
.row { display:flex; gap:8px; flex-wrap:wrap; }
.row label { flex:1; text-align:center; padding:9px 0; border:1.5px solid #dcdfe6;
border-radius:9px; cursor:pointer; font-size:14px; color:#555; user-select:none; }
.row input { display:none; }
.row label:has(input:checked) { border-color:var(--blue); color:var(--blue);
background:#eaf3ff; font-weight:600; }
button { width:100%; font-size:16px; padding:13px; border:0; border-radius:10px; margin-top:20px;
background:var(--blue); color:#fff; cursor:pointer; }
button:hover { opacity:.9; }
.err { background:#fff1f0; border:1px solid #ffccc7; color:#c0392b; padding:10px 12px;
border-radius:8px; font-size:13px; margin-bottom:16px; }
.ok { background:#f0f9eb; border:1px solid #b3e19d; color:#529b2e; padding:10px 12px;
border-radius:8px; font-size:13px; margin-bottom:12px; }
.ok a { display:inline-block; margin-top:8px; background:var(--blue); color:#fff;
padding:7px 16px; border-radius:8px; text-decoration:none; font-size:14px; }
.recent { margin-top:24px; border-top:1px solid #eee; padding-top:14px; }
.recent h2 { font-size:14px; color:#999; margin-bottom:8px; font-weight:normal; }
.list { max-height:180px; overflow-y:auto; padding-right:4px; }
.list::-webkit-scrollbar { width:6px; }
.list::-webkit-scrollbar-thumb { background:#d5dae2; border-radius:3px; }
.list a { display:flex; justify-content:space-between; align-items:center;
padding:9px 10px; border-radius:8px; text-decoration:none;
color:#333; font-size:14px; }
.list a:hover { background:#f0f6ff; }
.list .tag { color:#b0b6bf; font-size:12px; flex-shrink:0; margin-left:12px; }
.footer { text-align:center; color:#c0c6cf; font-size:11.5px; margin-top:22px; }
</style>
</head>
<body>
<div class="card">
<div class="brand"><img src="hrbrcb.png" alt="logo"><h1>在线协作表格</h1></div>
<div class="tip-main">请输入表格名称</div>
<div class="tip-sub">输入名称新建表格,同名即进入协同编辑</div>
<?php if ($okMsg !== ''): ?>
<div class="ok"><?= htmlspecialchars($okMsg) ?>
<?php if (!empty($okUrl)): ?>
<br><a href="<?= htmlspecialchars($okUrl) ?>">➤ 点这里进入表格</a>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($error !== ''): ?>
<div class="err"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<form method="post" autocomplete="off">
<input type="text" name="name" placeholder="例如:统计表" maxlength="<?= NAME_MAXLEN ?>"
value="<?= htmlspecialchars($name) ?>" required autofocus>
<div class="valid-head">
<span class="valid-label">表格有效期</span>
<span class="valid-note">到期后表格自动删除</span>
</div>
<div class="row">
<?php foreach (DAYS_OPTIONS as $d): ?>
<label><input type="radio" name="days" value="<?= $d ?>" <?= $d === $days ? 'checked' : '' ?>>
<span><?= $d ?> 天</span></label>
<?php endforeach; ?>
</div>
<button type="submit">进入 / 新建表格</button>
</form>
<?php if ($active): ?>
<div class="recent">
<h2>当前正在使用的表格(点击进入)</h2>
<div class="list">
<?php foreach ($active as $k => $v): ?>
<a href="<?= htmlspecialchars($_SERVER['SCRIPT_NAME'] . '?go=' . rawurlencode($v['name'])) ?>">
<span><?= htmlspecialchars($v['name']) ?></span>
<span class="tag"><?= date('n月j日', $v['due']) ?> 到期</span>
</a>
<?php endforeach; ?>
</div>
</div>
<?php endif; ?>
<div class="footer">© Pizza</div>
</div>
</body>
</html>附录 D 到期清理
cron_cleanup.php
<?php
// 到期清理脚本:由 DSM 任务计划每天凌晨执行一次
require_once __DIR__ . '/synology.php';
$reg = reg_read();
$now = time();
$todo = []; // key => entry,已过期的
foreach ($reg as $k => $v) {
if (($v['due'] ?? 0) <= $now) $todo[$k] = $v;
}
if (!$todo) { exit(0); }
$sid = ds_login();
if (!$sid) { blog('清理任务:登录失败'); exit(1); }
foreach ($todo as $k => $v) {
// 1) 撤共享链接(链接已失效/不存在时接口会报错,忽略即可)
if (!empty($v['link'])) share_delete($sid, $v['link']);
// 2) 删 .osheet 文件(进 Drive 版本/回收体系,可在回收站找回)
if (!empty($v['file_id'])) file_delete($sid, $v['file_id']);
unset($reg[$k]);
blog("清理完成: {$v['name']}");
}
ds_logout($sid);
reg_write($reg);
exit(0);附:给”用户”的话
如果你此刻和我同时身处业务网——那么这套系统已经就绪:http://10.30.1.253或https://10.30.1.253;
输入一个表格名称即可开始:没建过的名字就是新建,建过的名字直接加入协同;把名字告诉一起填表的同事,各自打开就是同一张表。