SharePoint 2016 文件庫提供線上編輯或以桌面版 Word/Excel 開啟兩種選項。線上編輯可直接用瀏覽器編輯較方便且支援多人共同修改,但功能及操作流暢度遠不及桌面版,因此要做粗活兒大家多半還是會開本機的 Word/Excel 作業,反正改完會自動儲存同步回伺服器端也很方便。

用本機 Word/Excel 開啟文件庫檔案時,預設為唯讀模式,啟用編輯模式後 SharePoint 會鎖定該檔案防止別人修改,此時其他人如試圖編輯同一檔案將出現提示:

不過,一直以來常有個困擾 - 前一位編輯者明明已關閉 Word/Excel,其他人開啟該檔案卻一直顯示檔案仍被該使用者鎖定,更好笑的情況是,鎖定者有時還是自己。

推測似乎是 Word/Excel 在結束時未正確解除鎖定導致,並非每次都會發生但蠻常遇到的。爬文發現不少人都遇過類似問題,因此也出現各式解法:參考

  1. 耐心等 10 分鐘
  2. 修復 Office
  3. 清除 OfficeCache
  4. 重新啟動 WebClient 服務
  5. 使用 SharePoint Server PowerShell 工具 - Unlock Documents With PowerShell

用 SharePoint PowerShell 工具解鎖是我覺得最直接有效的做法,但需要由鎖定者以其身分執行,發生對象常為不特定的末端使用者,做一個小動作得先安裝 SharePoint PowerShell 模組工程有點浩大。如果想純手工解決,有人分享用 Word/Excel 反覆簽出/簽入釋放鎖定的做法也可一試:參考

回到解鎖工具上,實在不想扯上 SharePoint PowerShell 模組,我想嘗試用 C# 或 PowerShell 配 SharePoint CSOM 程式庫寫成小程式或腳本比較輕巧。研究發現 CSOM 沒有提供對映做法,一個替代做法是設法查出鎖定識別碼,再呼叫 Web Service 解鎖。參考

實際的程式邏輯是呼叫 _vti_bin/_vti_aut/author.dll 傳入文件連結取得 lockId,再呼叫 _vti_bin/cellstorage.svc/CellStorageService 模擬 WCF 呼叫解鎖,查到一篇 JavaScript 版範例 - How To Use JavaScript To Delete Short-Term Locks From Documents Opened From SharePoint?,我把它翻寫成 C# + PowerShell 版,只需傳入文件連結(我用了點小技巧,從 SPS 的 HTTP 302 導向回應拿到站台 URL),只靠 WebClient、HttpWebRequest 完成工作,不需要 SharePoint CSOM 或其他程式庫就搞定。

程式如下,我仍採用腳本內嵌 C# 程式轉成組件供 PowerShell 呼叫的混搭寫法,程式較好寫又避免部署執行檔的疑慮:

Param (
    [Parameter(Mandatory = $true)][string]$docUrl
)
$ErrorActionPreference = "STOP"
Add-Type -AssemblyName "System.Web"
if (-not ('UnlockSPDocHelper' -as [type])) {
    Add-Type -TypeDefinition @"
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using System.Web;

public class UnlockSPDocHelper
{
    public static string PeekRedirectLocation(string url)
    {
        var req = (HttpWebRequest)WebRequest.Create(url);
        req.AllowAutoRedirect = false;
        req.Method = "GET";
        req.UseDefaultCredentials = true;
        using (var resp = req.GetResponse())
        {
            var loc = resp.Headers[HttpResponseHeader.Location];
            if (string.IsNullOrEmpty(loc)) throw new ApplicationException("Not a redirection response");
            return loc;
        }
    }
    //REF: https://gist.github.com/tiagoduarte/066653f4d0560981d43cc34c8a0c5f56
    public static string CheckLock(string docUrl)
    {
        try
        {
            var redirectUrl = PeekRedirectLocation(docUrl);
            var siteUrl = redirectUrl.Substring(0, redirectUrl.IndexOf("_"));
            var unlockUrl = siteUrl + "_vti_bin/_vti_aut/author.dll";
            docUrl = HttpUtility.UrlDecode(docUrl).Split('?').First();
            var postBody = "method=getDocsMetaInfo%3a14%2e0%2e0%2e6009&url%5flist=%5b" + System.Web.HttpUtility.UrlEncode(docUrl) + "%5d&listHiddenDocs=false&listLinkInfo=false";
            var wc = new WebClient();
            wc.Encoding = Encoding.UTF8;
            wc.UseDefaultCredentials = true;
            wc.Headers.Add(HttpRequestHeader.ContentType, "application/x-www-form-urlencoded");
            wc.Headers.Add("X-Vermeer-Content-Type", "application/x-www-form-urlencoded");
            wc.Headers.Add("MIME-Version", "1.0");
            wc.Headers.Add("User-Agent", "MSFrontPage/14.0");
            wc.Headers.Add("Accept", "auth/sicily");
            var res = wc.UploadString(unlockUrl, postBody);
            var props = new Dictionary<string, string>();
            string propName = string.Empty;
            res.Split('\n').Where(o => o.StartsWith("<li>"))
                .Select(o => o.Substring(4))
                .ToList().ForEach(v =>
                {
                    if (Regex.IsMatch(v, "^[A-Z][A-Z][|]"))
                        props.Add(propName, v.Substring(3));
                    else propName = v;
                });
            Func<string, string> getProp = (n) =>
                props.ContainsKey(n) ? props[n] : string.Empty;
            var lockId = getProp("vti_sourcecontrollockid");
            var lockedBy = getProp("vti_sourcecontrolcheckedoutby");
            var lockExpires = getProp("vti_sourcecontrollockexpires");
            if (string.IsNullOrEmpty(lockId)) return string.Empty;
            return string.Format(lockId + "\t" + lockedBy.Split('|').Last().Replace("&#92;", "\\") + "\t" + 
                DateTime.Parse(lockExpires).ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"));
        }
        catch (Exception ex)
        {
            return "ERROR-" + ex.Message;
        }
    }


    public static string Unlock(string docUrl, string lockId)
    {
        try
        {
            var redirectUrl = PeekRedirectLocation(docUrl);
            var siteUrl = redirectUrl.Substring(0, redirectUrl.IndexOf("_"));
            var unlockUrl = siteUrl + "_vti_bin/cellstorage.svc/CellStorageService";
            docUrl = HttpUtility.UrlDecode(docUrl).Split('?').First();
            var releseLockReq = @"
--urn:uuid:8cfcbb22-dd52-4889-b29d-9ff2dcf909b2
Content-ID: <f13ad06d-8530-4af1-8cf3-d6d75c1635d4@tempuri.org>
Content-Transfer-Encoding: 8bit
Content-Type: application/xop+xml;charset=utf-8;type=""text/xml; charset=utf-8""

<s:Envelope xmlns:s=""http://schemas.xmlsoap.org/soap/envelope/"">
    <s:Body>
    <RequestVersion Version=""2"" MinorVersion=""0"" xmlns=""http://schemas.microsoft.com/sharepoint/soap/""/>
    <RequestCollection CorrelationId=""{{35E42C96-FE02-41FE-B4D8-F7DEC43AF784}}"" xmlns=""http://schemas.microsoft.com/sharepoint/soap/"">
        <Request Url=""{0}"" RequestToken=""1"">
        <SubRequest Type=""ExclusiveLock"" SubRequestToken=""1"">
            <SubRequestData ExclusiveLockRequestType=""ReleaseLock"" ExclusiveLockID=""{1}""/>
        </SubRequest>
        </Request>
    </RequestCollection>
    </s:Body>
</s:Envelope>
--urn:uuid:8cfcbb22-dd52-4889-b29d-9ff2dcf909b2--";
            var postBody = string.Format(releseLockReq, docUrl, lockId);
            var wc = new WebClient();
            wc.Encoding = Encoding.UTF8;
            wc.UseDefaultCredentials = true;
            wc.Headers.Add(HttpRequestHeader.ContentType, 
                @"multipart/related; type=""application/xop+xml""; boundary=""urn:uuid:8cfcbb22-dd52-4889-b29d-9ff2dcf909b2""; start=""<f13ad06d-8530-4af1-8cf3-d6d75c1635d4@tempuri.org>""; start-Info=""text/xml; charset=utf-8""");
            wc.Headers.Add("MIME-Version", "1.0");
            wc.Headers.Add("User-Agent", "Microsoft Office Upload Center 2010 (14.0.6124) Windows NT 6.1");
            wc.Headers.Add("SoapAction", "http://schemas.microsoft.com/sharepoint/soap/ICellStorages/ExecuteCellStorageRequest");
            var res = wc.UploadString(unlockUrl, postBody);
            if (res.Contains(@"ErrorCode=""Success""")) return "解鎖成功";
            return "ERROR-" + res;
        }
        catch (Exception ex)
        {
            return "ERROR-" + ex.Message;
        }
    }
}
"@ -Language CSharp -ReferencedAssemblies ("System.Web")
}

if (!$docUrl.Contains("?")) {
    $docUrl += "?web=1"
}

[string]$lockInfo = [UnlockSPDocHelper]::CheckLock($docUrl)
if ([string]::IsNullOrEmpty($lockInfo)) {
    Write-Host "檔案未被鎖定" -ForegroundColor Yellow
}
elseif ($lockInfo.Contains("ERROR")) {
    Write-Host $lockInfo -ForegroundColor Red
}
else {
    $p = $lockInfo.Split("`t")
    Write-Host "檔案被 [$($p[1])] 鎖定中(鎖定期間:$($p[2])),若您為鎖定者可強制解鎖,現在要解鎖嗎?(Y/N) " -NoNewline -ForegroundColor Cyan
    $confirm = Read-Host 
    if ($confirm.ToUpper() -eq "Y") {
        Write-Host ([UnlockSPDocHelper]::Unlock($docUrl, $p[0])) -ForegroundColor Yellow
    }
}

以文件連結作為參數執行 PowerShell Script,程式會先偵測檔案是否被鎖定,如在鎖定中會在詢問後強制解鎖,成功!

Example of C# + PowerShell code to release lock on SharePoint documents.


Comments

# by 資深打字員

這篇太實用了…有太多類似case 感謝分享

# by Al

太強了,真的常遇到被自己鎖住的問題!

# by Leon

不要用 SharePoint 就好,這句話看似廢文其實背後有很深的哲理。

# by My

ERROR-遠端伺服器傳回一個錯誤: (401) 未經授權。 怎樣使用其他帳號登入?

# by Peter

太強了 一試馬上成功 一定要回覆感謝大大的分享!!

# by Ju

請問若文件路徑非http://位置該如何解鎖呢 ,謝謝

Post a comment