Access 怎么做前后端分离?用 Web API 读写 SQL Server

摘要: 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:7043http://localhost:5043。本机开发使用 HTTP,避免开发证书未受信任导致 WinHTTP 拒绝连接;正式部署必须使用受信任证书的 HTTPS 地址。

浏览器直接访问接口会提示身份验证失败,这是正常的,因为接口要求请求头中包含 API Key;使用演示 Access 文件访问即可。

Access 端准备 JSON 模块

VBA 本身没有 JSON 解析器。这个示例使用常见的 JsonConverter.bas,在 VBE 中选择"文件 → 导入文件"导入模块,再到"工具 → 引用"勾选 Microsoft Scripting Runtime

如果项目里已经有 VBA-JSON,就不要重复导入。本文只调用它的两个公开方法:ParseJsonConvertToJson

这次为了让本机示例拿来就能测试,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

我在这里用了后期绑定创建 WinHttpRequestADODB.StreamScripting.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。项目规模到了这个阶段,增加这一层才是有实际回报的。

相关推荐
Juicedata1 小时前
腾讯云 x JuiceFS:基于 FoundationDB 的企业级统一存储实践
数据库·人工智能·科技·云计算·腾讯云
10share1 小时前
React 新一代样式隔离方案 —— 编译时、零运行时、原生写法
前端·react.js
问商十三载1 小时前
RAG 检索召回越多越好?2026 信噪比模型深度解析
人工智能·算法·机器学习
小罗水1 小时前
第13章 Redis 缓存、幂等锁与任务状态
数据库·redis·缓存
Revolution611 小时前
Node.js 是什么:前端项目里哪些事情由它完成
前端·面试·node.js
在学了加油1 小时前
LSTM-糖尿病探索与预测
人工智能·rnn·lstm
长三角活动观察1 小时前
政企线下活动全流程项目管控方案:苏州独石传媒长三角多场景落地实践总结
大数据·人工智能·传媒
正在走向自律1 小时前
AI 辅助研发内部复盘(2/5):老项目改造的工程化实践
人工智能·ai编程·代码规范·ai辅助编程·老代码改造
SelectDB技术团队1 小时前
当 PostgreSQL 面临性能瓶颈:80TB 电商业务迁移至 Apache Doris 的实践思考
数据库·postgresql·apache