erlang内置支持ssl
This commit is contained in:
parent
ae97b5374c
commit
ecfbdae8f4
@ -7,7 +7,7 @@
|
|||||||
{backlog, 10240}
|
{backlog, 10240}
|
||||||
]},
|
]},
|
||||||
|
|
||||||
{tcp_server, [
|
{ssl_server, [
|
||||||
{port, 18092},
|
{port, 18092},
|
||||||
{acceptors, 500},
|
{acceptors, 500},
|
||||||
{max_connections, 10240},
|
{max_connections, 10240},
|
||||||
|
|||||||
@ -7,6 +7,13 @@
|
|||||||
{backlog, 10240}
|
{backlog, 10240}
|
||||||
]},
|
]},
|
||||||
|
|
||||||
|
{ssl_server, [
|
||||||
|
{port, 18092},
|
||||||
|
{acceptors, 500},
|
||||||
|
{max_connections, 10240},
|
||||||
|
{backlog, 10240}
|
||||||
|
]},
|
||||||
|
|
||||||
{redis_server, [
|
{redis_server, [
|
||||||
{port, 16379},
|
{port, 16379},
|
||||||
{acceptors, 500},
|
{acceptors, 500},
|
||||||
|
|||||||
@ -219,7 +219,7 @@ handle_event({call, From}, {rpc_call, ReceiverPid, RpcCall}, _, State = #state{u
|
|||||||
case HasSession andalso is_pid(ChannelPid) of
|
case HasSession andalso is_pid(ChannelPid) of
|
||||||
true ->
|
true ->
|
||||||
%% 通过websocket发送请求
|
%% 通过websocket发送请求
|
||||||
Ref = tcp_channel:rpc_call(ChannelPid, ReceiverPid, RpcCall),
|
Ref = ssl_channel:rpc_call(ChannelPid, ReceiverPid, RpcCall),
|
||||||
{keep_state, State, [{reply, From, {ok, Ref}}]};
|
{keep_state, State, [{reply, From, {ok, Ref}}]};
|
||||||
false ->
|
false ->
|
||||||
logger:debug("[iot_host] uuid: ~p, invalid state: ~p", [UUID, state_map(State)]),
|
logger:debug("[iot_host] uuid: ~p, invalid state: ~p", [UUID, state_map(State)]),
|
||||||
@ -229,7 +229,7 @@ handle_event({call, From}, {rpc_call, ReceiverPid, RpcCall}, _, State = #state{u
|
|||||||
handle_event({call, From}, {container_call, ReceiverPid, Request}, _, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) ->
|
handle_event({call, From}, {container_call, ReceiverPid, Request}, _, State = #state{uuid = UUID, channel_pid = ChannelPid, has_session = HasSession}) ->
|
||||||
case HasSession andalso is_pid(ChannelPid) of
|
case HasSession andalso is_pid(ChannelPid) of
|
||||||
true ->
|
true ->
|
||||||
Ref = tcp_channel:container_call(ChannelPid, ReceiverPid, Request),
|
Ref = ssl_channel:container_call(ChannelPid, ReceiverPid, Request),
|
||||||
{keep_state, State, [{reply, From, {ok, Ref}}]};
|
{keep_state, State, [{reply, From, {ok, Ref}}]};
|
||||||
false ->
|
false ->
|
||||||
logger:debug("[iot_host] uuid: ~p, invalid state: ~p", [UUID, state_map(State)]),
|
logger:debug("[iot_host] uuid: ~p, invalid state: ~p", [UUID, state_map(State)]),
|
||||||
@ -239,7 +239,7 @@ handle_event({call, From}, {container_call, ReceiverPid, Request}, _, State = #s
|
|||||||
handle_event({call, From}, {cancel_rpc_call, Ref}, _, State = #state{channel_pid = ChannelPid}) ->
|
handle_event({call, From}, {cancel_rpc_call, Ref}, _, State = #state{channel_pid = ChannelPid}) ->
|
||||||
case is_pid(ChannelPid) of
|
case is_pid(ChannelPid) of
|
||||||
true ->
|
true ->
|
||||||
ok = tcp_channel:cancel_rpc_call(ChannelPid, Ref),
|
ok = ssl_channel:cancel_rpc_call(ChannelPid, Ref),
|
||||||
{keep_state, State, [{reply, From, ok}]};
|
{keep_state, State, [{reply, From, ok}]};
|
||||||
false ->
|
false ->
|
||||||
{keep_state, State, [{reply, From, ok}]}
|
{keep_state, State, [{reply, From, ok}]}
|
||||||
@ -251,7 +251,7 @@ handle_event({call, From}, {pub, Topic, Qos, Content}, ?STATE_ACTIVATED, State =
|
|||||||
true ->
|
true ->
|
||||||
logger:debug("[iot_host] host: ~p, publish to topic: ~p, content: ~p", [UUID, Topic, Content]),
|
logger:debug("[iot_host] host: ~p, publish to topic: ~p, content: ~p", [UUID, Topic, Content]),
|
||||||
%% 通过websocket发送请求
|
%% 通过websocket发送请求
|
||||||
tcp_channel:pub(ChannelPid, Topic, Qos, Content),
|
ssl_channel:pub(ChannelPid, Topic, Qos, Content),
|
||||||
|
|
||||||
{keep_state, State, [{reply, From, ok}]};
|
{keep_state, State, [{reply, From, ok}]};
|
||||||
false ->
|
false ->
|
||||||
@ -265,7 +265,7 @@ handle_event({call, From}, {command, CommandType, Command}, ?STATE_ACTIVATED, St
|
|||||||
true ->
|
true ->
|
||||||
logger:debug("[iot_host] host: ~p, command_type: ~p, command: ~p", [UUID, CommandType, Command]),
|
logger:debug("[iot_host] host: ~p, command_type: ~p, command: ~p", [UUID, CommandType, Command]),
|
||||||
%% 通过websocket发送请求
|
%% 通过websocket发送请求
|
||||||
tcp_channel:command(ChannelPid, CommandType, Command),
|
ssl_channel:command(ChannelPid, CommandType, Command),
|
||||||
{keep_state, State, [{reply, From, ok}]};
|
{keep_state, State, [{reply, From, ok}]};
|
||||||
false ->
|
false ->
|
||||||
logger:debug("[iot_host] host: ~p, command_type: ~p, command: ~p, invalid state: ~p", [UUID, CommandType, Command, state_map(State)]),
|
logger:debug("[iot_host] host: ~p, command_type: ~p, command: ~p, invalid state: ~p", [UUID, CommandType, Command, state_map(State)]),
|
||||||
@ -277,7 +277,7 @@ handle_event({call, From}, {activate, true}, _, State = #state{uuid = UUID, chan
|
|||||||
case is_pid(ChannelPid) of
|
case is_pid(ChannelPid) of
|
||||||
true ->
|
true ->
|
||||||
logger:debug("[iot_host] uuid: ~p, activate: true", [UUID]),
|
logger:debug("[iot_host] uuid: ~p, activate: true", [UUID]),
|
||||||
tcp_channel:command(ChannelPid, ?COMMAND_AUTH, <<1:8>>);
|
ssl_channel:command(ChannelPid, ?COMMAND_AUTH, <<1:8>>);
|
||||||
false ->
|
false ->
|
||||||
logger:debug("[iot_host] uuid: ~p, activate: true, no channel", [UUID])
|
logger:debug("[iot_host] uuid: ~p, activate: true, no channel", [UUID])
|
||||||
end,
|
end,
|
||||||
@ -287,9 +287,9 @@ handle_event({call, From}, {activate, true}, _, State = #state{uuid = UUID, chan
|
|||||||
handle_event({call, From}, {activate, false}, _, State = #state{uuid = UUID, channel_pid = ChannelPid}) ->
|
handle_event({call, From}, {activate, false}, _, State = #state{uuid = UUID, channel_pid = ChannelPid}) ->
|
||||||
case is_pid(ChannelPid) of
|
case is_pid(ChannelPid) of
|
||||||
true ->
|
true ->
|
||||||
tcp_channel:command(ChannelPid, ?COMMAND_AUTH, <<0:8>>),
|
ssl_channel:command(ChannelPid, ?COMMAND_AUTH, <<0:8>>),
|
||||||
logger:debug("[iot_host] uuid: ~p, activate: false", [UUID]),
|
logger:debug("[iot_host] uuid: ~p, activate: false", [UUID]),
|
||||||
tcp_channel:stop(ChannelPid, closed);
|
ssl_channel:stop(ChannelPid, closed);
|
||||||
false ->
|
false ->
|
||||||
logger:debug("[iot_host] uuid: ~p, activate: false, no channel", [UUID])
|
logger:debug("[iot_host] uuid: ~p, activate: false, no channel", [UUID])
|
||||||
end,
|
end,
|
||||||
@ -347,7 +347,7 @@ handle_event(info, {timeout, _, heartbeat_ticker}, _, State = #state{uuid = UUID
|
|||||||
end,
|
end,
|
||||||
|
|
||||||
%% 关闭channel,主机需要重新连接,才能保存状态的一致
|
%% 关闭channel,主机需要重新连接,才能保存状态的一致
|
||||||
is_pid(ChannelPid) andalso tcp_channel:stop(ChannelPid, closed),
|
is_pid(ChannelPid) andalso ssl_channel:stop(ChannelPid, closed),
|
||||||
erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker),
|
erlang:start_timer(?HEARTBEAT_INTERVAL, self(), heartbeat_ticker),
|
||||||
|
|
||||||
{keep_state, State#state{channel_pid = undefined, has_session = false, heartbeat_counter = 0}};
|
{keep_state, State#state{channel_pid = undefined, has_session = false, heartbeat_counter = 0}};
|
||||||
|
|||||||
@ -19,8 +19,8 @@ start(_StartType, _StartArgs) ->
|
|||||||
%% 启动http服务
|
%% 启动http服务
|
||||||
start_http_server(),
|
start_http_server(),
|
||||||
|
|
||||||
%% 启动tcp服务
|
%% 启动ssl服务
|
||||||
start_tcp_server(),
|
start_ssl_server(),
|
||||||
|
|
||||||
iot_sup:start_link().
|
iot_sup:start_link().
|
||||||
|
|
||||||
@ -68,26 +68,31 @@ start_http_server() ->
|
|||||||
|
|
||||||
logger:debug("[http_server] the http server start at: ~p, pid is: ~p", [Port, Pid]).
|
logger:debug("[http_server] the http server start at: ~p, pid is: ~p", [Port, Pid]).
|
||||||
|
|
||||||
%% 启动tcp服务
|
%% 启动ssl服务
|
||||||
start_tcp_server() ->
|
start_ssl_server() ->
|
||||||
{ok, Props} = application:get_env(iot, tcp_server),
|
{ok, Props} = application:get_env(iot, ssl_server),
|
||||||
Acceptors = proplists:get_value(acceptors, Props, 50),
|
Acceptors = proplists:get_value(acceptors, Props, 50),
|
||||||
MaxConnections = proplists:get_value(max_connections, Props, 10240),
|
MaxConnections = proplists:get_value(max_connections, Props, 10240),
|
||||||
Backlog = proplists:get_value(backlog, Props, 1024),
|
Backlog = proplists:get_value(backlog, Props, 1024),
|
||||||
Port = proplists:get_value(port, Props),
|
Port = proplists:get_value(port, Props),
|
||||||
|
PrivDir = code:priv_dir(iot),
|
||||||
|
CertFile = filename:join([PrivDir, "ssl", "server.crt"]),
|
||||||
|
KeyFile = filename:join([PrivDir, "ssl", "server.key"]),
|
||||||
|
|
||||||
TransOpts = #{
|
TransOpts = #{
|
||||||
max_connections => MaxConnections,
|
max_connections => MaxConnections,
|
||||||
num_acceptors => Acceptors,
|
num_acceptors => Acceptors,
|
||||||
shutdown => brutal_kill,
|
shutdown => brutal_kill,
|
||||||
socket_opts => [
|
socket_opts => [
|
||||||
{nodelay, false},
|
{nodelay, true},
|
||||||
{backlog, Backlog},
|
{backlog, Backlog},
|
||||||
{port, Port}
|
{port, Port},
|
||||||
|
{certfile, CertFile},
|
||||||
|
{keyfile, KeyFile}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{ok, _} = ranch:start_listener(tcp_server, ranch_tcp, TransOpts, tcp_channel, []),
|
{ok, _} = ranch:start_listener(ssl_server, ranch_ssl, TransOpts, ssl_channel, []),
|
||||||
logger:debug("[iot_app] the tcp server start at: ~p", [Port]).
|
logger:debug("[iot_app] the ssl server start at: ~p", [Port]).
|
||||||
|
|
||||||
-spec ensure_mnesia_schema() -> any().
|
-spec ensure_mnesia_schema() -> any().
|
||||||
ensure_mnesia_schema() ->
|
ensure_mnesia_schema() ->
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
%%% @end
|
%%% @end
|
||||||
%%% Created : 11. 1月 2021 上午12:17
|
%%% Created : 11. 1月 2021 上午12:17
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-module(tcp_channel).
|
-module(ssl_channel).
|
||||||
-author("licheng5").
|
-author("licheng5").
|
||||||
-include("protocol.hrl").
|
-include("protocol.hrl").
|
||||||
-include("message_pb.hrl").
|
-include("message_pb.hrl").
|
||||||
@ -134,20 +134,6 @@ handle_cast({request_call, ReceiverPid, Ref, Body}, State = #state{transport = T
|
|||||||
inflight = maps:put(PacketId, RequestInfo, Inflight)
|
inflight = maps:put(PacketId, RequestInfo, Inflight)
|
||||||
}}.
|
}}.
|
||||||
|
|
||||||
%% auth验证
|
|
||||||
handle_info({tcp, Socket, <<?FRAME_REQUEST, FrameBin/binary>>}, State = #state{transport = Transport, socket = Socket}) ->
|
|
||||||
RequestFrame = message_pb:decode_msg(FrameBin, 'RequestFrame'),
|
|
||||||
handle_request_frame(RequestFrame, Transport, Socket, State);
|
|
||||||
|
|
||||||
handle_info({tcp, Socket, <<?FRAME_CAST, FrameBin/binary>>}, State = #state{socket = Socket, host_pid = HostPid}) ->
|
|
||||||
CastFrame = message_pb:decode_msg(FrameBin, 'CastFrame'),
|
|
||||||
handle_cast_frame(CastFrame, HostPid, State);
|
|
||||||
|
|
||||||
%% 主机端的消息响应
|
|
||||||
handle_info({tcp, Socket, <<?FRAME_RESPONSE, FrameBin/binary>>}, State = #state{socket = Socket, inflight = Inflight}) ->
|
|
||||||
ResponseFrame = message_pb:decode_msg(FrameBin, 'ResponseFrame'),
|
|
||||||
handle_response_frame(ResponseFrame, Inflight, State);
|
|
||||||
|
|
||||||
handle_info({timeout, TimerRef, {request_timeout, PacketId}}, State = #state{inflight = Inflight}) ->
|
handle_info({timeout, TimerRef, {request_timeout, PacketId}}, State = #state{inflight = Inflight}) ->
|
||||||
case maps:get(PacketId, Inflight, undefined) of
|
case maps:get(PacketId, Inflight, undefined) of
|
||||||
#inflight_request{ref = Ref, timer_ref = TimerRef} ->
|
#inflight_request{ref = Ref, timer_ref = TimerRef} ->
|
||||||
@ -157,14 +143,6 @@ handle_info({timeout, TimerRef, {request_timeout, PacketId}}, State = #state{inf
|
|||||||
{noreply, State}
|
{noreply, State}
|
||||||
end;
|
end;
|
||||||
|
|
||||||
handle_info({tcp_error, Sock, Reason}, State = #state{socket = Sock}) ->
|
|
||||||
logger:notice("[sdlan_channel] tcp_error: ~p", [Reason]),
|
|
||||||
{stop, normal, State};
|
|
||||||
|
|
||||||
handle_info({tcp_closed, Sock}, State = #state{socket = Sock}) ->
|
|
||||||
logger:notice("[sdlan_channel] tcp_closed"),
|
|
||||||
{stop, normal, State};
|
|
||||||
|
|
||||||
%% 关闭当前通道
|
%% 关闭当前通道
|
||||||
handle_info({stop, Reason}, State) ->
|
handle_info({stop, Reason}, State) ->
|
||||||
{stop, Reason, State};
|
{stop, Reason, State};
|
||||||
@ -174,8 +152,35 @@ handle_info({'DOWN', _, process, HostPid, Reason}, State = #state{uuid = UUID, h
|
|||||||
logger:debug("[ws_channel] uuid: ~p, channel will close because host exited with reason: ~p", [UUID, Reason]),
|
logger:debug("[ws_channel] uuid: ~p, channel will close because host exited with reason: ~p", [UUID, Reason]),
|
||||||
{stop, State};
|
{stop, State};
|
||||||
|
|
||||||
|
handle_info({ssl, Socket, <<?FRAME_REQUEST, FrameBin/binary>>},
|
||||||
|
State = #state{transport = Transport, socket = Socket}) ->
|
||||||
|
RequestFrame = message_pb:decode_msg(FrameBin, 'RequestFrame'),
|
||||||
|
handle_request_frame(RequestFrame, Transport, Socket, State);
|
||||||
|
|
||||||
|
handle_info({ssl, Socket, <<?FRAME_CAST, FrameBin/binary>>},
|
||||||
|
State = #state{socket = Socket, host_pid = HostPid}) ->
|
||||||
|
CastFrame = message_pb:decode_msg(FrameBin, 'CastFrame'),
|
||||||
|
handle_cast_frame(CastFrame, HostPid, State);
|
||||||
|
|
||||||
|
handle_info({ssl, Socket, <<?FRAME_RESPONSE, FrameBin/binary>>},
|
||||||
|
State = #state{socket = Socket, inflight = Inflight}) ->
|
||||||
|
ResponseFrame = message_pb:decode_msg(FrameBin, 'ResponseFrame'),
|
||||||
|
handle_response_frame(ResponseFrame, Inflight, State);
|
||||||
|
|
||||||
|
handle_info({ssl_closed, Socket}, State = #state{socket = Socket}) ->
|
||||||
|
logger:notice("[sdlan_channel] ssl socket closed"),
|
||||||
|
{stop, normal, State};
|
||||||
|
|
||||||
|
handle_info({ssl_error, Socket, Reason}, State = #state{socket = Socket}) ->
|
||||||
|
logger:notice("[sdlan_channel] ssl socket error: ~p", [Reason]),
|
||||||
|
{stop, normal, State};
|
||||||
|
|
||||||
|
handle_info({ssl_passive, Socket}, State = #state{transport = Transport, socket = Socket}) ->
|
||||||
|
ok = Transport:setopts(Socket, [{active, true}]),
|
||||||
|
{noreply, State};
|
||||||
|
|
||||||
handle_info(Info, State) ->
|
handle_info(Info, State) ->
|
||||||
logger:warning("[sdlan_channel] get a unknown message: ~p, channel will closed, state: ~p", [Info, State]),
|
logger:warning("[sdlan_channel] get a unknown message: ~p, state: ~p", [Info, State]),
|
||||||
{noreply, State}.
|
{noreply, State}.
|
||||||
|
|
||||||
terminate(Reason, #state{}) ->
|
terminate(Reason, #state{}) ->
|
||||||
@ -265,14 +270,14 @@ handle_cast_frame(#'CastFrame'{body = {event_stream, CastMessage}}, HostPid, Sta
|
|||||||
handle_event_stream_frame(CastMessage),
|
handle_event_stream_frame(CastMessage),
|
||||||
{noreply, State};
|
{noreply, State};
|
||||||
handle_cast_frame(#'CastFrame'{body = Body}, _HostPid, State) ->
|
handle_cast_frame(#'CastFrame'{body = Body}, _HostPid, State) ->
|
||||||
logger:warning("[tcp_channel] unsupported cast message type: command, body: ~p", [Body]),
|
logger:warning("[ssl_channel] unsupported cast message type: command, body: ~p", [Body]),
|
||||||
{noreply, State}.
|
{noreply, State}.
|
||||||
|
|
||||||
-spec handle_event_stream_frame(message_pb:'TaskEventStream'()) -> any().
|
-spec handle_event_stream_frame(message_pb:'TaskEventStream'()) -> any().
|
||||||
handle_event_stream_frame(#'TaskEventStream'{task_id = TaskId, type = Type0, stream = Reason0}) when Type0 =:= <<"close">> ->
|
handle_event_stream_frame(#'TaskEventStream'{task_id = TaskId, type = Type0, stream = Reason0}) when Type0 =:= <<"close">> ->
|
||||||
iot_event_stream_observer:stream_close(TaskId, iolist_to_binary(Reason0));
|
iot_event_stream_observer:stream_close(TaskId, iolist_to_binary(Reason0));
|
||||||
handle_event_stream_frame(#'TaskEventStream'{task_id = TaskId, type = Type, stream = Stream}) ->
|
handle_event_stream_frame(#'TaskEventStream'{task_id = TaskId, type = Type, stream = Stream}) ->
|
||||||
logger:debug("[tcp_channel] get task_id: ~p, type: ~ts, stream: ~ts", [TaskId, Type, Stream]),
|
logger:debug("[ssl_channel] get task_id: ~p, type: ~ts, stream: ~ts", [TaskId, Type, Stream]),
|
||||||
iot_event_stream_observer:stream_data(TaskId, Type, Stream).
|
iot_event_stream_observer:stream_data(TaskId, Type, Stream).
|
||||||
|
|
||||||
-spec handle_response_frame(message_pb:'ResponseFrame'(), map(), #state{}) ->
|
-spec handle_response_frame(message_pb:'ResponseFrame'(), map(), #state{}) ->
|
||||||
@ -1,37 +0,0 @@
|
|||||||
# 待完成
|
|
||||||
|
|
||||||
## 1. endpoint需要存储在数据库
|
|
||||||
```mysql
|
|
||||||
|
|
||||||
CREATE TABLE `endpoint` (
|
|
||||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
|
||||||
`name` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '名称,路由时基于名称',
|
|
||||||
`title` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '序列号',
|
|
||||||
`type` varchar(60) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '类型',
|
|
||||||
`config_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '配置信息,基于json格式存储',
|
|
||||||
`status` smallint NOT NULL DEFAULT '-1',
|
|
||||||
`creator` smallint NOT NULL DEFAULT '0' COMMENT '创建人',
|
|
||||||
`created_at` timestamp NULL DEFAULT NULL,
|
|
||||||
`updated_at` timestamp NULL DEFAULT NULL,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_name` (`name`)
|
|
||||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. service_config微服务配置也要存在于数据库
|
|
||||||
```mysql
|
|
||||||
CREATE TABLE `service_config` (
|
|
||||||
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
|
|
||||||
`service_id` bigint unsigned NOT NULL COMMENT '服务的id',
|
|
||||||
`host_uuid` varchar(191) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '名称,路由时基于名称',
|
|
||||||
`config_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '配置信息,基于json格式存储',
|
|
||||||
`last_config_json` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci COMMENT '配置信息,基于json格式存储',
|
|
||||||
`creator` smallint NOT NULL DEFAULT '0' COMMENT '创建人',
|
|
||||||
`created_at` timestamp NULL DEFAULT NULL,
|
|
||||||
`updated_at` timestamp NULL DEFAULT NULL,
|
|
||||||
PRIMARY KEY (`id`),
|
|
||||||
KEY `idx_service_id` (`service_id`)
|
|
||||||
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
||||||
|
|
||||||
|
|
||||||
```
|
|
||||||
Loading…
x
Reference in New Issue
Block a user