摘要: Access 不直接连接 SQL Server,也能通过 HTTPS 调用 Web API 完成查询和保存;本文给出 ASP.NET Core 接口、SQL 参数化命令和 Access VBA 调用代码。
Hi,大家好!
上一篇写了不用链接表访问 SQL Server,主要讲 DAO 直连和 ADO 参数化查询。后来有人继续问:如果连 DAO、ADO 都不想直接碰 SQL Server,还有没有办法?
有,就是在 Access 和 SQL Server 中间加一层 Web API。那今天我就给大家秀一把肌肉,注意以下的代码只能做为demo,不能用于生产环境
Web API 可以理解为一套通过 HTTP 对外提供功能的程序接口。客户端按约定的地址和方法发出请求,例如用 GET 查询客户、用 POST 新增客户、用 PUT 修改客户;服务器处理完成后,通常用 JSON 返回数据和执行结果。调用方不需要知道服务器内部用了什么语言,也不需要直接操作数据库。
现在的网页、手机 App、小程序和桌面软件,大多不会各自直接连接数据库,而是共同调用后端 API。用户登录、权限判断、库存校验、订单保存、文件上传这些规则放在服务器端,前端只负责收集输入和显示结果。同一套 API 还可以提供给合作伙伴或其他内部系统使用,这也是现代前后端分离、移动应用和系统集成中很常见的做法。
对 Access 来说,道理也一样。Access 虽然是桌面程序,但它同样能发送 HTTP 请求、提交 JSON 和读取返回结果。这样既保留了 Access 做窗体和报表的效率,又能把数据库账号、权限检查和核心业务规则留在服务器端。
这时 Access 不再拿数据库账号连接 SQL Server,而是向一个 HTTPS 地址发送请求。API 收到请求后检查身份、验证数据,再去查询或修改 SQL Server,最后把 JSON 结果返回给 Access。
Access 窗体 → HTTPS → Web API → SQL Server
Access 窗体 ← JSON ← Web API ← SQL Server
我一般不会为了局域网里两个简单窗体专门加这一层。DAO 或 ADO 已经能稳定解决的问题,再增加 API 只会多一套部署和维护工作。但系统需要跨公网访问、SQL Server 不允许客户端直连,或者将来还会接网页、手机端时,Web API 就有价值了。业务规则只写在服务器上,几个前端共用同一套接口。
下面直接用我本机已经跑通的结构演示:数据库是 test,表是 dbo.TD_Customer。Access 通过 API 查询客户、读取单个客户、新增客户和修改客户。服务器端使用 ASP.NET Core Minimal API。
本文使用的客户表
TD_Customer 一共有 12 个字段:
| 字段 | SQL Server 类型 | 说明 |
|---|---|---|
FID |
int IDENTITY |
自增主键 |
FCustomerCode |
nvarchar(255) |
客户编码,必填 |
FCustomerName |
nvarchar(255) |
客户名称 |
FShortName |
nvarchar(255) |
客户简称 |
FPhone |
nvarchar(255) |
联系电话 |
FAddress |
nvarchar(255) |
联系地址 |
FRemark |
nvarchar(255) |
备注 |
FCreator |
nvarchar(50) |
创建人 |
FCreateTime |
datetime |
创建时间 |
FModifier |
nvarchar(50) |
修改人 |
FModifyTime |
datetime |
修改时间 |
FEnabled |
bit |
是否启用 |
需要自己建立测试表时,在 SQL Server Management Studio 中执行:
USE test;
GO
CREATE TABLE dbo.TD_Customer
(
FID INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
FCustomerCode NVARCHAR(255) NOT NULL,
FCustomerName NVARCHAR(255) NULL,
FShortName NVARCHAR(255) NULL,
FPhone NVARCHAR(255) NULL,
FAddress NVARCHAR(255) NULL,
FRemark NVARCHAR(255) NULL,
FCreator NVARCHAR(50) NULL,
FCreateTime DATETIME NULL,
FModifier NVARCHAR(50) NULL,
FModifyTime DATETIME NULL,
FEnabled BIT NULL
);
INSERT INTO dbo.TD_Customer
(FCustomerCode, FCustomerName, FShortName, FEnabled)
VALUES
(N'C00001', N'测试客户一', N'客户一', 1),
(N'C00002', N'测试客户二', N'客户二', 1);
本文后面的 SQL、C# 数据模型和 Access 控件都按这张表写。正式使用前仍建议先备份数据库,再在测试库验证新增和修改。

