1000億のプラットフォームテクノロジーアーキテクチャ:高い同時実行性をサポートするために、IDカードをJSに保存しました

@


前の記事 「1000億のインターネットプラットフォームの背後にあるもの-月を覆うために青い空に行きたい」の次に 、今日はユーザーのプライバシーについて話します。

時代とインターネットの発達に伴い、人々は個人のプライバシーにますます注意を払っていますが、個人情報の漏洩や悪用の問題は依然として一般的です。以前は、「中国の個人情報セキュリティとプライバシー保護レポート」が100万件の調査データを抽出し、80%のユーザーがプライバシー漏洩に遭遇しました。たとえば、マリオットは18年間で3億8,300万件のプライバシーデータ漏洩に見舞われ、2020年3月31日に再び爆発しました。 520万人の顧客情報のうち、漏えいしたもの。この理由は、あまり話さないためですが、一部の悪意のある企業の悪意のある行動は別として、多くの商業的利益があるはずです。今日私たちは開発者がユーザーのプライバシーをどのように扱うべきかについて話します。約半年前を知っていた省のプートンファ能力試験クエリシステムの開発者がjsに直接IDカードを書いたことを思い出しました。一部のネチズンはこれが本当だと笑いましたフロントエンドとバックエンドの分離は、10億レベルの同時実行性をサポートするものではありません
記事が始まる前に、小さな質問が投げかけられます:名前、IDカード、銀行カード、携帯電話番号に加えて、暗号化して保存する必要があるユーザーの機密情報は何だと思いますか?ここに画像の説明を挿入

個人情報とは何ですか?機密情報とは何ですか?個人情報はどのように保存および表示する必要がありますか?ゲームの機密情報にある引き換えコードはありますか?宿泊情報は機密ですか?優れた開発者として、私たちは単にコードに集中するだけでなく、これを行うために常に製品マネージャーやプロジェクトマネージャーであるとは限りません。また、私たちを強化し、拡大するために、法律やポリシー規制を含む業界のビジネス知識を習得する必要もあります。ビジネス知識面。

1.ユーザー情報セキュリティ仕様

