DSM WebAPI 逆向记录:在业务网用群晖搭建”在线文档”

本文记录一套部署于 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 让定义的服务账号自动产出”公开、免登录、可编辑”的分享链接。而官方文档对这条自动化路径几乎没有覆盖。

  1. 授权层:服务账号默认不具备创建和公开链接的资格,需在 Drive 管理控制台放开非管理员的文件共享与公开共享授权;
  2. 语义层:role 是分享链接的权限级别参数,决定拿到链接的人能做什么——viewer 只能查看、commenter 可附加评论、editor 可直接编辑;本系统需要后者,创建链接时传入 role=editor。但 create 接口对这个参数并不理会,建出的链接一律是默认的只读(viewer)——“可编辑”权限必须对已建成的链接再执行一次 update 才真正生效;有效期:链接创建时虽会尝试向 update 写入 due_date,但真正保证到期自动删表、释放名称的,是每日定时的清理任务,它以登记表记录的到期时间为准,撤销链接、删除文件。链接侧的 due_date 是双保险,即便个别版本不接受,清理任务也会执行;
  3. 寻址层:一个文件在 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 创建业务共享文件夹

  1. 控制面板 → 共享文件夹 → 新增,命名 表格;
  2. 权限页签:sheetbot 勾选 读/写;
  3. 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 服务配置

  1. 套件中心确认 Web Station 已安装,并确认 PHP 配置文件存在;
  2. Web Station → 脚本语言设置 → PHP → 新增配置文件,关键项:
    • PHP 版本:8.x;
    • 扩展:勾选 curl 与 openssl(前者为全部接口调用的传输层,后者支撑本机 HTTPS 回环),其余保持默认;
    • 调试阶段可选勾选 display_errors,稳定运行后可关闭;
    • open_basedir:不限制(系统默认)。
  3. Web Station → 网页服务 → 编辑默认站点 → PHP 配置文件选择上述新建配置 → 保存;
  4. 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 核心流程

建表(新表名):

  1. 服务账号登录,按名称定位团队文件夹 ID;
  2. 目录中已存在同名 .osheet 则直接进入链接建立步骤;
  3. 否则以 File Station 官方接口将模板.osheet复制至团队文件夹,并异步轮询复制任务、重命名为 表名.osheet;
  4. 轮询 Drive 文件列表获取新表 file_id;
  5. 调用 AdvanceSharing 创建分享链接,随后二次 update 将权限提升为 editor(并尝试写入链接到期时间作为双保险;有效期以登记表与清理任务为准);
  6. 将接口返回的完整公开地址写入登记表,302 跳转。

进表(已存在):

  1. 查登记表:存在且未过期 → 校验文件仍存在 → 直接归一化返回既有链接;
  2. 文件已被手动删除 → 清理登记记录,按新表流程重建;
  3. 链接字段缺失或损坏,则按原名称复用文件、重建链接,沿用原到期时间(防止反复进入重置到期时间)。

列表(首页渲染):

渲染前对登记表中未过期条目逐一验证文件是否在 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 的 SIDFile 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 无效119multipart 请求中 _sid 位于表单体不被识别_sid 移至 URL 查询串
无法创建分享链接1040非 admin 用户未获公开共享授权Drive 管理控制台 → 设置 → 共享,放开非管理员的”文件共享”与”公开共享”授权
建出的链接只读、无法编辑—create 返回的默认权限为 viewer,传入 editor 被忽略对已建成的链接二次 update 提权为 editor
新上传/复制文件查询不到—Drive 文件索引存在秒级延迟带超时轮询(本文设置为最长 12 秒)

五、运维说明

  1. 模板禁忌:模板.osheet 禁止删除、改名、写入内容;误删/误改后重建一张空白电子表格、放回原路径即可。
  2. 故障定位:页面红框提示均可读原因;接口层原始返回记录于 /web/online-sheet/data/app.log.php。
  3. 数据备份:全部表格实体集中于共享文件夹”表格”中,Hyper Backup 常规备份该文件夹即可;登记表为可再生数据,丢失仅导致按名称重建链接。
  4. 安全边界:持有分享链接即拥有编辑权限,这是初始的设计目标;部署边界应限定于可信内网,有效期机制用于限制长尾暴露。

六、附录源码

附录 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;

输入一个表格名称即可开始:没建过的名字就是新建,建过的名字直接加入协同;把名字告诉一起填表的同事,各自打开就是同一张表。

0 评论
最新
最旧 得票最多