建立 ASP.NET Core Web API
服务器需要安装 .NET 8 SDK。打开命令行,创建项目并安装 SQL Server 驱动:
dotnet new web -n CustomerApi
cd CustomerApi
dotnet add package Microsoft.Data.SqlClient
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:SalesDb" "Server=localhost;Database=test;User ID=sa;Password=123;Encrypt=True;TrustServerCertificate=True"
dotnet user-secrets set "ApiKey" "customer-api-local-2026"
dotnet dev-certs https --trust
我这里是用本文本机测试环境,不能照搬到生产服务器。连接字符串和 API 密钥放进 User Secrets,没有写进源码;部署后应改用权限受控的配置或密钥管理服务,并给 API 单独建立最小权限数据库账号。
打开项目中的 Program.cs,替换为下面的代码:
using System.Data;
using System.Security.Cryptography;
using System.Text;
using Microsoft.Data.SqlClient;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
var connectionString = builder.Configuration.GetConnectionString("SalesDb")
?? throw new InvalidOperationException("缺少 SalesDb 连接字符串。");
var apiKey = builder.Configuration["ApiKey"]
?? throw new InvalidOperationException("缺少 ApiKey 配置。");
if (!app.Environment.IsDevelopment())
app.UseHttpsRedirection();
app.Use(async (context, next) =>
{
var suppliedKey = context.Request.Headers["X-Api-Key"].ToString();
if (!SameText(suppliedKey, apiKey))
{
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
await context.Response.WriteAsJsonAsync(new { message = "身份验证失败。" });
return;
}
await next();
});
app.MapGet("/api/customers", async (string? keyword) =>
{
const string sql = """
SELECT TOP (200)
FID, FCustomerCode, FCustomerName, FShortName, FPhone,
FAddress, FRemark, FCreator, FCreateTime,
FModifier, FModifyTime, FEnabled
FROM dbo.TD_Customer
WHERE @Keyword = N''
OR FCustomerCode LIKE N'%' + @Keyword + N'%'
OR FCustomerName LIKE N'%' + @Keyword + N'%'
OR FShortName LIKE N'%' + @Keyword + N'%'
ORDER BY FID DESC;
""";
var customers = new List<Customer>();
await using var conn = new SqlConnection(connectionString);
await using var cmd = new SqlCommand(sql, conn);
cmd.Parameters.Add("@Keyword", SqlDbType.NVarChar, 255).Value =
(keyword ?? string.Empty).Trim();
await conn.OpenAsync();
await using var reader = await cmd.ExecuteReaderAsync();
while (await reader.ReadAsync())
customers.Add(ReadCustomer(reader));
return Results.Ok(new { customers });
});
app.MapGet("/api/customers/{id:int}", async (int id) =>
{
const string sql = """
SELECT FID, FCustomerCode, FCustomerName, FShortName, FPhone,
FAddress, FRemark, FCreator, FCreateTime,
FModifier, FModifyTime, FEnabled
FROM dbo.TD_Customer
WHERE FID = @FID;
""";
await using var conn = new SqlConnection(connectionString);
await using var cmd = new SqlCommand(sql, conn);
cmd.Parameters.Add("@FID", SqlDbType.Int).Value = id;
await conn.OpenAsync();
await using var reader = await cmd.ExecuteReaderAsync();
return await reader.ReadAsync()
? Results.Ok(ReadCustomer(reader))
: Results.NotFound(new { message = "客户不存在。" });
});
app.MapPost("/api/customers", async (CustomerInput input) =>
{
var validationError = Validate(input);
if (validationError is not null)
return Results.BadRequest(new { message = validationError });
const string sql = """
INSERT INTO dbo.TD_Customer
(FCustomerCode, FCustomerName, FShortName, FPhone, FAddress,
FRemark, FCreator, FCreateTime, FModifier, FModifyTime, FEnabled)
OUTPUT INSERTED.FID
VALUES
(@Code, @Name, @ShortName, @Phone, @Address,
@Remark, @Operator, GETDATE(), @Operator, GETDATE(), @Enabled);
""";
await using var conn = new SqlConnection(connectionString);
await using var cmd = new SqlCommand(sql, conn);
AddCustomerParameters(cmd, input);
await conn.OpenAsync();
var id = Convert.ToInt32(await cmd.ExecuteScalarAsync());
return Results.Created($"/api/customers/{id}", new { id });
});
app.MapPut("/api/customers/{id:int}", async (int id, CustomerInput input) =>
{
var validationError = Validate(input);
if (validationError is not null)
return Results.BadRequest(new { message = validationError });
const string sql = """
UPDATE dbo.TD_Customer
SET FCustomerCode = @Code,
FCustomerName = @Name,
FShortName = @ShortName,
FPhone = @Phone,
FAddress = @Address,
FRemark = @Remark,
FModifier = @Operator,
FModifyTime = GETDATE(),
FEnabled = @Enabled
WHERE FID = @FID;
""";
await using var conn = new SqlConnection(connectionString);
await using var cmd = new SqlCommand(sql, conn);
AddCustomerParameters(cmd, input);
cmd.Parameters.Add("@FID", SqlDbType.Int).Value = id;
await conn.OpenAsync();
return await cmd.ExecuteNonQueryAsync() == 1
? Results.NoContent()
: Results.NotFound(new { message = "客户不存在。" });
});
app.Run();
static Customer ReadCustomer(SqlDataReader reader) => new(
reader.GetInt32(0),
reader.GetString(1),
GetNullableString(reader, 2),
GetNullableString(reader, 3),
GetNullableString(reader, 4),
GetNullableString(reader, 5),
GetNullableString(reader, 6),
GetNullableString(reader, 7),
reader.IsDBNull(8) ? null : reader.GetDateTime(8),
GetNullableString(reader, 9),
reader.IsDBNull(10) ? null : reader.GetDateTime(10),
!reader.IsDBNull(11) && reader.GetBoolean(11));
static string? GetNullableString(SqlDataReader reader, int ordinal) =>
reader.IsDBNull(ordinal) ? null : reader.GetString(ordinal);
static string? Validate(CustomerInput input)
{
if (string.IsNullOrWhiteSpace(input.CustomerCode))
return "客户编码不能为空。";
if (input.CustomerCode.Trim().Length > 255)
return "客户编码不能超过 255 个字符。";
if (TooLong(input.CustomerName) || TooLong(input.ShortName) ||
TooLong(input.Phone) || TooLong(input.Address) || TooLong(input.Remark))
return "客户名称、简称、电话、地址和备注不能超过 255 个字符。";
if (string.IsNullOrWhiteSpace(input.Operator))
return "操作人不能为空。";
if (input.Operator.Trim().Length > 50)
return "操作人不能超过 50 个字符。";
return null;
}
static bool TooLong(string? value) => value?.Trim().Length > 255;
static void AddCustomerParameters(SqlCommand cmd, CustomerInput input)
{
cmd.Parameters.Add("@Code", SqlDbType.NVarChar, 255).Value = input.CustomerCode.Trim();
AddNullableText(cmd, "@Name", input.CustomerName, 255);
AddNullableText(cmd, "@ShortName", input.ShortName, 255);
AddNullableText(cmd, "@Phone", input.Phone, 255);
AddNullableText(cmd, "@Address", input.Address, 255);
AddNullableText(cmd, "@Remark", input.Remark, 255);
cmd.Parameters.Add("@Operator", SqlDbType.NVarChar, 50).Value = input.Operator.Trim();
cmd.Parameters.Add("@Enabled", SqlDbType.Bit).Value = input.Enabled;
}
static void AddNullableText(SqlCommand cmd, string name, string? value, int size) =>
cmd.Parameters.Add(name, SqlDbType.NVarChar, size).Value =
string.IsNullOrWhiteSpace(value) ? DBNull.Value : value.Trim();
static bool SameText(string left, string right)
{
var leftBytes = Encoding.UTF8.GetBytes(left);
var rightBytes = Encoding.UTF8.GetBytes(right);
return leftBytes.Length == rightBytes.Length
&& CryptographicOperations.FixedTimeEquals(leftBytes, rightBytes);
}
record CustomerInput(
string CustomerCode,
string? CustomerName,
string? ShortName,
string? Phone,
string? Address,
string? Remark,
string Operator,
bool Enabled);
record Customer(
int Id,
string CustomerCode,
string? CustomerName,
string? ShortName,
string? Phone,
string? Address,
string? Remark,
string? Creator,
DateTime? CreateTime,
string? Modifier,
DateTime? ModifyTime,
bool Enabled);
查询、新增和修改全部使用 SqlParameter,Access 传来的值不会被拼进 SQL。ReadCustomer 集中处理数据库里的 NULL,否则遇到客户名称、电话或备注为空时,直接调用 GetString 会报错。连接也是每次请求用完就释放,底层连接池会复用物理连接,不需要自己维护一个全局 SqlConnection。
运行 API:
dotnet run --launch-profile https