情報システムの構築に関しては、国の標準や全文開示システム(http://openstd.samr.gov.cn/)など、国や業界には実際に多くの規格や規制があります。個人情報に関しては、最新が今年発表された「GB / T 35273-2020情報セキュリティ技術-個人情報セキュリティ仕様書」で、2020-10-01正式に実装さ、旧規格であるGB / T 35273-2017に置き換わります。仕様書全体は主に7つの原則を具体化します:全会一致の権限と責任の原則、明確な目的の原則、選択と同意の原則、最低限の原則、開放性と透明性の原則、安全性の確保の原則、および対象者の参加の原則
ここに画像の説明を挿入

1.1ユーザー情報と機密情報の定義と判断基準

1.1.1個人情報

個人情報。電子またはその他の方法で記録された、特定の自然人の身元を識別したり、特定の自然人の活動を単独または他の情報と組み合わせて反映したりできるあらゆる種類の情報を指します。

判定方法

  1. 識別:情報から個人まで、特定の自然人は、情報自体の特殊性によって識別されます。個人情報は、特定の個人の識別に役立つはずです。
  2. 相関関係:個人から情報まで、特定の自然人が知られている場合、その活動において特定の自然人によって生成される情報(個人の位置情報、個人の通話記録、個人の閲覧記録など)は個人情報です。
    上記の2つの状況のいずれかを満たす情報は、個人情報として決定する必要があります。

個人情報の例個人情報の例
:個人情報またはユーザーの肖像画や機能タグなどのその他の情報を処理して個人情報管理者が作成した情報は、特定の自然人の身元を識別したり、特定の自然人の活動を単独で、または他の情報と組み合わせて反映したりできます。また、個人情報に属します。

1.1.2個人の機密情報

個人の機密情報。漏えいしたり、違法に提供されたり、誤用されたりすると、個人および財産の安全を脅かし、個人の評判、身体的および精神的健康、または差別的扱いに損害を与える可能性のある個人情報を指します。一般に、14歳未満の子供の個人情報(包括的)および自然人のプライバシーに関連する情報は、個人の機密情報です。

判定方法

  1. 漏洩:個人情報が漏洩すると、個人情報の本体、および個人情報を収集および使用する組織や機関が個人情報を管理する能力を失い、その結果、制御不能な範囲および個人情報の使用につながります。漏洩後、一部の個人情報は、個人情報の主体の意向または他の情報との関連分析に反する方法で直接使用され、個人情報の主体の権利と利益に重大なリスクをもたらす可能性があり、個人の機密情報として決定する必要があります。例えば、個人情報本体のIDカードのコピーは、携帯電話番号カードや銀行口座開設カードの実名登録などに利用されます。

  2. 違法規定:一部の個人情報は、個人情報主体の許可された同意の範囲外に広がっているため、個人情報主体の権利と利益に重大なリスクをもたらすだけであり、個人の機密情報として決定される必要があります。たとえば、性的指向、寄託情報、感染症の病歴など。

  3. 誤用:特定の個人情報が許可の合理的な制限を超えて使用される場合(処理目的の変更、処理範囲の拡大など)、個人情報主体の権利と利益に重大なリスクをもたらす可能性があるため、個人の機密情報として決定する必要があります。例えば、個人情報本体の認証が得られない場合、保険会社のマーケティングや個人保険料の高低の判断に利用されます。

個人の機密情報の
個人の機密情報の例
例注:個人情報または他の情報の処理を通じて個人情報管理者によって形成された情報(一度漏洩、違法に提供または乱用されたGB / T 35273-20206など)は、個人および財産の安全を危険にさらし、簡単に個人の評判につながる可能性があります、身体的および精神的健康が損なわれている、または差別的な扱いなどが、個人の機密情報である。

1.2ユーザー情報の保存に関する注意

  1. 個人情報の保存期間は最小限に抑えられ、個人情報の保存期間が過ぎた後は、個人情報を削除または匿名化する必要があります。
  2. 個人の機密情報を送信および保存する場合は、暗号化およびその他のセキュリティ対策を採用する必要があります。暗号化技術を使用する場合は、関連する国家規格のパスワード管理に従うことをお勧めします。
  3. 個人の生体情報は個人の身元情報とは別に保存する必要があります
  4. 原理的には、元の個人の生体情報(例えば、サンプル、画像、等)に格納されるべきではない、処置を行うことができ、これらに限定されないが:だけ要約情報は、個人の生体識別情報が格納され、直接個人生体情報収集端末識別情報を使用して実装、認証およびその他の機能;顔認識機能、指紋、掌紋、虹彩などを使用して識別、認証およびその他の機能を実現した後、個人の生体情報を抽出できる元の画像を削除します。

仕様書全体には、ユーザー情報の使用、表示、サードパーティアクセス、セキュリティ管理などについても記載されており、興味のあるパートナーは自分で検索できます。

第二に、フレームワーク技術の実装

2.1ユーザーの機密情報の自動暗号化および復号化

最初の章で述べたように、ユーザーの本名、携帯電話番号、銀行カード番号、および宿泊施設を含む機密情報は、暗号化されてデータベースに保存され、ビジネスが通常使用されているときにプレーンテキストデータに変換される必要があります。技術的な実装の観点から見ると、追加、編集、および照会時の復号化は暗号化にすぎません。そのような操作はまだ比較的少なく、ある日、新しいメソッドが追加され、暗号化および復号化を忘れていた可能性があります。そのため、それらのほとんどはフレームワークを介して実装され、実装の原則は反射マシン+インターセプターに過ぎません。次に、Mybatisを例にとります。具体的な参照として、原則は次のとおりです。https://blog.csdn.net/weixin_39494923/article/details/91534658
ここに画像の説明を挿入

2.1.1 Interceptorによるデータの自動暗号化および復号化

Mybatisはインターセプターインターフェイスをデフォルトで提供します。Interceptorはデフォルトで、Mybatisの拡張ツールのほとんどはこのインターフェイスを通じて実装されます。カスタムインターセプターを実装する場合は、3つのメソッドを持つorg.apache.ibatis.plugin.Interceptorインターフェースを実装するだけで済みます。

Object intercept(Invocation invocation) throws Throwable;

Object plugin(Object target);

void setProperties(Properties properties);

まず、フィールドに作用するカスタムアノテーション@Cryptを使用して、フィールドを暗号化および復号化する必要があることをインターセプターに伝えます。

@Target({ ElementType.FIELD,ElementType.TYPE })
@Retention(RetentionPolicy.RUNTIME)
public @interface Crypt {

}

次に、カスタムインターセプターを追加し、selelctメソッドを使用する場合は復号化し、updateメソッドとaddメソッドを使用する場合は暗号化します。

@Intercepts({ @Signature(type = Executor.class, method = "update", args = { MappedStatement.class, Object.class, }),
        @Signature(type = Executor.class, method = "query", args = { MappedStatement.class, Object.class,
                RowBounds.class, ResultHandler.class }),
        @Signature(type = ResultSetHandler.class, method = "handleResultSets", args = { Statement.class }) })
public class CryptInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        if (args.length <= 0 || invocation.getMethod() == null || args[0] == null) {
            return invocation.proceed();
        }

        String methodName = invocation.getMethod().getName();
        if ("update".equals(methodName) && args[1] != null) {
            return this.interceptUpdate(invocation);
        } else if ("query".equals(methodName) && args[1] != null) {
            return this.interceptQuery(invocation);
        } else if ("handleResultSets".equals(methodName)) {
            return this.interceptHandleResultSets(invocation);
        }
        return invocation.proceed();
    }

    private Object interceptHandleResultSets(Invocation invocation) throws Throwable {
        Object resultCollection = invocation.proceed();
        // 略 将resultCollection的对象中有@Crypt注解的Feild进行解密
        return newObject;
    }

    private Object interceptUpdate(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        Object args1Obj = args[1];
        // 略 将args1Obj的对象进行加密
        args[1] = newObject;
        return invocation.proceed();
    }
    
    private Object interceptQuery(Invocation invocation) throws Throwable {
        Object[] args = invocation.getArgs();
        Object condition = args[1];
        // 略 将condition对象进行解密
        args[1] = newObject;
        return invocation.proceed();
    }    
}