本文测试项目监听 https://localhost:7043 和 http://localhost:5043。本机开发使用 HTTP,避免开发证书未受信任导致 WinHTTP 拒绝连接;正式部署必须使用受信任证书的 HTTPS 地址。
浏览器直接访问接口会提示身份验证失败,这是正常的,因为接口要求请求头中包含 API Key;使用演示 Access 文件访问即可。

Access 端准备 JSON 模块
VBA 本身没有 JSON 解析器。这个示例使用常见的 JsonConverter.bas,在 VBE 中选择"文件 → 导入文件"导入模块,再到"工具 → 引用"勾选 Microsoft Scripting Runtime。
如果项目里已经有 VBA-JSON,就不要重复导入。本文只调用它的两个公开方法:ParseJson 和 ConvertToJson。
这次为了让本机示例拿来就能测试,API Key 直接写在 VBA 常量里。它只适合演示,不能当作生产环境的安全方案。API Key 放进 Access 客户端后始终可能被取出,公网或高权限业务应使用用户登录换取短期访问令牌,而不是给所有客户端发一个永久密钥。
写一个 HTTP 公共模块
新建标准模块 modCustomerApi,代码如下:
Option Compare Database
Option Explicit
' 本机开发使用 HTTP;正式部署必须改为受信任证书的 HTTPS 地址。
Private Const API_BASE As String = "http://localhost:5043"
Private Const API_KEY As String = "customer-api-local-2026"
Private Function CreateRequest( _
ByVal method As String, _
ByVal path As String _
) As Object
Dim http As Object
Set http = CreateObject("WinHttp.WinHttpRequest.5.1")
http.Open method, API_BASE & path, False
http.SetRequestHeader "Accept", "application/json"
http.SetRequestHeader "X-Api-Key", API_KEY
http.SetTimeouts 5000, 5000, 15000, 15000
Set CreateRequest = http
End Function
Private Function UrlEncodeUtf8(ByVal value As String) As String
Dim stream As Object
Dim bytes() As Byte
Dim index As Long
Dim result As String
Dim currentByte As Long
If Len(value) = 0 Then
UrlEncodeUtf8 = vbNullString
Exit Function
End If
Set stream = CreateObject("ADODB.Stream")
stream.Type = 2
stream.Charset = "utf-8"
stream.Open
stream.WriteText value
stream.Position = 0
stream.Type = 1
stream.Position = 3
bytes = stream.Read
stream.Close
For index = LBound(bytes) To UBound(bytes)
currentByte = bytes(index)
Select Case currentByte
Case 48 To 57, 65 To 90, 97 To 122, 45, 46, 95, 126
result = result & Chr$(currentByte)
Case Else
result = result & "%" & Right$("0" & Hex$(currentByte), 2)
End Select
Next
UrlEncodeUtf8 = result
End Function
Private Sub EnsureStatus(ByVal http As Object, ByVal expectedStatus As Long)
If http.Status <> expectedStatus Then
Err.Raise vbObjectError + 2200, "modCustomerApi", _
"请求失败(HTTP " & http.Status & "):" & http.ResponseText
End If
End Sub
Public Function ApiGetCustomers(Optional ByVal keyword As String = "") As Object
Dim http As Object
Set http = CreateRequest( _
"GET", "/api/customers?keyword=" & UrlEncodeUtf8(keyword))
http.Send
EnsureStatus http, 200
Set ApiGetCustomers = JsonConverter.ParseJson(http.ResponseText)
End Function
Public Function ApiGetCustomer(ByVal customerID As Long) As Object
Dim http As Object
Set http = CreateRequest("GET", "/api/customers/" & CStr(customerID))
http.Send
EnsureStatus http, 200
Set ApiGetCustomer = JsonConverter.ParseJson(http.ResponseText)
End Function
Public Function ApiAddCustomer( _
ByVal customerCode As String, _
ByVal customerName As String, _
ByVal shortName As String, _
ByVal phone As String, _
ByVal address As String, _
ByVal remark As String, _
ByVal operatorName As String, _
ByVal enabled As Boolean _
) As Long
Dim http As Object
Dim body As Object
Dim result As Object
Set body = CreateObject("Scripting.Dictionary")
FillCustomerBody body, customerCode, customerName, shortName, _
phone, address, remark, operatorName, enabled
Set http = CreateRequest("POST", "/api/customers")
http.SetRequestHeader "Content-Type", "application/json; charset=utf-8"
http.Send JsonConverter.ConvertToJson(body)
EnsureStatus http, 201
Set result = JsonConverter.ParseJson(http.ResponseText)
ApiAddCustomer = CLng(result("id"))
End Function
Public Sub ApiUpdateCustomer( _
ByVal customerID As Long, _
ByVal customerCode As String, _
ByVal customerName As String, _
ByVal shortName As String, _
ByVal phone As String, _
ByVal address As String, _
ByVal remark As String, _
ByVal operatorName As String, _
ByVal enabled As Boolean _
)
Dim http As Object
Dim body As Object
Set body = CreateObject("Scripting.Dictionary")
FillCustomerBody body, customerCode, customerName, shortName, _
phone, address, remark, operatorName, enabled
Set http = CreateRequest("PUT", "/api/customers/" & CStr(customerID))
http.SetRequestHeader "Content-Type", "application/json; charset=utf-8"
http.Send JsonConverter.ConvertToJson(body)
EnsureStatus http, 204
End Sub
Private Sub FillCustomerBody( _
ByVal body As Object, _
ByVal customerCode As String, _
ByVal customerName As String, _
ByVal shortName As String, _
ByVal phone As String, _
ByVal address As String, _
ByVal remark As String, _
ByVal operatorName As String, _
ByVal enabled As Boolean _
)
body.Add "customerCode", customerCode
body.Add "customerName", customerName
body.Add "shortName", shortName
body.Add "phone", phone
body.Add "address", address
body.Add "remark", remark
body.Add "operator", operatorName
body.Add "enabled", enabled
End Sub
我在这里用了后期绑定创建 WinHttpRequest、ADODB.Stream 和 Scripting.Dictionary,不需要额外勾选这三个引用。UrlEncodeUtf8 负责把中文搜索词安全地放进 URL;空关键字要提前返回,否则读取空字节数组时会出错。SetTimeouts 也不要省,服务器或网络异常时,Access 至少能在限定时间内返回,而不是整个窗体一直卡住。
做一个客户维护窗体
在 Access 窗体放置以下控件:
| 控件类型 | 控件名称 | 用途 |
|---|---|---|
| 文本框 | txtKeyword |
按编码、名称或简称搜索 |
| 列表框 | lstCustomers |
显示客户列表 |
| 文本框 | txtFID |
保存客户主键,设为不可见 |
| 文本框 | txtCustomerCode |
客户编码 |
| 文本框 | txtCustomerName |
客户名称 |
| 文本框 | txtShortName |
客户简称 |
| 文本框 | txtPhone |
联系电话 |
| 文本框 | txtAddress |
联系地址 |
| 文本框 | txtRemark |
备注 |
| 复选框 | chkEnabled |
是否启用 |
| 按钮 | btnQuery |
执行查询 |
| 按钮 | btnNew |
清空控件,准备新增 |
| 按钮 | btnSave |
新增或修改客户 |