2.1.2 BaseTypeHandlerによるデータの自動暗号化および復号化

通常の状況では、「読み取りと書き込みの分離」などの複雑な要件がない限り、InterceptorインターフェースはMybatisリクエストをインターセプトしません。上記のmybatisの実行プロセスを見ると、TypeHanderと呼ばれる最後のステップであることがわかりました。このクラスの役割は、データベースとエンティティの間の型変換(MySql varcharからJava Longへの変換、Java IntegerからMysqlへの変換など)を実行することです。 intなので、BaseTypeHandlerクラスを使用できます。

@Component
@Alias("CryptHandler")
@MappedTypes(value = {Crypt.class})
public class EncryptHandler extends BaseTypeHandler {

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, Object parameter, JdbcType jdbcType)
        throws SQLException {
        ps.setString(i, encrypt(parameter.toString()));
    }

    @Override
    public String getNullableResult(ResultSet rs, String columnName) throws SQLException {
        String columnValue = rs.getString(columnName);
        return decrypt(columnValue);
    }

    @Override
    public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
        String columnValue = rs.getString(columnIndex);
        return decrypt(columnValue);
    }

    @Override
    public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
        String columnValue = cs.getString(columnIndex);
        return decrypt(columnValue);
    }

    private String encrypt(String parameter) {
        // 加密
        return parameter;
    }

    private String decrypt(String columnValue) {
        // 解密
        return columnValue;
    }
}

完全なコードについては上記を参照してください。詳細については説明しません。次に、暗号化と復号化が必要なフィールドをMybatisに通知する必要があります。書き込みを簡単にするために、cryptに名前を変更したクラスCryptを定義します。上記のクラスEncryptHandlerもEncryptHandlerに名前変更しました

@Alias("crypt")
public final class Crypt {

}

上記の2つのクラスはcn.itmds.pluginディレクトリーに配置されます。構成ymlファイルは、名前変更された構成を読み取るようにMybatisに指示します

mybatis:
  type-aliases-Package: cn.itmds.plugin.dbcrypt
 