把 lstCustomers 的"行来源类型"设为"值列表","列数"设为 5。第一列是隐藏的 FID,后面依次显示客户编码、客户名称、联系电话和启用状态。窗体模块先写查询和加载代码:
Private Sub Form_Load()
Me.lstCustomers.ColumnCount = 5
Me.lstCustomers.ColumnWidths = "0cm;2.8cm;4cm;3cm;2cm"
LoadCustomers
End Sub
Private Sub btnQuery_Click()
LoadCustomers Nz(Me.txtKeyword.Value, vbNullString)
End Sub
Private Sub LoadCustomers(Optional ByVal keyword As String = "")
On Error GoTo LoadErr
Dim result As Object
Dim customer As Variant
Set result = ApiGetCustomers(keyword)
Me.lstCustomers.RowSourceType = "Value List"
Me.lstCustomers.RowSource = vbNullString
For Each customer In result("customers")
Me.lstCustomers.AddItem _
customer("id") & ";" & _
SafeListText(customer("customerCode")) & ";" & _
SafeListText(customer("customerName")) & ";" & _
SafeListText(customer("phone")) & ";" & _
IIf(CBool(customer("enabled")), "启用", "停用")
Next
Exit Sub
LoadErr:
MsgBox Err.Description, vbCritical
End Sub
Private Function SafeListText(ByVal value As Variant) As String
If IsNull(value) Then
SafeListText = vbNullString
Else
SafeListText = Replace(CStr(value), ";", ",")
End If
End Function
SafeListText 有两个作用:数据库字段为 NULL 时不报错;字段内容含英文分号时先替换掉,因为值列表本身用分号分列。接口最多返回 200 条,数据再多时应增加分页,不要把几千条 JSON 全塞进值列表。
双击列表中的客户时,根据隐藏的 FID 再读取一条完整资料:
Private Sub lstCustomers_DblClick(Cancel As Integer)
On Error GoTo LoadErr
Dim customer As Object
If Me.lstCustomers.ListIndex < 0 Then Exit Sub
Set customer = ApiGetCustomer(CLng(Me.lstCustomers.Column(0)))
Me.txtFID = customer("id")
Me.txtCustomerCode = JsonValue(customer, "customerCode")
Me.txtCustomerName = JsonValue(customer, "customerName")
Me.txtShortName = JsonValue(customer, "shortName")
Me.txtPhone = JsonValue(customer, "phone")
Me.txtAddress = JsonValue(customer, "address")
Me.txtRemark = JsonValue(customer, "remark")
Me.chkEnabled = CBool(customer("enabled"))
Exit Sub
LoadErr:
MsgBox Err.Description, vbCritical
End Sub
Private Function JsonValue(ByVal item As Object, ByVal key As String) As Variant
If IsNull(item(key)) Then
JsonValue = Null
Else
JsonValue = item(key)
End If
End Function
通过 API 新增和修改客户
txtFID 为空时调用 POST 新增;有值时调用 PUT 修改。新增按钮和保存按钮代码如下:
Private Sub btnNew_Click()
Me.txtFID = Null
Me.txtCustomerCode = Null
Me.txtCustomerName = Null
Me.txtShortName = Null
Me.txtPhone = Null
Me.txtAddress = Null
Me.txtRemark = Null
Me.chkEnabled = True
Me.txtCustomerCode.SetFocus
End Sub
Private Sub btnSave_Click()
On Error GoTo SaveErr
Dim customerID As Long
If Len(Trim$(Nz(Me.txtCustomerCode.Value, vbNullString))) = 0 Then
MsgBox "客户编码不能为空。", vbExclamation
Me.txtCustomerCode.SetFocus
Exit Sub
End If
If IsNull(Me.txtFID.Value) Then
customerID = ApiAddCustomer( _
Nz(Me.txtCustomerCode.Value, vbNullString), _
Nz(Me.txtCustomerName.Value, vbNullString), _
Nz(Me.txtShortName.Value, vbNullString), _
Nz(Me.txtPhone.Value, vbNullString), _
Nz(Me.txtAddress.Value, vbNullString), _
Nz(Me.txtRemark.Value, vbNullString), _
Environ$("USERNAME"), _
Nz(Me.chkEnabled.Value, False))
Me.txtFID = customerID
MsgBox "新增成功,客户 ID:" & customerID, vbInformation
Else
ApiUpdateCustomer _
CLng(Me.txtFID.Value), _
Nz(Me.txtCustomerCode.Value, vbNullString), _
Nz(Me.txtCustomerName.Value, vbNullString), _
Nz(Me.txtShortName.Value, vbNullString), _
Nz(Me.txtPhone.Value, vbNullString), _
Nz(Me.txtAddress.Value, vbNullString), _
Nz(Me.txtRemark.Value, vbNullString), _
Environ$("USERNAME"), _
Nz(Me.chkEnabled.Value, False)
MsgBox "修改成功。", vbInformation
End If
LoadCustomers Nz(Me.txtKeyword.Value, vbNullString)
Exit Sub
SaveErr:
MsgBox Err.Description, vbCritical
End Sub
这套代码已经在本机 test.dbo.TD_Customer 上实际测试过:Access VBA 完成查询、新增、修改和回读后,再删除测试记录,表中原来的数据没有被改动。Access 做一次输入检查,API 再检查一次,这不是重复。客户端检查是为了马上提醒用户,服务器端检查才是不能绕过的业务边界。