次に、実名(実名)フィールドを暗号化および復号化する必要があるメンバーテーブルがあると仮定すると、非常に簡単に記述できます。

 <sql id="memberConditionSql">
        <where>
            <if test="id != null">and id = #id}</if>
            <!--这个地方只需要指定javaType=crypt,如果上面没有重命名,这个地方需要写成javaType= cn.itmds.plugin.dbcrypt.Crypt,写起来比较麻烦 -->
            <if test=realName != null">and real_name = #{realName,javaType=crypt}</if>
        </where>
    </sql>
    <resultMap id="memberDOResultMap" type="MemberDO">
        <!--这个地方只需要指定typeHandler=CryptHandler,如果上面没有重命名,这个地方需要写成javaType= cn.itmds.plugin.dbcrypt.CryptHandler,写起来比较麻烦 -->
        <!--另外,只需要将需要解密的字段写到这个resultMap里即可,不需要写全部的字段,其他字段系统会自动映射为MemberDO -->
        <result column="phone" property="phone" typeHandler="CryptHandler"/>
    </resultMap>

2.1.3 MybatisPlusはデータの自動暗号化と復号化を実現します

MyBatis-Plus(略称MP)は、MyBatisの拡張ツールです。MyBatisをベースに、拡張のみが行われ、変更は行われません。開発を簡素化し、効率を向上させるために生まれました。

シンプルな構成で、MyBatis-PlusはCRUD操作をすばやく実行でき、時間を大幅に節約できます。また、オブジェクトを介したLambda式、SQLの操作などもサポートしているため、ますます多くの人々がそれを使用しています。では、データの自動暗号化と復号化を超シンプルに実現するにはどうすればよいでしょうか。実現原理は2.1.2と同じですが、BaseTypeHandlerを使用することもできます。

1.が追加@TableField(typeHandler = EncryptHandler.class)されました。EncryptHandlerは2.1.2で定義されてEncryptHandler.javaおり、追加または変更時に自動暗号化が実装されます。
2. @TableName autoResultMap = true設定します。この時点で、戻り値の自動復号が実現されます。

できた!例:

@Data
@TableName(value = "user_info",autoResultMap = true)
public class UserPO {

    /**  */
    @TableId(type = IdType.AUTO)
    private Long id;

    /** 真实姓名 */
    @TableField(typeHandler = EncryptHandler.class)
    private String realName;
}

2.2ログファイルはユーザーの機密情報を自動的にフィルタリングします

開発とデバッグを容易にし、生産ラインの問題を特定するために、開発フレームワークは基本的にログインターセプターを定義し、コントローラーレイヤーとサービスレイヤーのすべてのメソッドをインターセプトし、詳細な入出力パラメーターを出力します。2.1では、ユーザーの機密情報の暗号化と復号化はdaoの下部で自動的に行われるため、ユーザーの機密情報がログに出力されるので、現時点ではどのように対処すればよいでしょうか。次に、完全なケースを提供します。

  1. クラスまたはメソッドに適用できるアノテーション@ServiceLogを定義します。パラメータを指定します。無視してください。デフォルトはfalseです。trueの場合、メソッドはログを出力する必要がないことを意味します。たとえば、特定のクラスにはログを必要とする多くのメソッドがありますが、メソッドの1つはファイルのアップロードに使用されるか、スケジュールされたタスクは1秒に1回実行されます。これらのシナリオでは、ログを印刷する必要がないため、ignore = trueを設定できます。
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ServiceLog {

    boolean ignore() default false;
}
  1. グローバルインターセプターを定義し、ログを印刷および出力し、FastJsonを使用してオブジェクトを文字列に変換します。
@Aspect
@Component
public class ServiceLogAspect {

	@Around("@within(cn.itmds.log.ServiceLog)")
    protected Object aroundJoinPoint(ProceedingJoinPoint joinPoint) throws Throwable {
        MethodSignature signature = (MethodSignature)joinPoint.getSignature();
        Method method = signature.getMethod();
        ServiceLog serviceLog = method.getAnnotation(ServiceLog.class);
        if (null != serviceLog && serviceLog.ignore()) {
            return joinPoint.proceed();
        }
        long beginTime = System.currentTimeMillis();
        Class clazz = joinPoint.getTarget().getClass();
        String methodName = clazz.getSimpleName() + "." + method.getName();
        // 打印请求所有的入参
        log.info("Begin|{}|{}", methodName, jsonString(joinPoint.getArgs()));

        Object result = null;
        try {
            result = joinPoint.proceed();
        } finally {
        	// 打印所有的出参
            log.info("End|{}|{}ms|{}", methodName, System.currentTimeMillis(),
            	 - beginTime, jsonString(result));
        }
        return result;
    }
}

  1. 構成アイテムを追加して、本名、携帯電話番号、IDカード、パスワードなど、フィルタリングする機密情報を定義します。
logging:
  sensitiveChars: realName,phoneNumber,idCard,mail,password
  1. 次に、FastJSONのフィルター機能を使用して、ログをフィルター処理できます。
    private ValueFilter valueFilter = (object, name, value) -> {
        if (null == value || "".equals(value)) {
            return value;
        }

        if (value instanceof byte[]) {
            // 如果是byte字节,直接打印长度
            return "byte length:" + ((byte[])value).length;
        } else if (value instanceof String) {
            // 在该方法里检查name,如果name包含我们配置的敏感信息,则将value设置为加*隐藏。
            return stringValueProcess(name, (String)value);
        } else {
            return value;
        }
    };

2番目のステップのインターセプターメソッドのaboutJoinPointでは、オブジェクトがStringに変換されるときに、FastJSONフィルターが使用されます。

    protected String jsonString(Object object) {
        return JSON.toJSONString(object, valueFilter);
    }
  1. コントローラレイヤも同様で、コントローラディレクトリ内のすべてのファイルをインターセプトします。
@Around("execution(public * cn.itmds.controller..*(..) )")

コントローラはこのメソッドに注意を払う必要があります。httpリクエストとレスポンスリクエストの一部のフィールドはシリアル化できないため、フィルタリングする必要があります。

public static <T> Stream<T> streamOf(T[] array) {
        return ArrayUtils.isEmpty(array) ? Stream.empty() : Arrays.asList(array).stream();
    }

//... 拦截器的方法中增加过滤
 List<Object> logArgs = (List)streamOf(args).filter((arg) -> {
                return !(arg instanceof HttpServletRequest) && !(arg instanceof HttpServletResponse);
            }).collect(Collectors.toList());
// 打印请求所有的入参
log.info("Begin|{}|{}", methodName, jsonString(logArgs));

2.3パスワードの暗号化と「パスワード法」

パスワードについては、最近推進されていると思われる「暗号法」もあります。もちろん、私たちが通常言う「パスワード」というユーザー名は、暗号化における「パスワード」ではなく、単なる「パスワード」です。「パスワード法」におけるパスワードの利用範囲には、第2世代IDカード、電子署名、VAT請求書パスワード欄などが含まれますが、詳しくは本文全文をご覧いただき、口外しないでください。
ここに画像の説明を挿入

2.3.1パスワード暗号化に関する注意

今日の開発者は基本的に一定のセキュリティ知識を持っています。プレーンテキストで保存されるパスワードはほとんどなく、直接のmd5もわずかです。それらのほとんどはsha1とsha256を使い始めており、一部の企業はArgon2を使い始めています。

Argon2は低速ハッシュ関数で、2015年にパスワードハッシュコンペティションで優勝しました。多くのメモリ計算を使用してGPUやその他のカスタムハードウェアのクラックに抵抗し、ハッシュ結果のセキュリティを向上させています。

ここにいくつかのポイントがあります:

  1. 同じパスワードでも異なるハッシュが生成されるようにするには、各パスワードに異なるソルトを追加する必要があります。たとえば、両方の人のパスワードはabcd1234であり、生成されるハッシュは異なる必要があります。
  2. 通常のランダムアルゴリズムを使用してソルトを生成しないでください。CSPRNG(暗号化された安全な擬似乱数ジェネレーター)を使用する必要があります。対応するjavaは、C / C ++ CryptGenRandomに対応するJava.security.SecureRandomです。
  3. 一部のシステムでは、ユーザーのID、携帯電話番号などをソルト暗号化パスワードとして使用しますが、ソルト生成ルールを満たしていません。ただし、サイトの一般的なセキュリティ要件はそれほど高くないため、基本的には使用できます。

2.3.2 BCryptを使用してパスワード暗号化を実装する