部署时不能照搬开发配置
本机跑通后,正式部署至少要处理四件事。
第一,API 要使用受信任的 HTTPS 证书。开发证书只适合本机测试,不要在 VBA 里加入"忽略所有证书错误"的代码。那样虽然少弹一个错误,却失去了 HTTPS 对服务器身份的验证。
第二,不要把 SQL Server 暴露到公网。只有 API 服务器需要访问数据库端口,Access 客户端只访问 API 的 443 端口。数据库账号也只保存在服务器端,客户端拿不到连接字符串。
第三,统一 API Key 只适合风险较低的内部程序。涉及不同用户权限时,应接入 Windows 身份、OAuth 2.0 或 OpenID Connect,让 Access 登录后取得短期令牌,API 再按用户和角色授权。身份验证解决"你是谁",接口还要继续判断"你能操作哪些客户资料"。
第四,要保留请求日志,但不要把密钥、密码和完整客户隐私写进日志。至少记录请求时间、用户、接口、状态码和服务器生成的跟踪编号,出了问题才查得到是哪一次请求。
这个方案适合什么项目
如果 Access 和 SQL Server 都在同一局域网,客户端数量不多,DAO、ADO 已经稳定运行,我不会仅仅为了"架构看起来新"就改成 Web API。API 多了一套服务器程序,也多了证书、发布、监控和版本兼容工作。
但下面几种情况,我会优先考虑 API:SQL Server 明确禁止客户端直连;Access 需要在外网使用;同一套业务还要给网页或手机端调用;数据库权限不能下发到每台电脑;保存时有一套必须统一执行的业务规则。
从 Access 的角度看,它只是把原来的"执行 SQL"换成了"发送 HTTP 请求"。真正的变化发生在系统边界:数据库被藏在服务器后面,验证、权限和写入规则都集中到了 API。项目规模到了这个阶段,增加这一层才是有实际回报的。