Bcryptはクロスプラットフォームのファイル暗号化ツールであり、SpringSecurityはデフォルトでこのアルゴリズムを使用します。SpringSecurityがプロジェクトにない場合は、jarパッケージを個別にインポートすることもできます。bcryptアルゴリズムとmd5 / shaアルゴリズムには大きな違いがあります。つまり、毎回生成されるハッシュ値は異なり、saltを自分で指定する必要はありません。暗号化後の文字長は比較的長く、60ビットです。データベースフィールドを設計するときは注意が必要です。次に例を示します。

    public static void main(String[] args) {
        BCryptPasswordEncoder bcrypt = new BCryptPasswordEncoder();
        String pwd = "abcd1234";
        for (int i = 0; i < 5; i++) {
            String encodePwd = bcrypt.encode(pwd);
            boolean result = bcrypt.matches(pwd, encodePwd);
            System.out.println(encodePwd + "|" + result);
        }
    }

ここに画像の説明を挿入
暗号化された文字列値作曲

  • $は意味のない区切り文字です。
  • 2aはbcrypt暗号化バージョン番号です。
  • 10はコストの値です。
  • 次の文字列では、最初の22桁がソルト値で、次の文字列はパスワードの暗号文です。

興味のある人はソースコードを見ることができます

public static String gensalt(int log_rounds, SecureRandom random) {
		if (log_rounds < MIN_LOG_ROUNDS || log_rounds > MAX_LOG_ROUNDS) {
			throw new IllegalArgumentException("Bad number of rounds");
		}
		StringBuilder rs = new StringBuilder();
		byte rnd[] = new byte[BCRYPT_SALT_LEN];

		random.nextBytes(rnd);

		rs.append("$2a$");
		if (log_rounds < 10) {
			rs.append("0");
		}
		rs.append(log_rounds);
		rs.append("$");
		encode_base64(rnd, rnd.length, rs);
		return rs.toString();
	}

2.3.3 Dropboxパスワード暗号化ストレージの防止

Dropboxは、ファイルのオンラインストレージを提供する有名なメーカーです。「Dropboxがパスワードを安全に保存する方法」と呼ばれる記事を公式テクニカルブログに掲載し、ユーザーのパスワード暗号化ストレージソリューションについて説明しています。
ここに画像の説明を挿入

  1. 最初に、sha512を使用してユーザーパスワードを64バイトのハッシュ値に正規化します。2つの理由:1つはBcryptが入力に敏感であることです。ユーザーが入力したパスワードが長いと、Bcryptが遅くなりすぎて応答時間が遅くなる可能性があります。もう1つは、Bcryptアルゴリズムの実装によっては、長い入力を直接72バイトに切り捨てるというものです情報理論の観点からは、これによりユーザー情報のエントロピーが小さくなります。
  2. 次に、Bcryptアルゴリズムを使用します。Bcryptを選択する理由は、Dropboxエンジニアがこのアルゴリズムに精通しており、チューニングの経験が豊富であるためです。パラメータ選択の標準は、DropboxのオンラインAPIサーバーが約100ミリ秒で結果を計算できるためです。さらに、BcryptとScryptのどちらのアルゴリズムが優れているかについても、暗号学者は決定的ではありません。同時に、Dropboxはパスワードハッシュアルゴリズムの新人Argon2にも注目しており、適切なタイミングで導入されると述べています。
  3. 最後にAES暗号化を使用します。Bcryptは完全なアルゴリズムではないため、DropboxはAESとグローバルキーを使用して、パスワードクラッキングのリスクをさらに減らします。キーの漏洩を防ぐために、Dropboxは専用のキーストレージハードウェアを使用しています。Dropboxは、最後にAES暗号化を使用するもう1つの利点についても言及しました。つまり、キーを定期的に変更して、ユーザー情報/キーの漏洩のリスクを減らすことができます。

ユーザーのプライバシー保護は、開発者による暗号化と復号化ほど単純なものではありませんが、運用、運用、保守チームのあらゆる側面からの協力も必要です。

[人々は常に自分のプライバシースペースを離れる必要があります。たとえば、あなたは常にあなたの影の前に立ち、光の視界を遮ります。]
人々は常にあなた自身にいくつかのプライバシースペースを与えたいと思います。あなたの影が光の視線を遮っています。

リファレンス:
https : //www.cnblogs.com/xinzhao/p/6035847.html
https://blog.csdn.net/weixin_39494923/article/details/91534658


建築家、10年間栄[コード]、古い[おじ]開花。パーソナルWeChat:qiaojs。アーキテクチャ設計、ビッグデータ、マイクロサービス、テクノロジー管理に重点を置いています。
ここに画像の説明を挿入

おすすめ

転載: www.cnblogs.com/madashu/p/12735752.html